From addc537a8809006adb4093025f7f2bd6d4ae1390 Mon Sep 17 00:00:00 2001 From: Tim Marman Date: Tue, 28 Jul 2026 07:27:56 -0700 Subject: [PATCH 01/99] feat(acp): bridge OASF agents over A2A Signed-off-by: Tim Marman --- .github/workflows/ci.yml | 4 +- .github/workflows/linux-canary.yml | 2 +- .github/workflows/release.yml | 8 +- .github/workflows/signed-macos-canary.yml | 2 +- .github/workflows/windows-canary.yml | 2 +- Cargo.lock | 18 + Cargo.toml | 1 + Justfile | 13 +- crates/buzz-a2a-acp/Cargo.toml | 32 + crates/buzz-a2a-acp/README.md | 95 + crates/buzz-a2a-acp/src/lib.rs | 2096 +++++++++++++++++++++ crates/buzz-a2a-acp/src/main.rs | 6 + crates/buzz-acp/src/acp.rs | 19 + crates/buzz-acp/src/config.rs | 7 + crates/sprig/Cargo.toml | 1 + crates/sprig/src/main.rs | 5 +- desktop/src-tauri/tauri.conf.json | 1 + scripts/build-sprig.sh | 6 +- scripts/bundle-sidecars.sh | 4 +- 19 files changed, 2303 insertions(+), 19 deletions(-) create mode 100644 crates/buzz-a2a-acp/Cargo.toml create mode 100644 crates/buzz-a2a-acp/README.md create mode 100644 crates/buzz-a2a-acp/src/lib.rs create mode 100644 crates/buzz-a2a-acp/src/main.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd18179ee4..d1306901bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -901,6 +901,7 @@ jobs: -p buzz-relay \ -p buzz-acp \ -p buzz-agent \ + -p buzz-a2a-acp \ -p buzz-dev-mcp \ -p git-credential-nostr \ -p git-sign-nostr @@ -939,7 +940,7 @@ jobs: shell: bash run: | mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}.exe" done - name: Clippy (workspace) @@ -1013,6 +1014,7 @@ jobs: mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-a2a-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 18d476e400..5e0a52bcad 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -166,7 +166,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh - name: Build Linux Tauri app diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c613924e57..896650c22f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. @@ -353,7 +353,7 @@ jobs: - name: Build sidecars run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" - name: Build unsigned Tauri app @@ -611,7 +611,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh - name: Generate release config @@ -773,7 +773,7 @@ jobs: - name: Build sidecars shell: bash run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" - name: Build Windows NSIS installer (unsigned) diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index fb0656028a..1a7bff0072 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -93,7 +93,7 @@ jobs: - name: Build sidecars run: | - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh # Mesh rev derived from Cargo.lock (no lockstep edit on dep bump); cache key tracks it. diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 29f74fa0f6..192da231d5 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -122,7 +122,7 @@ jobs: - name: Build sidecars shell: bash run: | - cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" - name: Build Windows NSIS installer (unsigned) diff --git a/Cargo.lock b/Cargo.lock index 3b60dc4579..4c1e8c960f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -760,6 +760,23 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "buzz-a2a-acp" +version = "0.1.0" +dependencies = [ + "base64", + "clap", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "url", + "uuid", +] + [[package]] name = "buzz-acp" version = "0.1.0" @@ -8323,6 +8340,7 @@ dependencies = [ name = "sprig" version = "0.1.0" dependencies = [ + "buzz-a2a-acp", "buzz-acp", "buzz-agent", "buzz-dev-mcp", diff --git a/Cargo.toml b/Cargo.toml index 3ac7ee4cce..f8b07208f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/buzz-audit", "crates/buzz-acp", "crates/buzz-agent", + "crates/buzz-a2a-acp", "crates/sprig", "crates/buzz-test-client", "crates/buzz-ws-client", diff --git a/Justfile b/Justfile index bcef8983bc..2f27dfa9fd 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,7 @@ _ensure-sidecar-stubs: set -euo pipefail TARGET=$(rustc -vV | sed -n 's|host: ||p') mkdir -p desktop/src-tauri/binaries - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -236,6 +236,7 @@ desktop-release-build target="aarch64-apple-darwin": mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/buzz-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-agent-$TARGET" + touch "desktop/src-tauri/binaries/buzz-a2a-acp-$TARGET" touch "desktop/src-tauri/binaries/buzz-dev-mcp-$TARGET" touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" touch "desktop/src-tauri/binaries/buzz-$TARGET" @@ -428,7 +429,7 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations fi done fi - cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay + cargo build -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay if [[ -n "{{mesh}}" ]]; then export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi @@ -475,10 +476,10 @@ desktop-standalone *ARGS: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" - cargo build -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr TARGET=$(rustc -vV | sed -n 's|host: ||p') TARGET_DIR=$(cargo metadata --format-version 1 --no-deps | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).target_directory") - for bin in buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz; do + for bin in buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz; do cp "${TARGET_DIR}/debug/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -504,7 +505,7 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: staging must always start with a clean dep tree - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) @@ -531,7 +532,7 @@ production *ARGS: bootstrap _ensure-sidecar-stubs set -euo pipefail export PATH="{{justfile_directory()}}/bin:$PATH" pnpm install # unconditional: production must always start with a clean dep tree - cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr + cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) diff --git a/crates/buzz-a2a-acp/Cargo.toml b/crates/buzz-a2a-acp/Cargo.toml new file mode 100644 index 0000000000..b8cb8738c6 --- /dev/null +++ b/crates/buzz-a2a-acp/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "buzz-a2a-acp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "ACP adapter for agents advertised by an OASF Agent Record and invoked through A2A" +readme = "README.md" +keywords = ["acp", "a2a", "agntcy", "oasf", "agent"] +categories = ["command-line-utilities", "web-programming"] + +[lib] +name = "buzz_a2a_acp" +path = "src/lib.rs" + +[[bin]] +name = "buzz-a2a-acp" +path = "src/main.rs" + +[dependencies] +base64 = "0.22" +clap = { version = "4", features = ["derive", "env"] } +hex = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls"] } +serde = { workspace = true } +serde_json = { workspace = true, features = ["raw_value"] } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs", "io-std", "io-util", "macros", "net", "rt-multi-thread", "sync", "time"] } +url = { workspace = true } +uuid = { workspace = true } diff --git a/crates/buzz-a2a-acp/README.md b/crates/buzz-a2a-acp/README.md new file mode 100644 index 0000000000..ccf49117cb --- /dev/null +++ b/crates/buzz-a2a-acp/README.md @@ -0,0 +1,95 @@ +# buzz-a2a-acp + +`buzz-a2a-acp` is a small BYOH subprocess adapter. It lets Buzz host an agent +that is described by an [AGNTCY/OASF Agent Record](https://docs.agntcy.org/oasf/agent-record-guide/) +and invoked with [A2A](https://a2a-protocol.org/latest/). + +The adapter reads the record, resolves the `integration/a2a` module (OASF id +`203`), and exposes the remote agent through Buzz's existing ACP stdio seam: + +``` +OASF Agent Record -> A2A Agent Card -> A2A JSON-RPC -> ACP stdio -> Buzz +``` + +The current adapter prefers an A2A JSON-RPC interface declared in +`supportedInterfaces`. It selects `SendMessage`/`GetTask` for A2A 1.x and +`message/send`/`tasks/get` for A2A 0.3. It sends the matching `A2A-Version` +header on every standard A2A request. It also has an explicit vendor +compatibility path for a non-standard card shape (`serviceEndpoint` plus +`agent/sendMessage` and `agent/getTask`). The compatibility path is not an A2A +release contract. Task responses are polled with bounded backoff until a +terminal state or the configured timeout. + +## Configure in Buzz + +Register the binary as a BYOH ACP runtime with: + +```text +command: buzz-a2a-acp +args: --record,/absolute/path/to/agent-record.json +``` + +The same configuration can be represented by a custom-harness JSON object: + +```json +{ + "id": "remote-oasf-agent", + "label": "Remote OASF agent", + "command": "buzz-a2a-acp", + "args": ["--record", "/absolute/path/to/agent-record.json"], + "env": {} +} +``` + +This is an operator-owned configuration example. The adapter is not added to +Buzz's compiled-in runtime gallery and does not auto-import or auto-trust +records. + +Or set `BUZZ_A2A_AGENT_RECORD`. The record can be a local file or an HTTP(S) +URL. Use `BUZZ_A2A_BEARER_TOKEN` only when the remote A2A endpoint requires it. +The token is supplied by the operator and is never read from the public record. + +The adapter requires the OASF descriptor fields `digest`, `media_type`, and +`size` for every artifact. It validates the SHA-256 digest and exact size, and +it accepts only JSON media types. It accepts the OASF `data.card_data` field +only as an explicit deprecated compatibility fallback because current OASF +schemas prefer an artifact descriptor. + +Remote records and card/artifact endpoints must use HTTPS. HTTP is accepted +only for loopback hosts. Redirects are disabled. A bearer token is sent only +when `--bearer-token-endpoint` normalizes to the resolved A2A endpoint. This +keeps operator credentials out of arbitrary endpoints selected by a public +record. The adapter resolves each hostname, applies the address policy, and +pins the request client to the checked address. It does not perform a second +unchecked DNS lookup for the request. + +The source record does not supply commands, environment variables, or +credentials. New conversations use random UUID context identifiers by +default. An operator can supply a stable identifier with `--context-id` or +`BUZZ_A2A_CONTEXT_ID` when the host has a durable A2A conversation reference +to preserve intentionally. + +## Scope and trust boundary + +The adapter projects public discovery metadata and A2A results. It does not +copy an Agency's private prompts, memory, tools, local files, or signing keys +into Buzz. The source runtime remains responsible for authentication, +authorization, execution, and any Nostr or Git signing. Buzz receives the +ACP-visible response or task status. + +This is an experimental adapter. It does not implement AGNTCY Directory +registration, OASF custom taxonomy exchange, A2A streaming, push notifications, +or Surface rendering. Those are separate integration layers that can build on +the real invocation seam without inventing a parallel agency protocol. + +ACP cancellation currently stops the local Buzz turn and aborts the adapter's +request future. The adapter does not yet send an A2A task cancellation request, +so a task that the source runtime already accepted can continue remotely. + +OASF defines the record schema; it does not define how records are discovered +or transported. The current adapter resolves a reviewed local path or HTTPS +URL. Authenticity is therefore based on the operator's review and, for remote +records, the HTTPS connection. OASF 1.1 records do not carry a general record +signature. Domain-JWKS verification is the next planned trust layer. Optional +AGNTCY Directory resolution can follow when interoperable Directory identity +and verification are required. diff --git a/crates/buzz-a2a-acp/src/lib.rs b/crates/buzz-a2a-acp/src/lib.rs new file mode 100644 index 0000000000..7419568694 --- /dev/null +++ b/crates/buzz-a2a-acp/src/lib.rs @@ -0,0 +1,2096 @@ +#![forbid(unsafe_code)] + +//! A small, protocol-faithful bridge from an AGNTCY/OASF Agent Record to ACP. +//! +//! The bridge is intentionally a subprocess. Buzz owns the ACP session and UI; +//! the source runtime owns its agent identity, context, execution, and keys. + +use base64::Engine; +use clap::Parser; +use reqwest::{Client, StatusCode}; +use serde::Deserialize; +use serde_json::{json, value::RawValue, Value}; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashSet, + net::{IpAddr, SocketAddr}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; +use thiserror::Error; +use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use url::Url; +use uuid::Uuid; + +const MAX_RECORD_BYTES: usize = 2 * 1024 * 1024; +const MAX_ARTIFACT_BYTES: usize = 2 * 1024 * 1024; +const MAX_ACP_LINE_BYTES: usize = 1024 * 1024; +const DEFAULT_TASK_POLL_SECS: u64 = 7_200; +const TASK_POLL_BACKOFF_SECS: [u64; 4] = [1, 5, 15, 30]; + +/// Configuration supplied by Buzz's BYOH subprocess definition. +#[derive(Debug, Clone)] +pub struct AdapterConfig { + /// Local path or HTTP(S) URL for an OASF Agent Record. + pub record: String, + /// Optional operator-supplied token for the A2A endpoint. + pub bearer_token: Option, + /// Exact endpoint where the operator permits the bearer token to be sent. + pub bearer_token_endpoint: Option, + /// Optional caller-supplied A2A conversation context identifier. + pub context_id: Option, + /// Maximum time to wait for an asynchronous A2A task. + pub task_poll_secs: u64, +} + +#[derive(Debug, Parser)] +#[command( + name = "buzz-a2a-acp", + about = "Expose an OASF Agent Record as an ACP subprocess" +)] +struct Cli { + /// Local path or HTTP(S) URL for an OASF 1.0 Agent Record. + #[arg(long, env = "BUZZ_A2A_AGENT_RECORD")] + record: String, + + /// Exact A2A endpoint where the operator permits the bearer token to be sent. + #[arg(long, env = "BUZZ_A2A_BEARER_ENDPOINT")] + bearer_token_endpoint: Option, + + /// Optional stable A2A conversation context identifier. + #[arg(long, env = "BUZZ_A2A_CONTEXT_ID")] + context_id: Option, + + /// Maximum time to wait for an asynchronous A2A task. + #[arg( + long, + env = "BUZZ_A2A_TASK_POLL_SECS", + default_value_t = DEFAULT_TASK_POLL_SECS + )] + task_poll_secs: u64, +} + +#[derive(Debug, Error)] +pub enum AdapterError { + #[error("record source is empty")] + EmptyRecord, + #[error("record source is not a local path or HTTP(S) URL: {0}")] + InvalidSource(String), + #[error("fetch {what} failed with HTTP {status}")] + HttpStatus { + what: &'static str, + status: StatusCode, + }, + #[error("{what} exceeds the {limit} byte limit")] + TooLarge { what: &'static str, limit: usize }, + #[error("read {what}: {source}")] + Read { + what: &'static str, + source: std::io::Error, + }, + #[error("decode {what}: {source}")] + Decode { + what: &'static str, + source: serde_json::Error, + }, + #[error("invalid OASF Agent Record: {0}")] + InvalidRecord(String), + #[error("invalid OASF A2A artifact: {0}")] + InvalidArtifact(String), + #[error("A2A endpoint is not advertised by the Agent Card")] + MissingEndpoint, + #[error("unsafe endpoint URL: {0}")] + UnsafeEndpoint(String), + #[error("bearer token is not authorized for A2A endpoint {0}")] + UnauthorizedTokenEndpoint(String), + #[error("remote A2A task did not complete before the {0} second timeout")] + TaskTimeout(u64), + #[error("A2A request failed: {0}")] + Request(String), + #[error("A2A response was invalid: {0}")] + InvalidResponse(String), + #[error("ACP protocol error: {0}")] + Acp(String), +} + +#[derive(Debug, Deserialize)] +struct AgentRecord { + #[serde(default)] + name: Option, + #[serde(default)] + schema_version: Option, + #[serde(default)] + modules: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecordSource { + LocalPath(PathBuf), + HttpUrl(Url), +} + +impl RecordSource { + fn parse(source: &str) -> Result { + if source.trim().is_empty() { + return Err(AdapterError::EmptyRecord); + } + if let Ok(url) = Url::parse(source) { + if matches!(url.scheme(), "http" | "https") { + validate_http_url(source) + .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?; + return Ok(Self::HttpUrl(url)); + } + if source.contains("://") { + return Err(AdapterError::InvalidSource(source.to_owned())); + } + } + Ok(Self::LocalPath(PathBuf::from(source))) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RecordVerification { + OperatorReviewedLocal, + TlsOnly, +} + +impl RecordVerification { + fn label(self) -> &'static str { + match self { + Self::OperatorReviewedLocal => "operator-reviewed-local", + Self::TlsOnly => "tls-only", + } + } +} + +struct ResolvedRecord { + record: AgentRecord, + base: Option, + content_digest: String, + verification: RecordVerification, +} + +#[derive(Debug, Deserialize)] +struct OasfModule { + #[serde(default)] + name: Option, + #[serde(default)] + id: Option, + #[serde(default)] + artifact: Option>, + #[serde(default)] + data: Option, +} + +#[derive(Debug, Deserialize)] +struct A2aData { + #[serde(default)] + card_data: Option, + #[serde(default, rename = "card_schema_version")] + _card_schema_version: Option, +} + +#[derive(Debug, Deserialize)] +struct Descriptor { + #[serde(default)] + digest: Option, + #[serde(default, rename = "media_type")] + media_type: Option, + #[serde(default)] + size: Option, + #[serde(default)] + data: Option, + #[serde(default)] + json: Option>, + #[serde(default)] + urls: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +/// Public A2A Agent Card fields used to select an invocation interface. +pub struct AgentCard { + /// Non-standard identifier used only by the vendor compatibility path. + #[serde(default, rename = "id")] + pub vendor_id: Option, + /// Human-readable name, when advertised. + #[serde(default)] + pub name: Option, + /// Human-readable description, when advertised. + #[serde(default)] + pub description: Option, + /// A2A 0.3 card endpoint. + #[serde(default)] + pub url: Option, + /// Non-standard endpoint field used by the vendor compatibility path. + #[serde(default, rename = "serviceEndpoint")] + pub service_endpoint: Option, + /// Current A2A interface declarations. + #[serde(default, rename = "supportedInterfaces")] + pub supported_interfaces: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +/// A protocol endpoint declared by an A2A Agent Card. +pub struct SupportedInterface { + /// URL to the protocol endpoint. + #[serde(default)] + pub url: Option, + /// Protocol binding name, for example `JSONRPC`. + #[serde(default, rename = "protocolBinding")] + pub protocol_binding: Option, + /// Protocol version declared by the remote agent. + #[serde(default, rename = "protocolVersion")] + pub protocol_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolMode { + /// A2A JSON-RPC interface declared by a current Agent Card. + JsonRpc { + endpoint: String, + protocol_version: Option, + }, + /// Compatibility with a deployed vendor card and method shape. + VendorServiceEndpoint { endpoint: String }, +} + +impl ProtocolMode { + fn endpoint(&self) -> &str { + match self { + Self::JsonRpc { endpoint, .. } | Self::VendorServiceEndpoint { endpoint } => endpoint, + } + } + + fn a2a_version(&self) -> Option<&'static str> { + match self { + Self::JsonRpc { + protocol_version, .. + } if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) => + { + Some("1.0") + } + Self::JsonRpc { .. } => Some("0.3"), + Self::VendorServiceEndpoint { .. } => None, + } + } + + fn method(&self, task: bool) -> &str { + match self { + Self::JsonRpc { + protocol_version, .. + } => { + if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) + { + if task { + "GetTask" + } else { + "SendMessage" + } + } else if task { + "tasks/get" + } else { + "message/send" + } + } + Self::VendorServiceEndpoint { .. } => { + if task { + "agent/getTask" + } else { + "agent/sendMessage" + } + } + } + } +} + +/// A resolved public record and its invocation mode. +#[derive(Debug, Clone)] +/// Resolved public metadata and invocation mode for one remote agent. +pub struct ResolvedAgent { + /// Name from the OASF record. + pub record_name: Option, + /// OASF schema version from the record. + pub record_schema_version: Option, + /// Public A2A card resolved from the OASF module. + pub card: AgentCard, + /// Selected current or compatibility invocation mode. + pub mode: ProtocolMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CardSource { + Artifact, + DeprecatedCardData, +} + +static REQUEST_ID: AtomicU64 = AtomicU64::new(1); + +fn pinned_http_client(url: &Url, addresses: &[SocketAddr]) -> Result { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + let mut builder = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)); + // Pin every address that passed our policy check. This prevents reqwest + // from performing a second DNS lookup while preserving IPv4/IPv6 fallback. + if host.parse::().is_err() { + if addresses.is_empty() { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + builder = builder.resolve_to_addrs(&host, addresses); + } + builder + .build() + .map_err(|e| AdapterError::Request(format!("build HTTP client: {e}"))) +} + +fn validate_http_url(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|_| AdapterError::UnsafeEndpoint(raw.to_owned()))?; + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(raw.to_owned()))?; + let host = normalized_host(raw_host); + if let Ok(ip) = host.parse::() { + // Local A2A runtimes are allowed over loopback HTTP. Private and + // link-local addresses remain rejected for every other scheme. + if url.scheme() == "http" && ip.is_loopback() { + return Ok(url); + } + if is_private_ip(ip) { + return Err(AdapterError::UnsafeEndpoint(raw.to_owned())); + } + } + if url.scheme() == "https" && host.eq_ignore_ascii_case("localhost") { + return Err(AdapterError::UnsafeEndpoint(raw.to_owned())); + } + match url.scheme() { + "https" => Ok(url), + "http" if is_loopback_host(&host) => Ok(url), + _ => Err(AdapterError::UnsafeEndpoint(raw.to_owned())), + } +} + +fn normalized_host(host: &str) -> String { + host.trim_start_matches('[') + .trim_end_matches(']') + .to_ascii_lowercase() +} + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +fn is_private_ip(ip: IpAddr) -> bool { + let ip = match ip { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(address)), + address => address, + }; + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + } + IpAddr::V6(ip) => { + let segments = ip.segments(); + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + // IPv4-transitional address ranges can encode private IPv4 + // targets while still presenting as IPv6 DNS answers. + || (segments[0] == 0x0064 + && segments[1] == 0xff9b + && segments[2..6] == [0, 0, 0, 0]) + || segments[0] == 0x2002 + || (segments[0] == 0x2001 && segments[1] == 0) + || segments[..6] == [0, 0, 0, 0, 0, 0] + } + } +} + +async fn resolve_network_url(url: &Url) -> Result, AdapterError> { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + if let Ok(ip) = host.parse::() { + if url.scheme() == "http" && ip.is_loopback() { + return Ok(vec![SocketAddr::new( + ip, + url.port_or_known_default().unwrap_or(80), + )]); + } + if !is_private_ip(ip) { + return Ok(vec![SocketAddr::new( + ip, + url.port_or_known_default().unwrap_or(443), + )]); + } + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + let port = url + .port_or_known_default() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let addresses: Vec = tokio::net::lookup_host((host.as_str(), port)) + .await + .map_err(|_| AdapterError::UnsafeEndpoint(url.to_string()))? + .collect(); + validate_resolved_addresses(url, &addresses)?; + Ok(addresses) +} + +fn validate_resolved_addresses(url: &Url, addresses: &[SocketAddr]) -> Result<(), AdapterError> { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + if addresses.is_empty() { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + let is_local_http = url.scheme() == "http" && is_loopback_host(&host); + if url.scheme() == "http" && !is_local_http { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + if is_local_http { + if addresses.iter().any(|address| !address.ip().is_loopback()) { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + } else if addresses.iter().any(|address| is_private_ip(address.ip())) { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + Ok(()) +} + +async fn response_bytes( + mut response: reqwest::Response, + what: &'static str, + limit: usize, +) -> Result, AdapterError> { + if response + .content_length() + .is_some_and(|size| size > limit as u64) + { + return Err(AdapterError::TooLarge { what, limit }); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| AdapterError::Request(format!("read {what}: {e}")))? + { + if body.len().saturating_add(chunk.len()) > limit { + return Err(AdapterError::TooLarge { what, limit }); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +async fn read_source( + source: &str, + what: &'static str, + limit: usize, +) -> Result, AdapterError> { + if source.trim().is_empty() { + return Err(AdapterError::EmptyRecord); + } + if let Ok(url) = Url::parse(source) { + if matches!(url.scheme(), "http" | "https") { + validate_http_url(source) + .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?; + let addresses = resolve_network_url(&url).await?; + let response = pinned_http_client(&url, &addresses)? + .get(url) + .send() + .await + .map_err(|e| AdapterError::Request(format!("fetch {what}: {e}")))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { what, status }); + } + return response_bytes(response, what, limit).await; + } + if source.contains("://") { + return Err(AdapterError::InvalidSource(source.to_owned())); + } + } + let body = tokio::fs::read(Path::new(source)) + .await + .map_err(|source| AdapterError::Read { what, source })?; + if body.len() > limit { + return Err(AdapterError::TooLarge { what, limit }); + } + Ok(body) +} + +async fn load_record(source: &str) -> Result { + let source = RecordSource::parse(source)?; + let source_text = match &source { + RecordSource::LocalPath(path) => path.to_string_lossy().into_owned(), + RecordSource::HttpUrl(url) => url.to_string(), + }; + let bytes = read_source(&source_text, "Agent Record", MAX_RECORD_BYTES).await?; + let record = if bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'[') { + let mut records: Vec = + serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode { + what: "Agent Record", + source, + })?; + if records.len() != 1 { + return Err(AdapterError::InvalidRecord(format!( + "expected exactly one Agent Record, got {}", + records.len() + ))); + } + records + .pop() + .ok_or_else(|| AdapterError::InvalidRecord("Agent Record collection is empty".into()))? + } else { + serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode { + what: "Agent Record", + source, + })? + }; + let (base, verification) = match source { + RecordSource::LocalPath(_) => (None, RecordVerification::OperatorReviewedLocal), + RecordSource::HttpUrl(url) if url.scheme() == "https" => { + (Some(url), RecordVerification::TlsOnly) + } + RecordSource::HttpUrl(url) => (Some(url), RecordVerification::OperatorReviewedLocal), + }; + Ok(ResolvedRecord { + record, + base, + content_digest: format!("sha256:{}", hex::encode(Sha256::digest(&bytes))), + verification, + }) +} + +fn descriptor_from_raw(value: &RawValue) -> Result { + let raw = value.get(); + if raw.trim_start().starts_with('[') { + let mut descriptors: Vec = serde_json::from_str(raw) + .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}")))?; + if descriptors.len() != 1 { + return Err(AdapterError::InvalidArtifact(format!( + "expected exactly one artifact descriptor, got {}", + descriptors.len() + ))); + } + descriptors + .pop() + .ok_or_else(|| AdapterError::InvalidArtifact("artifact descriptor is absent".into())) + } else { + serde_json::from_str(raw) + .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}"))) + } +} + +fn verify_descriptor(descriptor: &Descriptor, bytes: &[u8]) -> Result<(), AdapterError> { + let size = descriptor.size.ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires size".into()) + })?; + if size != bytes.len() as u64 { + return Err(AdapterError::InvalidArtifact(format!( + "descriptor size {size} does not match {}", + bytes.len() + ))); + } + let digest = descriptor.digest.as_deref().ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires digest".into()) + })?; + let Some(expected) = digest + .strip_prefix("sha256:") + .or_else(|| digest.strip_prefix("sha256-")) + else { + return Err(AdapterError::InvalidArtifact(format!( + "unsupported digest {digest:?}; expected sha256:" + ))); + }; + let actual = hex::encode(Sha256::digest(bytes)); + if !actual.eq_ignore_ascii_case(expected) { + return Err(AdapterError::InvalidArtifact(format!( + "sha256 digest mismatch: expected {expected}, got {actual}" + ))); + } + Ok(()) +} + +async fn descriptor_bytes( + descriptor: &Descriptor, + record_url: Option<&Url>, +) -> Result, AdapterError> { + let media_type = descriptor.media_type.as_deref().ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires media_type".into()) + })?; + if !media_type.to_ascii_lowercase().contains("json") { + return Err(AdapterError::InvalidArtifact(format!( + "A2A artifact media type must be JSON, got {media_type:?}" + ))); + } + if let Some(value) = descriptor.json.as_ref() { + let bytes = value.get().as_bytes().to_vec(); + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + if let Some(data) = descriptor.data.as_deref() { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| { + AdapterError::InvalidArtifact(format!("descriptor data is not base64: {e}")) + })?; + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(AdapterError::TooLarge { + what: "A2A artifact", + limit: MAX_ARTIFACT_BYTES, + }); + } + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + if let Some(raw_url) = descriptor.urls.first() { + if descriptor.digest.is_none() { + return Err(AdapterError::InvalidArtifact( + "remote artifact descriptors require a sha256 digest".into(), + )); + } + let url = if let Ok(url) = Url::parse(raw_url) { + url + } else if let Some(base) = record_url { + base.join(raw_url) + .map_err(|_| AdapterError::UnsafeEndpoint(raw_url.clone()))? + } else { + return Err(AdapterError::InvalidArtifact(format!( + "relative artifact URL {raw_url:?} requires an HTTP(S) record source" + ))); + }; + validate_http_url(url.as_str())?; + let bytes = read_source(url.as_str(), "A2A artifact", MAX_ARTIFACT_BYTES).await?; + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + Err(AdapterError::InvalidArtifact( + "descriptor has no json, data, or urls".into(), + )) +} + +fn is_a2a_module(module: &OasfModule) -> bool { + module.name.as_deref() == Some("integration/a2a") + || module.id.as_ref().and_then(Value::as_u64) == Some(203) +} + +async fn resolve_card( + record: AgentRecord, + record_url: Option<&Url>, +) -> Result<(ResolvedAgent, CardSource), AdapterError> { + let module = record + .modules + .iter() + .find(|m| is_a2a_module(m)) + .ok_or_else(|| { + AdapterError::InvalidRecord("missing integration/a2a module (id 203)".into()) + })?; + let (card_value, source) = if let Some(artifact) = module.artifact.as_ref() { + let descriptor = descriptor_from_raw(artifact)?; + let bytes = descriptor_bytes(&descriptor, record_url).await?; + ( + serde_json::from_slice::(&bytes) + .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card JSON: {e}")))?, + CardSource::Artifact, + ) + } else if let Some(data) = module.data.as_ref().and_then(|data| data.card_data.clone()) { + (data, CardSource::DeprecatedCardData) + } else { + return Err(AdapterError::InvalidRecord( + "integration/a2a module has no artifact; deprecated data.card_data is also absent" + .into(), + )); + }; + let card: AgentCard = serde_json::from_value(card_value) + .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card shape: {e}")))?; + let mode = select_protocol_mode(&card)?; + Ok(( + ResolvedAgent { + record_name: record.name, + record_schema_version: record.schema_version, + card, + mode, + }, + source, + )) +} + +/// Select the declared JSON-RPC interface, with a named pre-1.0 compatibility path. +pub fn select_protocol_mode(card: &AgentCard) -> Result { + if let Some(interface) = card.supported_interfaces.iter().find(|i| { + i.protocol_binding + .as_deref() + .is_some_and(|binding| binding.to_ascii_lowercase().contains("jsonrpc")) + }) { + if let Some(endpoint) = interface.url.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::JsonRpc { + endpoint, + protocol_version: interface.protocol_version.clone(), + }); + } + } + if let Some(endpoint) = card.service_endpoint.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::VendorServiceEndpoint { endpoint }); + } + if let Some(endpoint) = card.url.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::JsonRpc { + endpoint, + protocol_version: Some("0.3".into()), + }); + } + Err(AdapterError::MissingEndpoint) +} + +fn protocol_request( + client: &Client, + mode: &ProtocolMode, + endpoint: &str, +) -> reqwest::RequestBuilder { + let request = client.post(endpoint); + match mode.a2a_version() { + Some(version) => request.header("A2A-Version", version), + None => request, + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PromptBlock { + Text { + text: String, + }, + #[serde(other)] + Unsupported, +} + +#[derive(Debug, Deserialize)] +struct PromptParams { + #[serde(rename = "sessionId")] + session_id: String, + prompt: Vec, +} + +#[derive(Debug, Deserialize)] +struct CancelParams { + #[serde(rename = "sessionId")] + session_id: String, +} + +fn prompt_text(blocks: &[PromptBlock]) -> Result { + let text = blocks + .iter() + .filter_map(|block| match block { + PromptBlock::Text { text } => Some(text.as_str()), + PromptBlock::Unsupported => None, + }) + .collect::>() + .join("\n"); + if text.trim().is_empty() { + return Err(AdapterError::Acp("prompt contains no text content".into())); + } + Ok(text) +} + +fn extract_text(value: &Value) -> Option { + if let Some(text) = value.get("text").and_then(Value::as_str) { + return Some(text.to_owned()); + } + if let Some(parts) = value.get("parts").and_then(Value::as_array) { + let joined = parts + .iter() + .filter_map(extract_text) + .collect::>() + .join("\n"); + if !joined.is_empty() { + return Some(joined); + } + } + if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) { + let joined = artifacts + .iter() + .rev() + .filter_map(extract_text) + .collect::>() + .join("\n"); + if !joined.is_empty() { + return Some(joined); + } + } + if let Some(history) = value.get("history").and_then(Value::as_array) { + for item in history.iter().rev() { + if item.get("role").and_then(Value::as_str) != Some("user") { + if let Some(text) = extract_text(item) { + return Some(text); + } + } + } + } + value.get("message").and_then(extract_text) +} + +async fn invoke( + resolved: &ResolvedAgent, + token: Option<&str>, + token_endpoint: Option<&str>, + task_poll_secs: u64, + session_id: &str, + text: &str, +) -> Result { + validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; + let endpoint = Url::parse(resolved.mode.endpoint()) + .map_err(|_| AdapterError::UnsafeEndpoint(resolved.mode.endpoint().to_owned()))?; + let addresses = resolve_network_url(&endpoint).await?; + let client = pinned_http_client(&endpoint, &addresses)?; + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let payload = request_payload( + &resolved.mode, + resolved.card.vendor_id.as_deref(), + id, + session_id, + text, + ); + let mut request = + protocol_request(&client, &resolved.mode, resolved.mode.endpoint()).json(&payload); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|e| AdapterError::Request(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { + what: "A2A request", + status, + }); + } + let bytes = response_bytes(response, "A2A response", MAX_ARTIFACT_BYTES).await?; + let body: Value = serde_json::from_slice(&bytes) + .map_err(|e| AdapterError::Request(format!("decode A2A response: {e}")))?; + if let Some(error) = body.get("error") { + return Err(AdapterError::InvalidResponse(error.to_string())); + } + let result = body.get("result").unwrap_or(&body); + let result = result + .get("task") + .or_else(|| result.get("message")) + .unwrap_or(result); + if result.pointer("/status/state").is_some() { + let task_id = result + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if let Some(text) = task_outcome(result, task_id)? { + return Ok(text); + } + if task_id != "unknown" { + return poll_task( + resolved, + token, + token_endpoint, + task_id, + task_poll_secs, + &client, + ) + .await; + } + return Err(AdapterError::InvalidResponse( + "A2A task response has no task id".into(), + )); + } + extract_text(result).ok_or_else(|| { + AdapterError::InvalidResponse("A2A response contains no message or task state".into()) + }) +} + +async fn poll_task( + resolved: &ResolvedAgent, + token: Option<&str>, + token_endpoint: Option<&str>, + task_id: &str, + task_poll_secs: u64, + client: &Client, +) -> Result { + let started = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(task_poll_secs); + let mut poll_attempt = 0usize; + while started.elapsed() < timeout { + let remaining = timeout.saturating_sub(started.elapsed()); + let delay = std::time::Duration::from_secs( + TASK_POLL_BACKOFF_SECS[poll_attempt.min(TASK_POLL_BACKOFF_SECS.len() - 1)], + ) + .min(remaining); + tokio::time::sleep(delay).await; + poll_attempt = poll_attempt.saturating_add(1); + if started.elapsed() >= timeout { + break; + } + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let params = match resolved.mode { + ProtocolMode::JsonRpc { .. } => json!({ "id": task_id }), + ProtocolMode::VendorServiceEndpoint { .. } => json!({ "taskId": task_id }), + }; + let payload = json!({ + "jsonrpc": "2.0", + "id": id, + "method": resolved.mode.method(true), + "params": params, + }); + validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; + let mut request = + protocol_request(client, &resolved.mode, resolved.mode.endpoint()).json(&payload); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|e| AdapterError::Request(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { + what: "A2A task poll", + status, + }); + } + let bytes = response_bytes(response, "A2A task response", MAX_ARTIFACT_BYTES).await?; + let body: Value = serde_json::from_slice(&bytes) + .map_err(|e| AdapterError::Request(format!("decode A2A task response: {e}")))?; + if let Some(error) = body.get("error") { + return Err(AdapterError::InvalidResponse(error.to_string())); + } + let result = body.get("result").unwrap_or(&body); + let result = result + .get("task") + .or_else(|| result.get("message")) + .unwrap_or(result); + if let Some(text) = task_outcome(result, task_id)? { + return Ok(text); + } + } + Err(AdapterError::TaskTimeout(task_poll_secs)) +} + +fn validate_endpoint_binding( + token: Option<&str>, + expected_endpoint: Option<&str>, + actual_endpoint: &str, +) -> Result<(), AdapterError> { + let endpoints_match = match expected_endpoint { + Some(expected) => { + let expected = validate_http_url(expected)?; + let actual = validate_http_url(actual_endpoint)?; + expected == actual + } + None => token.is_none(), + }; + if !endpoints_match { + return Err(AdapterError::UnauthorizedTokenEndpoint( + actual_endpoint.to_owned(), + )); + } + Ok(()) +} + +fn task_outcome(result: &Value, task_id: &str) -> Result, AdapterError> { + let wire_state = result + .get("status") + .and_then(|status| status.get("state")) + .and_then(Value::as_str) + .ok_or_else(|| { + AdapterError::InvalidResponse(format!("A2A task {task_id} has no status state")) + })?; + let normalized_state = wire_state.trim().to_ascii_lowercase(); + let state = normalized_state + .strip_prefix("task_state_") + .unwrap_or(&normalized_state); + match state { + "completed" => { + Ok(Some(extract_text(result).unwrap_or_else(|| { + format!("A2A task {task_id} completed") + }))) + } + "accepted" | "submitted" | "working" | "pending" => Ok(None), + "failed" | "canceled" | "cancelled" | "rejected" | "input-required" | "input_required" => { + let detail = extract_text(result) + .map(|text| format!(": {text}")) + .unwrap_or_default(); + Err(AdapterError::InvalidResponse(format!( + "A2A task {task_id} ended in {state}{detail}" + ))) + } + other => Err(AdapterError::InvalidResponse(format!( + "A2A task {task_id} has unknown state {wire_state} (normalized as {other})" + ))), + } +} + +fn request_payload( + mode: &ProtocolMode, + agent_id: Option<&str>, + id: u64, + session_id: &str, + text: &str, +) -> Value { + let params = match mode { + ProtocolMode::JsonRpc { + protocol_version, .. + } if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) => + { + json!({ + "message": { "messageId": format!("buzz-{id}"), "role": "ROLE_USER", "contextId": session_id, "parts": [{ "text": text }] }, + }) + } + ProtocolMode::JsonRpc { .. } => json!({ + "message": { "messageId": format!("buzz-{id}"), "role": "user", "contextId": session_id, "parts": [{ "kind": "text", "text": text }] }, + }), + ProtocolMode::VendorServiceEndpoint { .. } => json!({ + "agentId": agent_id, + "message": { "role": "user", "parts": [{ "type": "text", "text": text }] }, + "contextId": session_id, + }), + }; + json!({ "jsonrpc": "2.0", "id": id, "method": mode.method(false), "params": params }) +} + +async fn send_json( + writer: &mut W, + value: Value, +) -> Result<(), AdapterError> { + let mut line = serde_json::to_vec(&value) + .map_err(|e| AdapterError::Acp(format!("encode response: {e}")))?; + line.push(b'\n'); + writer + .write_all(&line) + .await + .map_err(|e| AdapterError::Acp(format!("write response: {e}")))?; + writer + .flush() + .await + .map_err(|e| AdapterError::Acp(format!("flush response: {e}")))?; + Ok(()) +} + +enum AcpAction { + Response(Value), + Prompt { + id: Value, + session_id: String, + text: String, + }, + Cancel { + id: Option, + session_id: String, + }, +} + +fn handle_acp_message( + message: &Value, + sessions: &mut HashSet, + agent_name: &str, + configured_context_id: Option<&str>, +) -> Result, AdapterError> { + let method = message.get("method").and_then(Value::as_str); + if method == Some("session/cancel") { + let params: CancelParams = + serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|e| AdapterError::Acp(format!("session/cancel params: {e}")))?; + return Ok(Some(AcpAction::Cancel { + id: message.get("id").cloned(), + session_id: params.session_id, + })); + } + let Some(id) = message.get("id").cloned() else { + return Ok(None); + }; + match method { + Some("initialize") => Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": message.pointer("/params/protocolVersion").and_then(Value::as_u64).unwrap_or(1).min(1), + "agentCapabilities": { + "loadSession": false, + "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, + "mcpCapabilities": { "http": false, "sse": false }, + }, + "agentInfo": { "name": agent_name, "version": "oasf-a2a" }, + } + })))), + Some("session/new") => { + let session_id = configured_context_id + .map(str::to_owned) + .unwrap_or_else(|| format!("a2a-{}", Uuid::new_v4())); + sessions.insert(session_id.clone()); + Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + })))) + } + Some("session/prompt") => { + let params: PromptParams = + serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|e| AdapterError::Acp(format!("session/prompt params: {e}")))?; + if !sessions.contains(¶ms.session_id) { + return Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32602, "message": "unknown session" }, + })))); + } + let text = match prompt_text(¶ms.prompt) { + Ok(text) => text, + Err(error) => { + return Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32602, "message": error.to_string() }, + })))); + } + }; + Ok(Some(AcpAction::Prompt { + id, + session_id: params.session_id, + text, + })) + } + Some(method) => Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32601, "message": format!("method not found: {method}") }, + })))), + None => Ok(None), + } +} + +fn prompt_success(id: Value, session_id: &str, text: &str) -> [Value; 2] { + [ + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": text }, + } + } + }), + json!({ "jsonrpc": "2.0", "id": id, "result": { "stopReason": "end_turn" } }), + ] +} + +struct ActivePrompt { + id: Value, + session_id: String, + task: tokio::task::JoinHandle>, +} + +enum LoopEvent { + Input(Option, AdapterError>>), + PromptFinished(Result, tokio::task::JoinError>), +} + +/// Run the adapter over ACP JSON-RPC lines on stdin/stdout. +pub async fn run(config: AdapterConfig) -> Result<(), AdapterError> { + let record = load_record(&config.record).await?; + eprintln!( + "buzz-a2a-acp: resolved Agent Record {} ({})", + record.content_digest, + record.verification.label() + ); + let (resolved, source) = resolve_card(record.record, record.base.as_ref()).await?; + if source == CardSource::DeprecatedCardData { + eprintln!( + "buzz-a2a-acp: using deprecated OASF integration/a2a data.card_data compatibility path" + ); + } + let mut sessions = HashSet::new(); + let mut lines = spawn_line_reader(BufReader::new(tokio::io::stdin())); + let mut writer = tokio::io::stdout(); + let mut active_prompt: Option = None; + loop { + let event = if let Some(active) = active_prompt.as_mut() { + tokio::select! { + line = lines.recv() => LoopEvent::Input(line), + result = &mut active.task => LoopEvent::PromptFinished(result), + } + } else { + LoopEvent::Input(lines.recv().await) + }; + match event { + LoopEvent::PromptFinished(result) => { + let Some(active) = active_prompt.take() else { + return Err(AdapterError::Acp( + "prompt completed without an active request".into(), + )); + }; + match result { + Ok(Ok(text)) => { + for value in prompt_success(active.id, &active.session_id, &text) { + send_json(&mut writer, value).await?; + } + } + Ok(Err(error)) => { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": error.to_string() } }), + ) + .await?; + } + Err(error) => { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": format!("remote prompt task failed: {error}") } }), + ) + .await?; + } + } + } + LoopEvent::Input(None | Some(Ok(None))) => return Ok(()), + LoopEvent::Input(Some(Err(error))) => { + eprintln!("buzz-a2a-acp: ignored malformed ACP input: {error}"); + } + LoopEvent::Input(Some(Ok(Some(line)))) => { + let message: Value = match serde_json::from_str(line.trim()) { + Ok(message) => message, + Err(error) => { + eprintln!("buzz-a2a-acp: ignored malformed JSON-RPC line: {error}"); + continue; + } + }; + let action = match handle_acp_message( + &message, + &mut sessions, + resolved.card.name.as_deref().unwrap_or("remote-a2a-agent"), + config.context_id.as_deref(), + ) { + Ok(action) => action, + Err(error) => { + if let Some(id) = message.get("id").cloned() { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": error.to_string() } }), + ) + .await?; + } else { + eprintln!("buzz-a2a-acp: ignored invalid notification: {error}"); + } + continue; + } + }; + match action { + Some(AcpAction::Response(response)) => send_json(&mut writer, response).await?, + Some(AcpAction::Prompt { + id, + session_id, + text, + }) => { + if active_prompt.is_some() { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32001, "message": "another prompt is already active" } }), + ) + .await?; + continue; + } + let prompt_resolved = resolved.clone(); + let prompt_token = config.bearer_token.clone(); + let prompt_token_endpoint = config.bearer_token_endpoint.clone(); + let prompt_task_poll_secs = config.task_poll_secs; + let prompt_session_id = session_id.clone(); + let task = tokio::spawn(async move { + invoke( + &prompt_resolved, + prompt_token.as_deref(), + prompt_token_endpoint.as_deref(), + prompt_task_poll_secs, + &prompt_session_id, + &text, + ) + .await + }); + active_prompt = Some(ActivePrompt { + id, + session_id, + task, + }); + } + Some(AcpAction::Cancel { id, session_id }) => { + if active_prompt + .as_ref() + .is_some_and(|active| active.session_id == session_id) + { + let Some(active) = active_prompt.take() else { + return Err(AdapterError::Acp( + "matching prompt disappeared during cancellation".into(), + )); + }; + active.task.abort(); + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "result": { "stopReason": "cancelled" } }), + ) + .await?; + if let Some(id) = id { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "result": {} }), + ) + .await?; + } + } else if let Some(id) = id { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": "no active prompt for session" } }), + ) + .await?; + } + } + None => {} + } + } + } + } +} + +fn spawn_line_reader( + mut reader: R, +) -> tokio::sync::mpsc::Receiver, AdapterError>> +where + R: tokio::io::AsyncBufRead + Send + Unpin + 'static, +{ + let (sender, receiver) = tokio::sync::mpsc::channel(8); + tokio::spawn(async move { + loop { + let line = read_bounded_line(&mut reader).await; + let reached_eof = matches!(line, Ok(None)); + let transport_failed = matches!(line, Err(AdapterError::Read { .. })); + if sender.send(line).await.is_err() || reached_eof || transport_failed { + break; + } + } + }); + receiver +} + +async fn read_bounded_line( + reader: &mut R, +) -> Result, AdapterError> { + let mut bytes = Vec::new(); + loop { + let chunk = reader + .fill_buf() + .await + .map_err(|source| AdapterError::Read { + what: "ACP request", + source, + })?; + if chunk.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + return Err(AdapterError::Acp("unterminated request at EOF".into())); + } + let take = chunk + .iter() + .position(|byte| *byte == b'\n') + .map_or(chunk.len(), |index| index + 1); + if bytes.len().saturating_add(take) > MAX_ACP_LINE_BYTES { + let ended = chunk[..take].ends_with(b"\n"); + reader.consume(take); + if !ended { + discard_until_newline(reader).await?; + } + return Err(AdapterError::Acp("request exceeds 1 MiB".into())); + } + bytes.extend_from_slice(&chunk[..take]); + reader.consume(take); + if bytes.ends_with(b"\n") { + bytes.pop(); + if bytes.ends_with(b"\r") { + bytes.pop(); + } + return String::from_utf8(bytes) + .map(Some) + .map_err(|_| AdapterError::Acp("request is not UTF-8".into())); + } + } +} + +async fn discard_until_newline( + reader: &mut R, +) -> Result<(), AdapterError> { + loop { + let chunk = reader + .fill_buf() + .await + .map_err(|source| AdapterError::Read { + what: "ACP request", + source, + })?; + if chunk.is_empty() { + return Ok(()); + } + let take = chunk + .iter() + .position(|byte| *byte == b'\n') + .map_or(chunk.len(), |index| index + 1); + let ended = chunk[..take].ends_with(b"\n"); + reader.consume(take); + if ended { + return Ok(()); + } + } +} + +/// Run the adapter as a normal CLI process. Sprig uses this entry point for +/// the `buzz-a2a-acp` multicall personality. +pub fn run_cli() -> Result<(), String> { + let args = Cli::parse(); + let bearer_token = std::env::var("BUZZ_A2A_BEARER_TOKEN") + .ok() + .filter(|value| !value.trim().is_empty()); + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| format!("build runtime: {error}"))? + .block_on(run(AdapterConfig { + record: args.record, + bearer_token, + bearer_token_endpoint: args.bearer_token_endpoint, + context_id: args.context_id, + task_poll_secs: args.task_poll_secs, + })) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn card(endpoint: &str) -> Value { + json!({ "id": "example-agent", "name": "Example Agent", "serviceEndpoint": endpoint }) + } + + #[tokio::test] + async fn resolves_oasf_artifact_and_validates_descriptor() { + let card = card("http://127.0.0.1:1337/a2a"); + let bytes = serde_json::to_vec(&card).expect("test card serializes"); + let digest = format!("sha256:{}", hex::encode(Sha256::digest(&bytes))); + let record = json!({ "name": "Example Agent", "schema_version": "1.0.0", "modules": [{ "name": "integration/a2a", "id": 203, "artifact": { "json": card, "digest": digest, "media_type": "application/a2a-agent-card+json", "size": bytes.len() } }] }); + let path = std::env::temp_dir().join(format!( + "buzz-a2a-record-{}-{}.json", + std::process::id(), + REQUEST_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::write( + &path, + serde_json::to_vec(&record).expect("test record serializes"), + ) + .expect("write record"); + let loaded = load_record(path.to_str().expect("temp path is utf8")) + .await + .expect("load record"); + let (resolved, source) = resolve_card(loaded.record, loaded.base.as_ref()) + .await + .expect("resolve card"); + assert_eq!(source, CardSource::Artifact); + assert_eq!( + resolved.mode, + ProtocolMode::VendorServiceEndpoint { + endpoint: "http://127.0.0.1:1337/a2a".into() + } + ); + let _ = fs::remove_file(path); + } + + #[tokio::test] + async fn accepts_one_record_collection_and_preserves_embedded_artifact_bytes() { + let raw_card = r#"{"name":"Example Agent","version":"1.0.0","supportedInterfaces":[{"url":"http://127.0.0.1:1337/a2a/agent","protocolBinding":"JSONRPC","protocolVersion":"1.0"}]}"#; + let digest = format!( + "sha256:{}", + hex::encode(Sha256::digest(raw_card.as_bytes())) + ); + let record = format!( + r#"[{{"name":"Example Agent","schema_version":"1.0.0","modules":[{{"name":"integration/a2a","id":203,"artifact":{{"json":{raw_card},"digest":"{digest}","media_type":"application/a2a-agent-card+json","size":{}}}}}]}}]"#, + raw_card.len() + ); + let path = std::env::temp_dir().join(format!( + "buzz-a2a-record-collection-{}-{}.json", + std::process::id(), + REQUEST_ID.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&path, record).expect("write record collection"); + let record = load_record(path.to_str().expect("temp path is utf8")) + .await + .expect("load one-record collection"); + let (resolved, source) = resolve_card(record.record, record.base.as_ref()) + .await + .expect("resolve exact embedded artifact bytes"); + assert_eq!(source, CardSource::Artifact); + assert_eq!(resolved.mode.endpoint(), "http://127.0.0.1:1337/a2a/agent"); + let _ = fs::remove_file(path); + } + + #[test] + fn prefers_declared_jsonrpc_interface() { + let card: AgentCard = serde_json::from_value(json!({ "supportedInterfaces": [{ "url": "https://agent.example/rpc", "protocolBinding": "JSONRPC", "protocolVersion": "1.0" }], "serviceEndpoint": "https://legacy.example/a2a" })).expect("card"); + assert_eq!( + select_protocol_mode(&card).expect("mode"), + ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("1.0".into()), + } + ); + } + + #[test] + fn builds_current_and_vendor_requests() { + let current = request_payload( + &ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("1.0".into()), + }, + Some("remote"), + 1, + "session", + "hello", + ); + assert_eq!(current["method"], "SendMessage"); + assert_eq!(current["params"]["message"]["role"], "ROLE_USER"); + assert!(current["params"]["message"]["parts"][0]["kind"].is_null()); + assert_eq!(current["params"]["message"]["parts"][0]["text"], "hello"); + let vendor = request_payload( + &ProtocolMode::VendorServiceEndpoint { + endpoint: "http://127.0.0.1:1337/a2a".into(), + }, + Some("remote"), + 2, + "session", + "hello", + ); + assert_eq!(vendor["method"], "agent/sendMessage"); + assert_eq!(vendor["params"]["agentId"], "remote"); + } + + #[test] + fn sends_a2a_version_header_for_standard_modes_only() { + let client = Client::new(); + for (mode, expected) in [ + ( + ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("1.0".into()), + }, + Some("1.0"), + ), + ( + ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("0.3".into()), + }, + Some("0.3"), + ), + ( + ProtocolMode::VendorServiceEndpoint { + endpoint: "https://agent.example/rpc".into(), + }, + None, + ), + ] { + let request = protocol_request(&client, &mode, mode.endpoint()) + .build() + .expect("request builds"); + assert_eq!( + request + .headers() + .get("A2A-Version") + .and_then(|value| value.to_str().ok()), + expected + ); + } + } + + #[test] + fn builds_a2a_0_3_request_and_preserves_context() { + let request = request_payload( + &ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("0.3".into()), + }, + Some("remote"), + 3, + "buzz-session", + "hello", + ); + assert_eq!(request["method"], "message/send"); + assert!(request["params"]["contextId"].is_null()); + assert_eq!(request["params"]["message"]["contextId"], "buzz-session"); + assert_eq!(request["params"]["message"]["parts"][0]["kind"], "text"); + } + + #[test] + fn rejects_non_loopback_http_endpoints() { + let card: AgentCard = serde_json::from_value(json!({ + "supportedInterfaces": [{ + "url": "http://remote.example/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }] + })) + .expect("card"); + assert!(matches!( + select_protocol_mode(&card), + Err(AdapterError::UnsafeEndpoint(_)) + )); + let private_card: AgentCard = serde_json::from_value(json!({ + "supportedInterfaces": [{ + "url": "https://127.0.0.1/a2a", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }] + })) + .expect("card"); + assert!(matches!( + select_protocol_mode(&private_card), + Err(AdapterError::UnsafeEndpoint(_)) + )); + } + + #[tokio::test] + async fn pinned_localhost_client_falls_back_across_checked_addresses() { + let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind IPv4 listener"); + let port = listener.local_addr().expect("listener address").port(); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept request"); + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await + .expect("write response"); + }); + let url = Url::parse(&format!("http://localhost:{port}/a2a")).expect("url"); + let addresses = [ + SocketAddr::new(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), port), + SocketAddr::new(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), port), + ]; + validate_resolved_addresses(&url, &addresses).expect("loopback addresses"); + + let response = tokio::time::timeout( + std::time::Duration::from_secs(2), + pinned_http_client(&url, &addresses) + .expect("pinned client") + .get(url) + .send(), + ) + .await + .expect("connect using validated fallback") + .expect("HTTP response"); + assert_eq!(response.status(), StatusCode::OK); + server.await.expect("server task"); + } + + #[test] + fn resolver_policy_rejects_mixed_or_private_addresses() { + let localhost = Url::parse("http://localhost:1337/a2a").expect("url"); + assert!(validate_resolved_addresses( + &localhost, + &[ + SocketAddr::from(([127, 0, 0, 1], 1337)), + SocketAddr::from(([8, 8, 8, 8], 1337)) + ] + ) + .is_err()); + let public = Url::parse("https://agent.example/a2a").expect("url"); + assert!(validate_resolved_addresses( + &public, + &[ + SocketAddr::from(([8, 8, 8, 8], 443)), + SocketAddr::from(([10, 0, 0, 1], 443)) + ] + ) + .is_err()); + assert!( + validate_resolved_addresses(&public, &[SocketAddr::from(([8, 8, 8, 8], 443))]).is_ok() + ); + } + + #[test] + fn reviewed_endpoint_is_enforced_even_without_a_token() { + assert!(validate_endpoint_binding( + None, + Some("https://reviewed.example/a2a"), + "https://reviewed.example/a2a" + ) + .is_ok()); + assert!(matches!( + validate_endpoint_binding( + None, + Some("https://reviewed.example/a2a"), + "https://other.example/a2a" + ), + Err(AdapterError::UnauthorizedTokenEndpoint(_)) + )); + assert!(matches!( + validate_endpoint_binding(Some("secret"), None, "https://agent.example/a2a"), + Err(AdapterError::UnauthorizedTokenEndpoint(_)) + )); + } + + #[tokio::test] + async fn deprecated_card_data_is_explicit_compatibility_path() { + let record: AgentRecord = serde_json::from_value(json!({ "modules": [{ "name": "integration/a2a", "data": { "card_data": card("http://127.0.0.1:1337/a2a"), "card_schema_version": "0.3" } }] })).expect("record"); + let (_, source) = resolve_card(record, None).await.expect("resolve card"); + assert_eq!(source, CardSource::DeprecatedCardData); + } + + #[tokio::test] + async fn digest_mismatch_is_rejected() { + let artifact = json!({ "name": "wrong" }); + let artifact_bytes = serde_json::to_vec(&artifact).expect("artifact serializes"); + let descriptor: Descriptor = serde_json::from_value(json!({ + "json": artifact, + "digest": "sha256:00", + "media_type": "application/json", + "size": artifact_bytes.len() + })) + .expect("descriptor"); + let err = descriptor_bytes(&descriptor, None) + .await + .expect_err("mismatch"); + assert!(err.to_string().contains("digest mismatch")); + } + + #[tokio::test] + async fn missing_oasf_descriptor_media_type_is_rejected() { + let artifact = json!({ "name": "missing media type" }); + let artifact_bytes = serde_json::to_vec(&artifact).expect("artifact serializes"); + let descriptor: Descriptor = serde_json::from_value(json!({ + "json": artifact, + "digest": format!("sha256:{}", hex::encode(Sha256::digest(&artifact_bytes))), + "size": artifact_bytes.len() + })) + .expect("descriptor"); + let err = descriptor_bytes(&descriptor, None) + .await + .expect_err("missing media_type"); + assert!(err.to_string().contains("requires media_type")); + } + + #[tokio::test] + async fn acp_transcript_handles_initialize_new_and_prompt() { + let mut sessions = HashSet::new(); + let initialize = handle_acp_message( + &json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": 1 } }), + &mut sessions, + "remote-agent", + None, + ) + .expect("initialize action") + .expect("initialize response"); + let AcpAction::Response(initialize) = initialize else { + panic!("initialize must return a response"); + }; + assert_eq!(initialize["result"]["protocolVersion"], 1); + assert_eq!(initialize["result"]["agentInfo"]["name"], "remote-agent"); + + let new = handle_acp_message( + &json!({ "jsonrpc": "2.0", "id": 2, "method": "session/new", "params": {} }), + &mut sessions, + "remote-agent", + None, + ) + .expect("session/new action") + .expect("session/new response"); + let AcpAction::Response(new) = new else { + panic!("session/new must return a response"); + }; + let session_id = new["result"]["sessionId"] + .as_str() + .expect("session id") + .to_owned(); + assert!(sessions.contains(&session_id)); + + let prompt = handle_acp_message( + &json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "session/prompt", + "params": { "sessionId": session_id, "prompt": [{ "type": "text", "text": "ship it" }] } + }), + &mut sessions, + "remote-agent", + None, + ) + .expect("session/prompt action") + .expect("session/prompt request"); + let AcpAction::Prompt { + id, + session_id, + text, + } = prompt + else { + panic!("session/prompt must invoke the remote runtime"); + }; + assert_eq!(id, 3); + assert_eq!(text, "ship it"); + let [update, result] = prompt_success(id, &session_id, "done"); + assert_eq!(update["method"], "session/update"); + assert_eq!(update["params"]["sessionId"], session_id); + assert_eq!(update["params"]["update"]["content"]["text"], "done"); + assert_eq!(result["result"]["stopReason"], "end_turn"); + } + + #[test] + fn terminal_task_polling_distinguishes_working_and_terminal_states() { + assert_eq!( + task_outcome(&json!({ "status": { "state": "working" } }), "task-1") + .expect("working is nonterminal"), + None, + ); + assert_eq!( + task_outcome( + &json!({ "status": { "state": "TASK_STATE_SUBMITTED" } }), + "task-1" + ) + .expect("protobuf-style submitted is nonterminal"), + None, + ); + assert_eq!( + task_outcome(&json!({ "status": { "state": "completed" } }), "task-1") + .expect("completed is successful"), + Some("A2A task task-1 completed".into()), + ); + assert_eq!( + task_outcome( + &json!({ "status": { "state": "TASK_STATE_COMPLETED" } }), + "task-1" + ) + .expect("protobuf-style completed is successful"), + Some("A2A task task-1 completed".into()), + ); + let failed = task_outcome( + &json!({ + "status": { "state": "TASK_STATE_FAILED" }, + "parts": [{ "text": "remote failure" }] + }), + "task-1", + ); + assert!(failed + .expect_err("failed tasks are ACP errors") + .to_string() + .contains("remote failure")); + } + + #[test] + fn unwraps_current_a2a_task_and_message_results() { + let task = json!({ "task": { "id": "task-2", "status": { "state": "completed" }, "artifacts": [{ "parts": [{ "text": "complete" }] }] } }); + let task_result = task.get("task").expect("task result"); + assert_eq!(task_result["id"], "task-2"); + assert_eq!( + task_outcome(task_result, "task-2").expect("completed task"), + Some("complete".into()), + ); + + let message = json!({ "message": { "messageId": "m-1", "role": "ROLE_AGENT", "parts": [{ "text": "direct response" }] } }); + let message_result = message.get("message").expect("message result"); + assert_eq!(extract_text(message_result), Some("direct response".into())); + } + + #[tokio::test] + async fn acp_reader_accepts_multiple_bounded_lines() { + let input = b"{\"method\":\"initialize\"}\r\n{\"method\":\"session/new\"}\n"; + let mut reader = BufReader::new(&input[..]); + assert_eq!( + read_bounded_line(&mut reader).await.expect("first line"), + Some("{\"method\":\"initialize\"}".into()) + ); + assert_eq!( + read_bounded_line(&mut reader).await.expect("second line"), + Some("{\"method\":\"session/new\"}".into()) + ); + assert_eq!(read_bounded_line(&mut reader).await.expect("eof"), None); + } + + #[tokio::test] + async fn spawned_reader_preserves_partial_lines_while_other_work_completes() { + use tokio::io::AsyncWriteExt; + + let (mut writer, reader) = tokio::io::duplex(32 * 1024); + let mut lines = spawn_line_reader(BufReader::with_capacity(8 * 1024, reader)); + let line = format!( + "{{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"session/prompt\",\"padding\":\"{}\"}}", + "P".repeat(20 * 1024) + ); + writer + .write_all(&line.as_bytes()[..12 * 1024]) + .await + .expect("write first fragment"); + tokio::task::yield_now().await; + writer + .write_all(&line.as_bytes()[12 * 1024..]) + .await + .expect("write second fragment"); + writer.write_all(b"\n").await.expect("finish line"); + + assert_eq!( + lines + .recv() + .await + .expect("reader remains available") + .expect("line is valid"), + Some(line), + ); + } + + #[tokio::test] + async fn spawned_reader_stops_after_a_transport_error() { + use std::{ + pin::Pin, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, + task::{Context, Poll}, + }; + use tokio::io::{AsyncBufRead, AsyncRead, ReadBuf}; + + struct FailingReader { + reads: Arc, + } + + impl AsyncRead for FailingReader { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut ReadBuf<'_>, + ) -> Poll> { + Poll::Ready(Err(std::io::Error::other("transport failed"))) + } + } + + impl AsyncBufRead for FailingReader { + fn poll_fill_buf( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + self.reads.fetch_add(1, Ordering::Relaxed); + Poll::Ready(Err(std::io::Error::other("transport failed"))) + } + + fn consume(self: Pin<&mut Self>, _amount: usize) {} + } + + let reads = Arc::new(AtomicUsize::new(0)); + let mut lines = spawn_line_reader(FailingReader { + reads: Arc::clone(&reads), + }); + assert!(matches!( + lines.recv().await, + Some(Err(AdapterError::Read { + what: "ACP request", + .. + })) + )); + assert!(lines.recv().await.is_none()); + assert_eq!(reads.load(Ordering::Relaxed), 1); + } + + #[test] + fn idless_notifications_do_not_produce_json_rpc_responses() { + let mut sessions = HashSet::new(); + let action = handle_acp_message( + &json!({ "jsonrpc": "2.0", "method": "unknown/notification" }), + &mut sessions, + "remote-agent", + None, + ) + .expect("notification is valid"); + assert!(action.is_none()); + } + + #[test] + fn cancel_notification_remains_actionable_without_an_id() { + let mut sessions = HashSet::new(); + let action = handle_acp_message( + &json!({ + "jsonrpc": "2.0", + "method": "session/cancel", + "params": { "sessionId": "session-1" } + }), + &mut sessions, + "remote-agent", + None, + ) + .expect("cancel is valid") + .expect("cancel action"); + let AcpAction::Cancel { id, session_id } = action else { + panic!("cancel notification must produce a local action"); + }; + assert!(id.is_none()); + assert_eq!(session_id, "session-1"); + } + + #[test] + fn private_ipv4_transitional_ipv6_addresses_are_rejected() { + for address in [ + "::ffff:127.0.0.1", + "::ffff:10.0.0.1", + "64:ff9b::0a00:0001", + "2002:0a00:0001::", + "2001:0000:4136:e378:8000:63bf:3fff:fdd2", + ] { + assert!( + is_private_ip(address.parse().expect("test IP")), + "{address} must not bypass the private-address policy" + ); + } + } + + #[tokio::test] + async fn remote_artifact_requires_a_digest_before_fetch() { + let descriptor: Descriptor = serde_json::from_value(json!({ + "urls": ["https://agent.example/card.json"], + "media_type": "application/a2a-agent-card+json", + "size": 1 + })) + .expect("descriptor"); + let error = descriptor_bytes(&descriptor, None) + .await + .expect_err("unsigned remote artifact"); + assert!(error.to_string().contains("require a sha256 digest")); + } +} diff --git a/crates/buzz-a2a-acp/src/main.rs b/crates/buzz-a2a-acp/src/main.rs new file mode 100644 index 0000000000..f2ecc41c93 --- /dev/null +++ b/crates/buzz-a2a-acp/src/main.rs @@ -0,0 +1,6 @@ +fn main() { + if let Err(error) = buzz_a2a_acp::run_cli() { + eprintln!("buzz-a2a-acp: {error}"); + std::process::exit(2); + } +} diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 23f0345e96..f0d6749679 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -418,6 +418,8 @@ impl AcpClient { use std::process::Stdio; let mut cmd = tokio::process::Command::new(command); + let is_remote_a2a_adapter = + crate::config::normalize_agent_command_identity(command) == "buzz-a2a-acp"; cmd.args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -426,6 +428,19 @@ impl AcpClient { // Ensure the child is killed when the AcpClient is dropped (best-effort). // Callers MUST still call shutdown().await for guaranteed cleanup. .kill_on_drop(true); + if is_remote_a2a_adapter { + for key in [ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + "BUZZ_A2A_BEARER_TOKEN", + ] { + cmd.env_remove(key); + } + } // Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER). // For most keys, operator precedence wins: skip injection if already set @@ -456,6 +471,10 @@ impl AcpClient { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } + if is_remote_a2a_adapter && key == "BUZZ_A2A_BEARER_TOKEN" { + cmd.env(key, value); + continue; + } if std::env::var(key).is_err() { cmd.env(key, value); } diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..77b52cd432 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -1026,6 +1026,13 @@ impl Config { } else { false }; + if normalize_agent_command_identity(&agent_command) == "buzz-a2a-acp" { + if let Ok(token) = std::env::var("BUZZ_A2A_BEARER_TOKEN") { + if !token.is_empty() { + persona_env_vars.push(("BUZZ_A2A_BEARER_TOKEN".to_string(), token)); + } + } + } validate_multiple_event_handling(args.multiple_event_handling, args.dedup)?; diff --git a/crates/sprig/Cargo.toml b/crates/sprig/Cargo.toml index 4e8c4ab41f..082bac570b 100644 --- a/crates/sprig/Cargo.toml +++ b/crates/sprig/Cargo.toml @@ -15,5 +15,6 @@ path = "src/main.rs" [dependencies] buzz-acp = { path = "../buzz-acp" } +buzz-a2a-acp = { path = "../buzz-a2a-acp" } buzz-agent = { path = "../buzz-agent" } buzz-dev-mcp = { path = "../buzz-dev-mcp" } diff --git a/crates/sprig/src/main.rs b/crates/sprig/src/main.rs index 672a5a5f37..530e506d7d 100644 --- a/crates/sprig/src/main.rs +++ b/crates/sprig/src/main.rs @@ -15,6 +15,7 @@ fn dispatch() -> Result<(), String> { match cmd.as_str() { "buzz-acp" => buzz_acp::run().map_err(|e| e.to_string()), + "buzz-a2a-acp" => buzz_a2a_acp::run_cli(), "buzz-agent" => buzz_agent::run().map_err(|e| e.to_string()), "sprig" => match std::env::args().nth(1).as_deref() { Some("-V") | Some("--version") => { @@ -46,8 +47,8 @@ fn print_usage() { println!( "Sprig — all-in-one Buzz ACP harness, agent, and developer MCP\n\n\ Sprig is a multicall binary. Invoke it through one of the personality names:\n\n\ - buzz-acp ACP harness\n buzz-agent ACP-compliant agent\n buzz-dev-mcp Developer MCP server\n\n\ + buzz-acp ACP harness\n buzz-agent ACP-compliant agent\n buzz-a2a-acp OASF/A2A remote-agent ACP adapter\n buzz-dev-mcp Developer MCP server\n\n\ Developer MCP helper names are also supported: rg, tree, buzz, git-credential-nostr, git-sign-nostr.\n\n\ -Installers can create links with:\n ln -s sprig buzz-acp\n ln -s sprig buzz-agent\n ln -s sprig buzz-dev-mcp" +Installers can create links with:\n ln -s sprig buzz-acp\n ln -s sprig buzz-agent\n ln -s sprig buzz-a2a-acp\n ln -s sprig buzz-dev-mcp" ); } diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 7a480c4c18..d2c4977390 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -55,6 +55,7 @@ "externalBin": [ "binaries/buzz-acp", "binaries/buzz-agent", + "binaries/buzz-a2a-acp", "binaries/buzz-dev-mcp", "binaries/git-credential-nostr", "binaries/buzz" diff --git a/scripts/build-sprig.sh b/scripts/build-sprig.sh index 77ee6e5832..de27b343ab 100755 --- a/scripts/build-sprig.sh +++ b/scripts/build-sprig.sh @@ -5,6 +5,7 @@ # sprig implementation binary # buzz-acp link to sprig (ACP harness) # buzz-agent link to sprig (ACP-compliant agent) +# buzz-a2a-acp link to sprig (OASF/A2A remote-agent ACP adapter) # buzz-dev-mcp link to sprig (developer MCP server; also dispatches # rg/tree/buzz/git-credential-nostr/git-sign-nostr) # @@ -34,6 +35,7 @@ # sprig # buzz-acp # buzz-agent +# buzz-a2a-acp # buzz-dev-mcp # README.md # sprig.json { version, git_sha, target, binaries: [{name, sha256, size}] } @@ -59,7 +61,7 @@ else fi BUNDLE_BIN="sprig" -COMMANDS=(buzz-acp buzz-agent buzz-dev-mcp) +COMMANDS=(buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp) echo "==> Building Sprig v${VERSION} for ${TARGET}" echo " git_sha=${GIT_SHA}" @@ -143,6 +145,8 @@ Commands: - `buzz-acp` — ACP harness that bridges Buzz channel events to an ACP-compliant agent over stdio. - `buzz-agent` — ACP-compliant agent (spawns MCP servers, calls LLMs). +- `buzz-a2a-acp` — OASF/A2A remote-agent ACP adapter. It reads an operator- + supplied Agent Record and forwards prompts through the advertised A2A endpoint. - `buzz-dev-mcp` — Developer MCP server (shell, str_replace, todo) and multicall entrypoint for `rg`, `tree`, `buzz`, `git-credential-nostr`, `git-sign-nostr`. diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 07de477405..a09f54a93a 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) +SIDECARS=(buzz-acp buzz-agent buzz-a2a-acp buzz-dev-mcp git-credential-nostr buzz) HOST=$(rustc -vV | sed -n 's|host: ||p') TARGET=${1:-$HOST} BINARIES_DIR="desktop/src-tauri/binaries" @@ -29,7 +29,7 @@ for bin in "${SIDECARS[@]}"; do done if [[ ${#missing[@]} -gt 0 ]]; then echo "Error: missing release binaries in $SRC_DIR: ${missing[*]}" >&2 - echo "Run 'cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli' first." >&2 + echo "Run 'cargo build --release -p buzz-acp -p buzz-agent -p buzz-a2a-acp -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli' first." >&2 exit 1 fi From ed2b720641636d2609ae2db45c49a7ab3ca4e761 Mon Sep 17 00:00:00 2001 From: Tim Marman Date: Tue, 28 Jul 2026 08:07:33 -0700 Subject: [PATCH 02/99] feat(a2a): negotiate advertised protocol extensions Signed-off-by: Tim Marman --- crates/buzz-a2a-acp/src/lib.rs | 254 ++++++++++++++++++++++++++++++++- 1 file changed, 247 insertions(+), 7 deletions(-) diff --git a/crates/buzz-a2a-acp/src/lib.rs b/crates/buzz-a2a-acp/src/lib.rs index 7419568694..f4020d8525 100644 --- a/crates/buzz-a2a-acp/src/lib.rs +++ b/crates/buzz-a2a-acp/src/lib.rs @@ -12,7 +12,7 @@ use serde::Deserialize; use serde_json::{json, value::RawValue, Value}; use sha2::{Digest, Sha256}; use std::{ - collections::HashSet, + collections::{BTreeMap, HashSet}, net::{IpAddr, SocketAddr}, path::{Path, PathBuf}, sync::atomic::{AtomicU64, Ordering}, @@ -25,6 +25,9 @@ use uuid::Uuid; const MAX_RECORD_BYTES: usize = 2 * 1024 * 1024; const MAX_ARTIFACT_BYTES: usize = 2 * 1024 * 1024; const MAX_ACP_LINE_BYTES: usize = 1024 * 1024; +const MAX_EXTENSIONS_JSON_BYTES: usize = 64 * 1024; +const MAX_EXTENSIONS: usize = 32; +const MAX_EXTENSION_URI_BYTES: usize = 2 * 1024; const DEFAULT_TASK_POLL_SECS: u64 = 7_200; const TASK_POLL_BACKOFF_SECS: [u64; 4] = [1, 5, 15, 30]; @@ -39,6 +42,8 @@ pub struct AdapterConfig { pub bearer_token_endpoint: Option, /// Optional caller-supplied A2A conversation context identifier. pub context_id: Option, + /// Extension metadata keyed by an exact URI advertised in the Agent Card. + pub extensions: BTreeMap, /// Maximum time to wait for an asynchronous A2A task. pub task_poll_secs: u64, } @@ -61,6 +66,10 @@ struct Cli { #[arg(long, env = "BUZZ_A2A_CONTEXT_ID")] context_id: Option, + /// JSON object keyed by A2A extension URI. + #[arg(long, env = "BUZZ_A2A_EXTENSIONS_JSON")] + extensions_json: Option, + /// Maximum time to wait for an asynchronous A2A task. #[arg( long, @@ -99,6 +108,12 @@ pub enum AdapterError { InvalidArtifact(String), #[error("A2A endpoint is not advertised by the Agent Card")] MissingEndpoint, + #[error("invalid A2A extension configuration: {0}")] + InvalidExtensionConfig(String), + #[error("A2A extension is not advertised by the Agent Card: {0}")] + UnsupportedExtension(String), + #[error("Agent Card requires an A2A extension that is not configured: {0}")] + RequiredExtension(String), #[error("unsafe endpoint URL: {0}")] UnsafeEndpoint(String), #[error("bearer token is not authorized for A2A endpoint {0}")] @@ -227,6 +242,27 @@ pub struct AgentCard { /// Current A2A interface declarations. #[serde(default, rename = "supportedInterfaces")] pub supported_interfaces: Vec, + /// Optional protocol extensions advertised by the agent. + #[serde(default)] + pub capabilities: AgentCapabilities, +} + +#[derive(Debug, Clone, Default, Deserialize)] +/// A2A capabilities used by the adapter. +pub struct AgentCapabilities { + /// Extension declarations from the Agent Card. + #[serde(default)] + pub extensions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +/// One A2A protocol extension advertised by an Agent Card. +pub struct AgentExtension { + /// Exact URI used for negotiation and message metadata. + pub uri: String, + /// Whether a client must activate the extension to invoke the agent. + #[serde(default)] + pub required: bool, } #[derive(Debug, Clone, Deserialize)] @@ -771,12 +807,80 @@ fn protocol_request( client: &Client, mode: &ProtocolMode, endpoint: &str, + extensions: &BTreeMap, ) -> reqwest::RequestBuilder { let request = client.post(endpoint); - match mode.a2a_version() { + let request = match mode.a2a_version() { Some(version) => request.header("A2A-Version", version), None => request, + }; + if extensions.is_empty() { + request + } else { + request.header( + "A2A-Extensions", + extensions.keys().cloned().collect::>().join(", "), + ) + } +} + +fn parse_extensions_json(raw: Option<&str>) -> Result, AdapterError> { + let Some(raw) = raw.filter(|value| !value.trim().is_empty()) else { + return Ok(BTreeMap::new()); + }; + if raw.len() > MAX_EXTENSIONS_JSON_BYTES { + return Err(AdapterError::InvalidExtensionConfig(format!( + "configuration exceeds {MAX_EXTENSIONS_JSON_BYTES} bytes" + ))); + } + let extensions: BTreeMap = serde_json::from_str(raw) + .map_err(|error| AdapterError::InvalidExtensionConfig(error.to_string()))?; + if extensions.len() > MAX_EXTENSIONS { + return Err(AdapterError::InvalidExtensionConfig(format!( + "configuration exceeds {MAX_EXTENSIONS} extensions" + ))); + } + for uri in extensions.keys() { + validate_extension_uri(uri)?; + } + Ok(extensions) +} + +fn validate_extension_uri(uri: &str) -> Result<(), AdapterError> { + if uri.is_empty() || uri.len() > MAX_EXTENSION_URI_BYTES { + return Err(AdapterError::InvalidExtensionConfig( + "extension URI is empty or too long".into(), + )); } + Url::parse(uri) + .map(|_| ()) + .map_err(|_| AdapterError::InvalidExtensionConfig(format!("invalid extension URI: {uri}"))) +} + +fn negotiate_extensions( + card: &AgentCard, + configured: &BTreeMap, + mode: &ProtocolMode, +) -> Result, AdapterError> { + if !configured.is_empty() && matches!(mode, ProtocolMode::VendorServiceEndpoint { .. }) { + return Err(AdapterError::InvalidExtensionConfig( + "A2A extensions require a standard A2A interface".into(), + )); + } + let mut advertised = HashSet::new(); + for extension in &card.capabilities.extensions { + validate_extension_uri(&extension.uri)?; + advertised.insert(extension.uri.as_str()); + if extension.required && !configured.contains_key(&extension.uri) { + return Err(AdapterError::RequiredExtension(extension.uri.clone())); + } + } + for uri in configured.keys() { + if !advertised.contains(uri.as_str()) { + return Err(AdapterError::UnsupportedExtension(uri.clone())); + } + } + Ok(configured.clone()) } #[derive(Debug, Deserialize)] @@ -858,6 +962,7 @@ async fn invoke( resolved: &ResolvedAgent, token: Option<&str>, token_endpoint: Option<&str>, + extensions: &BTreeMap, task_poll_secs: u64, session_id: &str, text: &str, @@ -874,9 +979,15 @@ async fn invoke( id, session_id, text, + extensions, ); - let mut request = - protocol_request(&client, &resolved.mode, resolved.mode.endpoint()).json(&payload); + let mut request = protocol_request( + &client, + &resolved.mode, + resolved.mode.endpoint(), + extensions, + ) + .json(&payload); if let Some(token) = token { request = request.bearer_auth(token); } @@ -918,6 +1029,7 @@ async fn invoke( task_id, task_poll_secs, &client, + extensions, ) .await; } @@ -937,6 +1049,7 @@ async fn poll_task( task_id: &str, task_poll_secs: u64, client: &Client, + extensions: &BTreeMap, ) -> Result { let started = std::time::Instant::now(); let timeout = std::time::Duration::from_secs(task_poll_secs); @@ -965,7 +1078,8 @@ async fn poll_task( }); validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; let mut request = - protocol_request(client, &resolved.mode, resolved.mode.endpoint()).json(&payload); + protocol_request(client, &resolved.mode, resolved.mode.endpoint(), extensions) + .json(&payload); if let Some(token) = token { request = request.bearer_auth(token); } @@ -1058,8 +1172,9 @@ fn request_payload( id: u64, session_id: &str, text: &str, + extensions: &BTreeMap, ) -> Value { - let params = match mode { + let mut params = match mode { ProtocolMode::JsonRpc { protocol_version, .. } if protocol_version @@ -1079,6 +1194,23 @@ fn request_payload( "contextId": session_id, }), }; + if !extensions.is_empty() && matches!(mode, ProtocolMode::JsonRpc { .. }) { + if let Some(message) = params.get_mut("message").and_then(Value::as_object_mut) { + message.insert( + "extensions".into(), + Value::Array(extensions.keys().cloned().map(Value::String).collect()), + ); + message.insert( + "metadata".into(), + Value::Object( + extensions + .iter() + .map(|(uri, metadata)| (uri.clone(), metadata.clone())) + .collect(), + ), + ); + } + } json!({ "jsonrpc": "2.0", "id": id, "method": mode.method(false), "params": params }) } @@ -1230,6 +1362,7 @@ pub async fn run(config: AdapterConfig) -> Result<(), AdapterError> { record.verification.label() ); let (resolved, source) = resolve_card(record.record, record.base.as_ref()).await?; + let extensions = negotiate_extensions(&resolved.card, &config.extensions, &resolved.mode)?; if source == CardSource::DeprecatedCardData { eprintln!( "buzz-a2a-acp: using deprecated OASF integration/a2a data.card_data compatibility path" @@ -1328,12 +1461,14 @@ pub async fn run(config: AdapterConfig) -> Result<(), AdapterError> { let prompt_token = config.bearer_token.clone(); let prompt_token_endpoint = config.bearer_token_endpoint.clone(); let prompt_task_poll_secs = config.task_poll_secs; + let prompt_extensions = extensions.clone(); let prompt_session_id = session_id.clone(); let task = tokio::spawn(async move { invoke( &prompt_resolved, prompt_token.as_deref(), prompt_token_endpoint.as_deref(), + &prompt_extensions, prompt_task_poll_secs, &prompt_session_id, &text, @@ -1481,6 +1616,8 @@ pub fn run_cli() -> Result<(), String> { let bearer_token = std::env::var("BUZZ_A2A_BEARER_TOKEN") .ok() .filter(|value| !value.trim().is_empty()); + let extensions = parse_extensions_json(args.extensions_json.as_deref()) + .map_err(|error| error.to_string())?; tokio::runtime::Builder::new_multi_thread() .enable_all() .build() @@ -1490,6 +1627,7 @@ pub fn run_cli() -> Result<(), String> { bearer_token, bearer_token_endpoint: args.bearer_token_endpoint, context_id: args.context_id, + extensions, task_poll_secs: args.task_poll_secs, })) .map_err(|error| error.to_string()) @@ -1587,6 +1725,7 @@ mod tests { 1, "session", "hello", + &BTreeMap::new(), ); assert_eq!(current["method"], "SendMessage"); assert_eq!(current["params"]["message"]["role"], "ROLE_USER"); @@ -1600,6 +1739,7 @@ mod tests { 2, "session", "hello", + &BTreeMap::new(), ); assert_eq!(vendor["method"], "agent/sendMessage"); assert_eq!(vendor["params"]["agentId"], "remote"); @@ -1630,7 +1770,7 @@ mod tests { None, ), ] { - let request = protocol_request(&client, &mode, mode.endpoint()) + let request = protocol_request(&client, &mode, mode.endpoint(), &BTreeMap::new()) .build() .expect("request builds"); assert_eq!( @@ -1654,6 +1794,7 @@ mod tests { 3, "buzz-session", "hello", + &BTreeMap::new(), ); assert_eq!(request["method"], "message/send"); assert!(request["params"]["contextId"].is_null()); @@ -1661,6 +1802,105 @@ mod tests { assert_eq!(request["params"]["message"]["parts"][0]["kind"], "text"); } + #[test] + fn negotiates_and_projects_advertised_extensions() { + let extension_uri = "https://example.com/a2a/extensions/work-context/v1"; + let card: AgentCard = serde_json::from_value(json!({ + "capabilities": { + "extensions": [{ "uri": extension_uri }] + }, + "supportedInterfaces": [{ + "url": "https://agent.example/rpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }] + })) + .expect("card"); + let mode = select_protocol_mode(&card).expect("mode"); + let configured = BTreeMap::from([( + extension_uri.to_string(), + json!({ "organizationRef": "https://example.com/organizations/acme" }), + )]); + let active = + negotiate_extensions(&card, &configured, &mode).expect("extension is advertised"); + let request = request_payload(&mode, None, 4, "session", "hello", &active); + assert_eq!( + request["params"]["message"]["extensions"], + json!([extension_uri]) + ); + assert_eq!( + request["params"]["message"]["metadata"][extension_uri]["organizationRef"], + "https://example.com/organizations/acme" + ); + + let http_request = protocol_request(&Client::new(), &mode, mode.endpoint(), &active) + .build() + .expect("request builds"); + assert_eq!( + http_request + .headers() + .get("A2A-Extensions") + .and_then(|value| value.to_str().ok()), + Some(extension_uri) + ); + } + + #[test] + fn rejects_unadvertised_and_missing_required_extensions() { + let required_uri = "https://example.com/a2a/extensions/required/v1"; + let card: AgentCard = serde_json::from_value(json!({ + "capabilities": { + "extensions": [{ "uri": required_uri, "required": true }] + }, + "supportedInterfaces": [{ + "url": "https://agent.example/rpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }] + })) + .expect("card"); + let mode = select_protocol_mode(&card).expect("mode"); + assert!(matches!( + negotiate_extensions(&card, &BTreeMap::new(), &mode), + Err(AdapterError::RequiredExtension(uri)) if uri == required_uri + )); + + let configured = BTreeMap::from([( + "https://example.com/a2a/extensions/other/v1".to_string(), + json!({}), + )]); + assert!(matches!( + negotiate_extensions(&card, &configured, &mode), + Err(AdapterError::RequiredExtension(uri)) if uri == required_uri + )); + + let optional_card: AgentCard = serde_json::from_value(json!({ + "capabilities": { "extensions": [] }, + "supportedInterfaces": [{ + "url": "https://agent.example/rpc", + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0" + }] + })) + .expect("card"); + let optional_mode = select_protocol_mode(&optional_card).expect("mode"); + assert!(matches!( + negotiate_extensions(&optional_card, &configured, &optional_mode), + Err(AdapterError::UnsupportedExtension(uri)) + if uri == "https://example.com/a2a/extensions/other/v1" + )); + } + + #[test] + fn parses_bounded_extension_configuration() { + let parsed = + parse_extensions_json(Some(r#"{"urn:example:extension":{"scope":"project-1"}}"#)) + .expect("valid extension map"); + assert_eq!(parsed["urn:example:extension"]["scope"], "project-1"); + assert!(parse_extensions_json(Some("[]")).is_err()); + assert!(parse_extensions_json(Some(r#"{"not a uri":{}}"#)).is_err()); + } + #[test] fn rejects_non_loopback_http_endpoints() { let card: AgentCard = serde_json::from_value(json!({ From 20578cc47f3ea667cba276a5b9c40ae8b8cf00d5 Mon Sep 17 00:00:00 2001 From: Tim Marman Date: Tue, 28 Jul 2026 10:51:17 -0700 Subject: [PATCH 03/99] docs(a2a): document extension configuration Signed-off-by: Tim Marman --- AGENTS.md | 1 + crates/buzz-a2a-acp/README.md | 23 +++++++++++++++++++++++ crates/buzz-a2a-acp/src/lib.rs | 19 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 7ff0eb4d47..5798f090f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,7 @@ crates/ buzz-media # Blossom/S3 media storage # Agent surface buzz-acp # ACP harness bridging Buzz events to AI agents + buzz-a2a-acp # OASF Agent Record + A2A remote-runtime adapter over ACP buzz-agent # Minimal ACP-compliant agent (non-streaming, tool-calls-as-output) buzz-dev-mcp # Developer MCP server — shell + file-edit tools buzz-persona # Agent persona packs diff --git a/crates/buzz-a2a-acp/README.md b/crates/buzz-a2a-acp/README.md index ccf49117cb..9f459126df 100644 --- a/crates/buzz-a2a-acp/README.md +++ b/crates/buzz-a2a-acp/README.md @@ -69,6 +69,29 @@ default. An operator can supply a stable identifier with `--context-id` or `BUZZ_A2A_CONTEXT_ID` when the host has a durable A2A conversation reference to preserve intentionally. +## A2A extensions + +Use `--extensions-json` or `BUZZ_A2A_EXTENSIONS_JSON` to activate optional A2A +protocol extensions. The value must be a JSON object. Each key must be an exact +extension URI advertised by the Agent Card. Each value is the metadata that the +adapter sends for that extension. + +```json +{ + "https://example.com/a2a/extensions/work-context/v1": { + "organizationRef": "https://example.com/organizations/acme", + "spaceRef": "https://example.com/spaces/project-1" + } +} +``` + +For a standard A2A interface, the adapter sends the configured URIs in the +`A2A-Extensions` header. It also sends the URI list and metadata on the A2A +message. If no extensions are configured, the adapter omits the header and both +message fields. The adapter rejects unadvertised extensions and rejects a card +whose required extension is not configured. The vendor compatibility path does +not support A2A extensions. + ## Scope and trust boundary The adapter projects public discovery metadata and A2A results. It does not diff --git a/crates/buzz-a2a-acp/src/lib.rs b/crates/buzz-a2a-acp/src/lib.rs index f4020d8525..a96810df52 100644 --- a/crates/buzz-a2a-acp/src/lib.rs +++ b/crates/buzz-a2a-acp/src/lib.rs @@ -1845,6 +1845,25 @@ mod tests { ); } + #[test] + fn omits_extension_headers_and_metadata_when_unconfigured() { + let mode = ProtocolMode::JsonRpc { + endpoint: "https://agent.example/rpc".into(), + protocol_version: Some("1.0".into()), + }; + let extensions = BTreeMap::new(); + let payload = request_payload(&mode, None, 5, "session", "hello", &extensions); + let message = &payload["params"]["message"]; + + assert!(message.get("extensions").is_none()); + assert!(message.get("metadata").is_none()); + + let request = protocol_request(&Client::new(), &mode, mode.endpoint(), &extensions) + .build() + .expect("request builds"); + assert!(request.headers().get("A2A-Extensions").is_none()); + } + #[test] fn rejects_unadvertised_and_missing_required_extensions() { let required_uri = "https://example.com/a2a/extensions/required/v1"; From 1108ca0904eab4c518efa39bae760eda6c44a62d Mon Sep 17 00:00:00 2001 From: Tim Marman Date: Tue, 28 Jul 2026 11:01:47 -0700 Subject: [PATCH 04/99] refactor(a2a): split adapter by protocol boundary Signed-off-by: Tim Marman --- crates/buzz-a2a-acp/src/a2a.rs | 554 ++++++++++ crates/buzz-a2a-acp/src/acp_loop.rs | 443 ++++++++ crates/buzz-a2a-acp/src/lib.rs | 1533 +-------------------------- crates/buzz-a2a-acp/src/net.rs | 225 ++++ crates/buzz-a2a-acp/src/oasf.rs | 307 ++++++ 5 files changed, 1567 insertions(+), 1495 deletions(-) create mode 100644 crates/buzz-a2a-acp/src/a2a.rs create mode 100644 crates/buzz-a2a-acp/src/acp_loop.rs create mode 100644 crates/buzz-a2a-acp/src/net.rs create mode 100644 crates/buzz-a2a-acp/src/oasf.rs diff --git a/crates/buzz-a2a-acp/src/a2a.rs b/crates/buzz-a2a-acp/src/a2a.rs new file mode 100644 index 0000000000..0f3e1fd87c --- /dev/null +++ b/crates/buzz-a2a-acp/src/a2a.rs @@ -0,0 +1,554 @@ +use crate::{ + net::{pinned_http_client, resolve_network_url, response_bytes, validate_http_url}, + AdapterError, MAX_ARTIFACT_BYTES, MAX_EXTENSIONS, MAX_EXTENSIONS_JSON_BYTES, + MAX_EXTENSION_URI_BYTES, TASK_POLL_BACKOFF_SECS, +}; +use reqwest::Client; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::{ + collections::{BTreeMap, HashSet}, + sync::atomic::{AtomicU64, Ordering}, +}; +use url::Url; + +#[derive(Debug, Clone, Deserialize)] +/// Public A2A Agent Card fields used to select an invocation interface. +pub struct AgentCard { + /// Non-standard identifier used only by the vendor compatibility path. + #[serde(default, rename = "id")] + pub vendor_id: Option, + /// Human-readable name, when advertised. + #[serde(default)] + pub name: Option, + /// Human-readable description, when advertised. + #[serde(default)] + pub description: Option, + /// A2A 0.3 card endpoint. + #[serde(default)] + pub url: Option, + /// Non-standard endpoint field used by the vendor compatibility path. + #[serde(default, rename = "serviceEndpoint")] + pub service_endpoint: Option, + /// Current A2A interface declarations. + #[serde(default, rename = "supportedInterfaces")] + pub supported_interfaces: Vec, + /// Optional protocol extensions advertised by the agent. + #[serde(default)] + pub capabilities: AgentCapabilities, +} + +#[derive(Debug, Clone, Default, Deserialize)] +/// A2A capabilities used by the adapter. +pub struct AgentCapabilities { + /// Extension declarations from the Agent Card. + #[serde(default)] + pub extensions: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +/// One A2A protocol extension advertised by an Agent Card. +pub struct AgentExtension { + /// Exact URI used for negotiation and message metadata. + pub uri: String, + /// Whether a client must activate the extension to invoke the agent. + #[serde(default)] + pub required: bool, +} + +#[derive(Debug, Clone, Deserialize)] +/// A protocol endpoint declared by an A2A Agent Card. +pub struct SupportedInterface { + /// URL to the protocol endpoint. + #[serde(default)] + pub url: Option, + /// Protocol binding name, for example `JSONRPC`. + #[serde(default, rename = "protocolBinding")] + pub protocol_binding: Option, + /// Protocol version declared by the remote agent. + #[serde(default, rename = "protocolVersion")] + pub protocol_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolMode { + /// A2A JSON-RPC interface declared by a current Agent Card. + JsonRpc { + endpoint: String, + protocol_version: Option, + }, + /// Compatibility with a deployed vendor card and method shape. + VendorServiceEndpoint { endpoint: String }, +} + +impl ProtocolMode { + pub(super) fn endpoint(&self) -> &str { + match self { + Self::JsonRpc { endpoint, .. } | Self::VendorServiceEndpoint { endpoint } => endpoint, + } + } + + fn a2a_version(&self) -> Option<&'static str> { + match self { + Self::JsonRpc { + protocol_version, .. + } if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) => + { + Some("1.0") + } + Self::JsonRpc { .. } => Some("0.3"), + Self::VendorServiceEndpoint { .. } => None, + } + } + + fn method(&self, task: bool) -> &str { + match self { + Self::JsonRpc { + protocol_version, .. + } => { + if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) + { + if task { + "GetTask" + } else { + "SendMessage" + } + } else if task { + "tasks/get" + } else { + "message/send" + } + } + Self::VendorServiceEndpoint { .. } => { + if task { + "agent/getTask" + } else { + "agent/sendMessage" + } + } + } + } +} + +/// A resolved public record and its invocation mode. +#[derive(Debug, Clone)] +/// Resolved public metadata and invocation mode for one remote agent. +pub struct ResolvedAgent { + /// Name from the OASF record. + pub record_name: Option, + /// OASF schema version from the record. + pub record_schema_version: Option, + /// Public A2A card resolved from the OASF module. + pub card: AgentCard, + /// Selected current or compatibility invocation mode. + pub mode: ProtocolMode, +} + +pub(super) static REQUEST_ID: AtomicU64 = AtomicU64::new(1); +/// Select the declared JSON-RPC interface, with a named pre-1.0 compatibility path. +pub fn select_protocol_mode(card: &AgentCard) -> Result { + if let Some(interface) = card.supported_interfaces.iter().find(|i| { + i.protocol_binding + .as_deref() + .is_some_and(|binding| binding.to_ascii_lowercase().contains("jsonrpc")) + }) { + if let Some(endpoint) = interface.url.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::JsonRpc { + endpoint, + protocol_version: interface.protocol_version.clone(), + }); + } + } + if let Some(endpoint) = card.service_endpoint.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::VendorServiceEndpoint { endpoint }); + } + if let Some(endpoint) = card.url.clone() { + validate_http_url(&endpoint)?; + return Ok(ProtocolMode::JsonRpc { + endpoint, + protocol_version: Some("0.3".into()), + }); + } + Err(AdapterError::MissingEndpoint) +} + +pub(super) fn protocol_request( + client: &Client, + mode: &ProtocolMode, + endpoint: &str, + extensions: &BTreeMap, +) -> reqwest::RequestBuilder { + let request = client.post(endpoint); + let request = match mode.a2a_version() { + Some(version) => request.header("A2A-Version", version), + None => request, + }; + if extensions.is_empty() { + request + } else { + request.header( + "A2A-Extensions", + extensions.keys().cloned().collect::>().join(", "), + ) + } +} + +pub(super) fn parse_extensions_json( + raw: Option<&str>, +) -> Result, AdapterError> { + let Some(raw) = raw.filter(|value| !value.trim().is_empty()) else { + return Ok(BTreeMap::new()); + }; + if raw.len() > MAX_EXTENSIONS_JSON_BYTES { + return Err(AdapterError::InvalidExtensionConfig(format!( + "configuration exceeds {MAX_EXTENSIONS_JSON_BYTES} bytes" + ))); + } + let extensions: BTreeMap = serde_json::from_str(raw) + .map_err(|error| AdapterError::InvalidExtensionConfig(error.to_string()))?; + if extensions.len() > MAX_EXTENSIONS { + return Err(AdapterError::InvalidExtensionConfig(format!( + "configuration exceeds {MAX_EXTENSIONS} extensions" + ))); + } + for uri in extensions.keys() { + validate_extension_uri(uri)?; + } + Ok(extensions) +} + +fn validate_extension_uri(uri: &str) -> Result<(), AdapterError> { + if uri.is_empty() || uri.len() > MAX_EXTENSION_URI_BYTES { + return Err(AdapterError::InvalidExtensionConfig( + "extension URI is empty or too long".into(), + )); + } + Url::parse(uri) + .map(|_| ()) + .map_err(|_| AdapterError::InvalidExtensionConfig(format!("invalid extension URI: {uri}"))) +} + +pub(super) fn negotiate_extensions( + card: &AgentCard, + configured: &BTreeMap, + mode: &ProtocolMode, +) -> Result, AdapterError> { + if !configured.is_empty() && matches!(mode, ProtocolMode::VendorServiceEndpoint { .. }) { + return Err(AdapterError::InvalidExtensionConfig( + "A2A extensions require a standard A2A interface".into(), + )); + } + let mut advertised = HashSet::new(); + for extension in &card.capabilities.extensions { + validate_extension_uri(&extension.uri)?; + advertised.insert(extension.uri.as_str()); + if extension.required && !configured.contains_key(&extension.uri) { + return Err(AdapterError::RequiredExtension(extension.uri.clone())); + } + } + for uri in configured.keys() { + if !advertised.contains(uri.as_str()) { + return Err(AdapterError::UnsupportedExtension(uri.clone())); + } + } + Ok(configured.clone()) +} + +pub(super) fn extract_text(value: &Value) -> Option { + if let Some(text) = value.get("text").and_then(Value::as_str) { + return Some(text.to_owned()); + } + if let Some(parts) = value.get("parts").and_then(Value::as_array) { + let joined = parts + .iter() + .filter_map(extract_text) + .collect::>() + .join("\n"); + if !joined.is_empty() { + return Some(joined); + } + } + if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) { + let joined = artifacts + .iter() + .rev() + .filter_map(extract_text) + .collect::>() + .join("\n"); + if !joined.is_empty() { + return Some(joined); + } + } + if let Some(history) = value.get("history").and_then(Value::as_array) { + for item in history.iter().rev() { + if item.get("role").and_then(Value::as_str) != Some("user") { + if let Some(text) = extract_text(item) { + return Some(text); + } + } + } + } + value.get("message").and_then(extract_text) +} + +pub(super) async fn invoke( + resolved: &ResolvedAgent, + token: Option<&str>, + token_endpoint: Option<&str>, + extensions: &BTreeMap, + task_poll_secs: u64, + session_id: &str, + text: &str, +) -> Result { + validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; + let endpoint = Url::parse(resolved.mode.endpoint()) + .map_err(|_| AdapterError::UnsafeEndpoint(resolved.mode.endpoint().to_owned()))?; + let addresses = resolve_network_url(&endpoint).await?; + let client = pinned_http_client(&endpoint, &addresses)?; + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let payload = request_payload( + &resolved.mode, + resolved.card.vendor_id.as_deref(), + id, + session_id, + text, + extensions, + ); + let mut request = protocol_request( + &client, + &resolved.mode, + resolved.mode.endpoint(), + extensions, + ) + .json(&payload); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|e| AdapterError::Request(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { + what: "A2A request", + status, + }); + } + let bytes = response_bytes(response, "A2A response", MAX_ARTIFACT_BYTES).await?; + let body: Value = serde_json::from_slice(&bytes) + .map_err(|e| AdapterError::Request(format!("decode A2A response: {e}")))?; + if let Some(error) = body.get("error") { + return Err(AdapterError::InvalidResponse(error.to_string())); + } + let result = body.get("result").unwrap_or(&body); + let result = result + .get("task") + .or_else(|| result.get("message")) + .unwrap_or(result); + if result.pointer("/status/state").is_some() { + let task_id = result + .get("id") + .and_then(Value::as_str) + .unwrap_or("unknown"); + if let Some(text) = task_outcome(result, task_id)? { + return Ok(text); + } + if task_id != "unknown" { + return poll_task( + resolved, + token, + token_endpoint, + task_id, + task_poll_secs, + &client, + extensions, + ) + .await; + } + return Err(AdapterError::InvalidResponse( + "A2A task response has no task id".into(), + )); + } + extract_text(result).ok_or_else(|| { + AdapterError::InvalidResponse("A2A response contains no message or task state".into()) + }) +} + +async fn poll_task( + resolved: &ResolvedAgent, + token: Option<&str>, + token_endpoint: Option<&str>, + task_id: &str, + task_poll_secs: u64, + client: &Client, + extensions: &BTreeMap, +) -> Result { + let started = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(task_poll_secs); + let mut poll_attempt = 0usize; + while started.elapsed() < timeout { + let remaining = timeout.saturating_sub(started.elapsed()); + let delay = std::time::Duration::from_secs( + TASK_POLL_BACKOFF_SECS[poll_attempt.min(TASK_POLL_BACKOFF_SECS.len() - 1)], + ) + .min(remaining); + tokio::time::sleep(delay).await; + poll_attempt = poll_attempt.saturating_add(1); + if started.elapsed() >= timeout { + break; + } + let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); + let params = match resolved.mode { + ProtocolMode::JsonRpc { .. } => json!({ "id": task_id }), + ProtocolMode::VendorServiceEndpoint { .. } => json!({ "taskId": task_id }), + }; + let payload = json!({ + "jsonrpc": "2.0", + "id": id, + "method": resolved.mode.method(true), + "params": params, + }); + validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; + let mut request = + protocol_request(client, &resolved.mode, resolved.mode.endpoint(), extensions) + .json(&payload); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request + .send() + .await + .map_err(|e| AdapterError::Request(e.to_string()))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { + what: "A2A task poll", + status, + }); + } + let bytes = response_bytes(response, "A2A task response", MAX_ARTIFACT_BYTES).await?; + let body: Value = serde_json::from_slice(&bytes) + .map_err(|e| AdapterError::Request(format!("decode A2A task response: {e}")))?; + if let Some(error) = body.get("error") { + return Err(AdapterError::InvalidResponse(error.to_string())); + } + let result = body.get("result").unwrap_or(&body); + let result = result + .get("task") + .or_else(|| result.get("message")) + .unwrap_or(result); + if let Some(text) = task_outcome(result, task_id)? { + return Ok(text); + } + } + Err(AdapterError::TaskTimeout(task_poll_secs)) +} + +pub(super) fn validate_endpoint_binding( + token: Option<&str>, + expected_endpoint: Option<&str>, + actual_endpoint: &str, +) -> Result<(), AdapterError> { + let endpoints_match = match expected_endpoint { + Some(expected) => { + let expected = validate_http_url(expected)?; + let actual = validate_http_url(actual_endpoint)?; + expected == actual + } + None => token.is_none(), + }; + if !endpoints_match { + return Err(AdapterError::UnauthorizedTokenEndpoint( + actual_endpoint.to_owned(), + )); + } + Ok(()) +} + +pub(super) fn task_outcome(result: &Value, task_id: &str) -> Result, AdapterError> { + let wire_state = result + .get("status") + .and_then(|status| status.get("state")) + .and_then(Value::as_str) + .ok_or_else(|| { + AdapterError::InvalidResponse(format!("A2A task {task_id} has no status state")) + })?; + let normalized_state = wire_state.trim().to_ascii_lowercase(); + let state = normalized_state + .strip_prefix("task_state_") + .unwrap_or(&normalized_state); + match state { + "completed" => { + Ok(Some(extract_text(result).unwrap_or_else(|| { + format!("A2A task {task_id} completed") + }))) + } + "accepted" | "submitted" | "working" | "pending" => Ok(None), + "failed" | "canceled" | "cancelled" | "rejected" | "input-required" | "input_required" => { + let detail = extract_text(result) + .map(|text| format!(": {text}")) + .unwrap_or_default(); + Err(AdapterError::InvalidResponse(format!( + "A2A task {task_id} ended in {state}{detail}" + ))) + } + other => Err(AdapterError::InvalidResponse(format!( + "A2A task {task_id} has unknown state {wire_state} (normalized as {other})" + ))), + } +} + +pub(super) fn request_payload( + mode: &ProtocolMode, + agent_id: Option<&str>, + id: u64, + session_id: &str, + text: &str, + extensions: &BTreeMap, +) -> Value { + let mut params = match mode { + ProtocolMode::JsonRpc { + protocol_version, .. + } if protocol_version + .as_deref() + .is_some_and(|version| version.starts_with("1.")) => + { + json!({ + "message": { "messageId": format!("buzz-{id}"), "role": "ROLE_USER", "contextId": session_id, "parts": [{ "text": text }] }, + }) + } + ProtocolMode::JsonRpc { .. } => json!({ + "message": { "messageId": format!("buzz-{id}"), "role": "user", "contextId": session_id, "parts": [{ "kind": "text", "text": text }] }, + }), + ProtocolMode::VendorServiceEndpoint { .. } => json!({ + "agentId": agent_id, + "message": { "role": "user", "parts": [{ "type": "text", "text": text }] }, + "contextId": session_id, + }), + }; + if !extensions.is_empty() && matches!(mode, ProtocolMode::JsonRpc { .. }) { + if let Some(message) = params.get_mut("message").and_then(Value::as_object_mut) { + message.insert( + "extensions".into(), + Value::Array(extensions.keys().cloned().map(Value::String).collect()), + ); + message.insert( + "metadata".into(), + Value::Object( + extensions + .iter() + .map(|(uri, metadata)| (uri.clone(), metadata.clone())) + .collect(), + ), + ); + } + } + json!({ "jsonrpc": "2.0", "id": id, "method": mode.method(false), "params": params }) +} diff --git a/crates/buzz-a2a-acp/src/acp_loop.rs b/crates/buzz-a2a-acp/src/acp_loop.rs new file mode 100644 index 0000000000..a6e7595b88 --- /dev/null +++ b/crates/buzz-a2a-acp/src/acp_loop.rs @@ -0,0 +1,443 @@ +use crate::{ + a2a::{invoke, negotiate_extensions}, + oasf::{load_record, resolve_card, CardSource}, + AdapterConfig, AdapterError, MAX_ACP_LINE_BYTES, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::HashSet; +use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum PromptBlock { + Text { + text: String, + }, + #[serde(other)] + Unsupported, +} + +#[derive(Debug, Deserialize)] +struct PromptParams { + #[serde(rename = "sessionId")] + session_id: String, + prompt: Vec, +} + +#[derive(Debug, Deserialize)] +struct CancelParams { + #[serde(rename = "sessionId")] + session_id: String, +} + +fn prompt_text(blocks: &[PromptBlock]) -> Result { + let text = blocks + .iter() + .filter_map(|block| match block { + PromptBlock::Text { text } => Some(text.as_str()), + PromptBlock::Unsupported => None, + }) + .collect::>() + .join("\n"); + if text.trim().is_empty() { + return Err(AdapterError::Acp("prompt contains no text content".into())); + } + Ok(text) +} + +async fn send_json( + writer: &mut W, + value: Value, +) -> Result<(), AdapterError> { + let mut line = serde_json::to_vec(&value) + .map_err(|e| AdapterError::Acp(format!("encode response: {e}")))?; + line.push(b'\n'); + writer + .write_all(&line) + .await + .map_err(|e| AdapterError::Acp(format!("write response: {e}")))?; + writer + .flush() + .await + .map_err(|e| AdapterError::Acp(format!("flush response: {e}")))?; + Ok(()) +} + +pub(super) enum AcpAction { + Response(Value), + Prompt { + id: Value, + session_id: String, + text: String, + }, + Cancel { + id: Option, + session_id: String, + }, +} + +pub(super) fn handle_acp_message( + message: &Value, + sessions: &mut HashSet, + agent_name: &str, + configured_context_id: Option<&str>, +) -> Result, AdapterError> { + let method = message.get("method").and_then(Value::as_str); + if method == Some("session/cancel") { + let params: CancelParams = + serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|e| AdapterError::Acp(format!("session/cancel params: {e}")))?; + return Ok(Some(AcpAction::Cancel { + id: message.get("id").cloned(), + session_id: params.session_id, + })); + } + let Some(id) = message.get("id").cloned() else { + return Ok(None); + }; + match method { + Some("initialize") => Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { + "protocolVersion": message.pointer("/params/protocolVersion").and_then(Value::as_u64).unwrap_or(1).min(1), + "agentCapabilities": { + "loadSession": false, + "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, + "mcpCapabilities": { "http": false, "sse": false }, + }, + "agentInfo": { "name": agent_name, "version": "oasf-a2a" }, + } + })))), + Some("session/new") => { + let session_id = configured_context_id + .map(str::to_owned) + .unwrap_or_else(|| format!("a2a-{}", Uuid::new_v4())); + sessions.insert(session_id.clone()); + Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "sessionId": session_id }, + })))) + } + Some("session/prompt") => { + let params: PromptParams = + serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|e| AdapterError::Acp(format!("session/prompt params: {e}")))?; + if !sessions.contains(¶ms.session_id) { + return Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32602, "message": "unknown session" }, + })))); + } + let text = match prompt_text(¶ms.prompt) { + Ok(text) => text, + Err(error) => { + return Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32602, "message": error.to_string() }, + })))); + } + }; + Ok(Some(AcpAction::Prompt { + id, + session_id: params.session_id, + text, + })) + } + Some(method) => Ok(Some(AcpAction::Response(json!({ + "jsonrpc": "2.0", + "id": id, + "error": { "code": -32601, "message": format!("method not found: {method}") }, + })))), + None => Ok(None), + } +} + +pub(super) fn prompt_success(id: Value, session_id: &str, text: &str) -> [Value; 2] { + [ + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session_id, + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": text }, + } + } + }), + json!({ "jsonrpc": "2.0", "id": id, "result": { "stopReason": "end_turn" } }), + ] +} + +struct ActivePrompt { + id: Value, + session_id: String, + task: tokio::task::JoinHandle>, +} + +enum LoopEvent { + Input(Option, AdapterError>>), + PromptFinished(Result, tokio::task::JoinError>), +} + +/// Run the adapter over ACP JSON-RPC lines on stdin/stdout. +pub async fn run(config: AdapterConfig) -> Result<(), AdapterError> { + let record = load_record(&config.record).await?; + eprintln!( + "buzz-a2a-acp: resolved Agent Record {} ({})", + record.content_digest, + record.verification.label() + ); + let (resolved, source) = resolve_card(record.record, record.base.as_ref()).await?; + let extensions = negotiate_extensions(&resolved.card, &config.extensions, &resolved.mode)?; + if source == CardSource::DeprecatedCardData { + eprintln!( + "buzz-a2a-acp: using deprecated OASF integration/a2a data.card_data compatibility path" + ); + } + let mut sessions = HashSet::new(); + let mut lines = spawn_line_reader(BufReader::new(tokio::io::stdin())); + let mut writer = tokio::io::stdout(); + let mut active_prompt: Option = None; + loop { + let event = if let Some(active) = active_prompt.as_mut() { + tokio::select! { + line = lines.recv() => LoopEvent::Input(line), + result = &mut active.task => LoopEvent::PromptFinished(result), + } + } else { + LoopEvent::Input(lines.recv().await) + }; + match event { + LoopEvent::PromptFinished(result) => { + let Some(active) = active_prompt.take() else { + return Err(AdapterError::Acp( + "prompt completed without an active request".into(), + )); + }; + match result { + Ok(Ok(text)) => { + for value in prompt_success(active.id, &active.session_id, &text) { + send_json(&mut writer, value).await?; + } + } + Ok(Err(error)) => { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": error.to_string() } }), + ) + .await?; + } + Err(error) => { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": format!("remote prompt task failed: {error}") } }), + ) + .await?; + } + } + } + LoopEvent::Input(None | Some(Ok(None))) => return Ok(()), + LoopEvent::Input(Some(Err(error))) => { + eprintln!("buzz-a2a-acp: ignored malformed ACP input: {error}"); + } + LoopEvent::Input(Some(Ok(Some(line)))) => { + let message: Value = match serde_json::from_str(line.trim()) { + Ok(message) => message, + Err(error) => { + eprintln!("buzz-a2a-acp: ignored malformed JSON-RPC line: {error}"); + continue; + } + }; + let action = match handle_acp_message( + &message, + &mut sessions, + resolved.card.name.as_deref().unwrap_or("remote-a2a-agent"), + config.context_id.as_deref(), + ) { + Ok(action) => action, + Err(error) => { + if let Some(id) = message.get("id").cloned() { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": error.to_string() } }), + ) + .await?; + } else { + eprintln!("buzz-a2a-acp: ignored invalid notification: {error}"); + } + continue; + } + }; + match action { + Some(AcpAction::Response(response)) => send_json(&mut writer, response).await?, + Some(AcpAction::Prompt { + id, + session_id, + text, + }) => { + if active_prompt.is_some() { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32001, "message": "another prompt is already active" } }), + ) + .await?; + continue; + } + let prompt_resolved = resolved.clone(); + let prompt_token = config.bearer_token.clone(); + let prompt_token_endpoint = config.bearer_token_endpoint.clone(); + let prompt_task_poll_secs = config.task_poll_secs; + let prompt_extensions = extensions.clone(); + let prompt_session_id = session_id.clone(); + let task = tokio::spawn(async move { + invoke( + &prompt_resolved, + prompt_token.as_deref(), + prompt_token_endpoint.as_deref(), + &prompt_extensions, + prompt_task_poll_secs, + &prompt_session_id, + &text, + ) + .await + }); + active_prompt = Some(ActivePrompt { + id, + session_id, + task, + }); + } + Some(AcpAction::Cancel { id, session_id }) => { + if active_prompt + .as_ref() + .is_some_and(|active| active.session_id == session_id) + { + let Some(active) = active_prompt.take() else { + return Err(AdapterError::Acp( + "matching prompt disappeared during cancellation".into(), + )); + }; + active.task.abort(); + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": active.id, "result": { "stopReason": "cancelled" } }), + ) + .await?; + if let Some(id) = id { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "result": {} }), + ) + .await?; + } + } else if let Some(id) = id { + send_json( + &mut writer, + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": "no active prompt for session" } }), + ) + .await?; + } + } + None => {} + } + } + } + } +} + +pub(super) fn spawn_line_reader( + mut reader: R, +) -> tokio::sync::mpsc::Receiver, AdapterError>> +where + R: tokio::io::AsyncBufRead + Send + Unpin + 'static, +{ + let (sender, receiver) = tokio::sync::mpsc::channel(8); + tokio::spawn(async move { + loop { + let line = read_bounded_line(&mut reader).await; + let reached_eof = matches!(line, Ok(None)); + let transport_failed = matches!(line, Err(AdapterError::Read { .. })); + if sender.send(line).await.is_err() || reached_eof || transport_failed { + break; + } + } + }); + receiver +} + +pub(super) async fn read_bounded_line( + reader: &mut R, +) -> Result, AdapterError> { + let mut bytes = Vec::new(); + loop { + let chunk = reader + .fill_buf() + .await + .map_err(|source| AdapterError::Read { + what: "ACP request", + source, + })?; + if chunk.is_empty() { + if bytes.is_empty() { + return Ok(None); + } + return Err(AdapterError::Acp("unterminated request at EOF".into())); + } + let take = chunk + .iter() + .position(|byte| *byte == b'\n') + .map_or(chunk.len(), |index| index + 1); + if bytes.len().saturating_add(take) > MAX_ACP_LINE_BYTES { + let ended = chunk[..take].ends_with(b"\n"); + reader.consume(take); + if !ended { + discard_until_newline(reader).await?; + } + return Err(AdapterError::Acp("request exceeds 1 MiB".into())); + } + bytes.extend_from_slice(&chunk[..take]); + reader.consume(take); + if bytes.ends_with(b"\n") { + bytes.pop(); + if bytes.ends_with(b"\r") { + bytes.pop(); + } + return String::from_utf8(bytes) + .map(Some) + .map_err(|_| AdapterError::Acp("request is not UTF-8".into())); + } + } +} + +async fn discard_until_newline( + reader: &mut R, +) -> Result<(), AdapterError> { + loop { + let chunk = reader + .fill_buf() + .await + .map_err(|source| AdapterError::Read { + what: "ACP request", + source, + })?; + if chunk.is_empty() { + return Ok(()); + } + let take = chunk + .iter() + .position(|byte| *byte == b'\n') + .map_or(chunk.len(), |index| index + 1); + let ended = chunk[..take].ends_with(b"\n"); + reader.consume(take); + if ended { + return Ok(()); + } + } +} diff --git a/crates/buzz-a2a-acp/src/lib.rs b/crates/buzz-a2a-acp/src/lib.rs index a96810df52..e8ce9fe294 100644 --- a/crates/buzz-a2a-acp/src/lib.rs +++ b/crates/buzz-a2a-acp/src/lib.rs @@ -5,22 +5,35 @@ //! The bridge is intentionally a subprocess. Buzz owns the ACP session and UI; //! the source runtime owns its agent identity, context, execution, and keys. -use base64::Engine; use clap::Parser; -use reqwest::{Client, StatusCode}; -use serde::Deserialize; -use serde_json::{json, value::RawValue, Value}; -use sha2::{Digest, Sha256}; -use std::{ - collections::{BTreeMap, HashSet}, - net::{IpAddr, SocketAddr}, - path::{Path, PathBuf}, - sync::atomic::{AtomicU64, Ordering}, -}; +use reqwest::StatusCode; +use serde_json::Value; +use std::collections::BTreeMap; use thiserror::Error; -use tokio::io::{AsyncBufReadExt, AsyncWrite, AsyncWriteExt, BufReader}; -use url::Url; -use uuid::Uuid; + +mod a2a; +use a2a::parse_extensions_json; +#[cfg(test)] +use a2a::{ + extract_text, protocol_request, request_payload, task_outcome, validate_endpoint_binding, + REQUEST_ID, +}; +pub use a2a::{ + select_protocol_mode, AgentCapabilities, AgentCard, AgentExtension, ProtocolMode, + ResolvedAgent, SupportedInterface, +}; +mod acp_loop; +pub use acp_loop::run; +#[cfg(test)] +use acp_loop::{ + handle_acp_message, prompt_success, read_bounded_line, spawn_line_reader, AcpAction, +}; +mod net; +#[cfg(test)] +use net::{is_private_ip, validate_resolved_addresses}; +mod oasf; +#[cfg(test)] +use oasf::{descriptor_bytes, AgentRecord, Descriptor}; const MAX_RECORD_BYTES: usize = 2 * 1024 * 1024; const MAX_ARTIFACT_BYTES: usize = 2 * 1024 * 1024; @@ -128,1487 +141,6 @@ pub enum AdapterError { Acp(String), } -#[derive(Debug, Deserialize)] -struct AgentRecord { - #[serde(default)] - name: Option, - #[serde(default)] - schema_version: Option, - #[serde(default)] - modules: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum RecordSource { - LocalPath(PathBuf), - HttpUrl(Url), -} - -impl RecordSource { - fn parse(source: &str) -> Result { - if source.trim().is_empty() { - return Err(AdapterError::EmptyRecord); - } - if let Ok(url) = Url::parse(source) { - if matches!(url.scheme(), "http" | "https") { - validate_http_url(source) - .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?; - return Ok(Self::HttpUrl(url)); - } - if source.contains("://") { - return Err(AdapterError::InvalidSource(source.to_owned())); - } - } - Ok(Self::LocalPath(PathBuf::from(source))) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RecordVerification { - OperatorReviewedLocal, - TlsOnly, -} - -impl RecordVerification { - fn label(self) -> &'static str { - match self { - Self::OperatorReviewedLocal => "operator-reviewed-local", - Self::TlsOnly => "tls-only", - } - } -} - -struct ResolvedRecord { - record: AgentRecord, - base: Option, - content_digest: String, - verification: RecordVerification, -} - -#[derive(Debug, Deserialize)] -struct OasfModule { - #[serde(default)] - name: Option, - #[serde(default)] - id: Option, - #[serde(default)] - artifact: Option>, - #[serde(default)] - data: Option, -} - -#[derive(Debug, Deserialize)] -struct A2aData { - #[serde(default)] - card_data: Option, - #[serde(default, rename = "card_schema_version")] - _card_schema_version: Option, -} - -#[derive(Debug, Deserialize)] -struct Descriptor { - #[serde(default)] - digest: Option, - #[serde(default, rename = "media_type")] - media_type: Option, - #[serde(default)] - size: Option, - #[serde(default)] - data: Option, - #[serde(default)] - json: Option>, - #[serde(default)] - urls: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -/// Public A2A Agent Card fields used to select an invocation interface. -pub struct AgentCard { - /// Non-standard identifier used only by the vendor compatibility path. - #[serde(default, rename = "id")] - pub vendor_id: Option, - /// Human-readable name, when advertised. - #[serde(default)] - pub name: Option, - /// Human-readable description, when advertised. - #[serde(default)] - pub description: Option, - /// A2A 0.3 card endpoint. - #[serde(default)] - pub url: Option, - /// Non-standard endpoint field used by the vendor compatibility path. - #[serde(default, rename = "serviceEndpoint")] - pub service_endpoint: Option, - /// Current A2A interface declarations. - #[serde(default, rename = "supportedInterfaces")] - pub supported_interfaces: Vec, - /// Optional protocol extensions advertised by the agent. - #[serde(default)] - pub capabilities: AgentCapabilities, -} - -#[derive(Debug, Clone, Default, Deserialize)] -/// A2A capabilities used by the adapter. -pub struct AgentCapabilities { - /// Extension declarations from the Agent Card. - #[serde(default)] - pub extensions: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -/// One A2A protocol extension advertised by an Agent Card. -pub struct AgentExtension { - /// Exact URI used for negotiation and message metadata. - pub uri: String, - /// Whether a client must activate the extension to invoke the agent. - #[serde(default)] - pub required: bool, -} - -#[derive(Debug, Clone, Deserialize)] -/// A protocol endpoint declared by an A2A Agent Card. -pub struct SupportedInterface { - /// URL to the protocol endpoint. - #[serde(default)] - pub url: Option, - /// Protocol binding name, for example `JSONRPC`. - #[serde(default, rename = "protocolBinding")] - pub protocol_binding: Option, - /// Protocol version declared by the remote agent. - #[serde(default, rename = "protocolVersion")] - pub protocol_version: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ProtocolMode { - /// A2A JSON-RPC interface declared by a current Agent Card. - JsonRpc { - endpoint: String, - protocol_version: Option, - }, - /// Compatibility with a deployed vendor card and method shape. - VendorServiceEndpoint { endpoint: String }, -} - -impl ProtocolMode { - fn endpoint(&self) -> &str { - match self { - Self::JsonRpc { endpoint, .. } | Self::VendorServiceEndpoint { endpoint } => endpoint, - } - } - - fn a2a_version(&self) -> Option<&'static str> { - match self { - Self::JsonRpc { - protocol_version, .. - } if protocol_version - .as_deref() - .is_some_and(|version| version.starts_with("1.")) => - { - Some("1.0") - } - Self::JsonRpc { .. } => Some("0.3"), - Self::VendorServiceEndpoint { .. } => None, - } - } - - fn method(&self, task: bool) -> &str { - match self { - Self::JsonRpc { - protocol_version, .. - } => { - if protocol_version - .as_deref() - .is_some_and(|version| version.starts_with("1.")) - { - if task { - "GetTask" - } else { - "SendMessage" - } - } else if task { - "tasks/get" - } else { - "message/send" - } - } - Self::VendorServiceEndpoint { .. } => { - if task { - "agent/getTask" - } else { - "agent/sendMessage" - } - } - } - } -} - -/// A resolved public record and its invocation mode. -#[derive(Debug, Clone)] -/// Resolved public metadata and invocation mode for one remote agent. -pub struct ResolvedAgent { - /// Name from the OASF record. - pub record_name: Option, - /// OASF schema version from the record. - pub record_schema_version: Option, - /// Public A2A card resolved from the OASF module. - pub card: AgentCard, - /// Selected current or compatibility invocation mode. - pub mode: ProtocolMode, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CardSource { - Artifact, - DeprecatedCardData, -} - -static REQUEST_ID: AtomicU64 = AtomicU64::new(1); - -fn pinned_http_client(url: &Url, addresses: &[SocketAddr]) -> Result { - let raw_host = url - .host_str() - .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; - let host = normalized_host(raw_host); - let mut builder = Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .timeout(std::time::Duration::from_secs(30)); - // Pin every address that passed our policy check. This prevents reqwest - // from performing a second DNS lookup while preserving IPv4/IPv6 fallback. - if host.parse::().is_err() { - if addresses.is_empty() { - return Err(AdapterError::UnsafeEndpoint(url.to_string())); - } - builder = builder.resolve_to_addrs(&host, addresses); - } - builder - .build() - .map_err(|e| AdapterError::Request(format!("build HTTP client: {e}"))) -} - -fn validate_http_url(raw: &str) -> Result { - let url = Url::parse(raw).map_err(|_| AdapterError::UnsafeEndpoint(raw.to_owned()))?; - let raw_host = url - .host_str() - .ok_or_else(|| AdapterError::UnsafeEndpoint(raw.to_owned()))?; - let host = normalized_host(raw_host); - if let Ok(ip) = host.parse::() { - // Local A2A runtimes are allowed over loopback HTTP. Private and - // link-local addresses remain rejected for every other scheme. - if url.scheme() == "http" && ip.is_loopback() { - return Ok(url); - } - if is_private_ip(ip) { - return Err(AdapterError::UnsafeEndpoint(raw.to_owned())); - } - } - if url.scheme() == "https" && host.eq_ignore_ascii_case("localhost") { - return Err(AdapterError::UnsafeEndpoint(raw.to_owned())); - } - match url.scheme() { - "https" => Ok(url), - "http" if is_loopback_host(&host) => Ok(url), - _ => Err(AdapterError::UnsafeEndpoint(raw.to_owned())), - } -} - -fn normalized_host(host: &str) -> String { - host.trim_start_matches('[') - .trim_end_matches(']') - .to_ascii_lowercase() -} - -fn is_loopback_host(host: &str) -> bool { - host.eq_ignore_ascii_case("localhost") - || host - .parse::() - .is_ok_and(|address| address.is_loopback()) -} - -fn is_private_ip(ip: IpAddr) -> bool { - let ip = match ip { - IpAddr::V6(address) => address - .to_ipv4_mapped() - .map(IpAddr::V4) - .unwrap_or(IpAddr::V6(address)), - address => address, - }; - match ip { - IpAddr::V4(ip) => { - let octets = ip.octets(); - ip.is_loopback() - || ip.is_private() - || ip.is_link_local() - || ip.is_unspecified() - || octets[0] == 0 - || (octets[0] == 100 && (64..=127).contains(&octets[1])) - } - IpAddr::V6(ip) => { - let segments = ip.segments(); - ip.is_loopback() - || ip.is_unspecified() - || ip.is_multicast() - || (segments[0] & 0xfe00) == 0xfc00 - || (segments[0] & 0xffc0) == 0xfe80 - // IPv4-transitional address ranges can encode private IPv4 - // targets while still presenting as IPv6 DNS answers. - || (segments[0] == 0x0064 - && segments[1] == 0xff9b - && segments[2..6] == [0, 0, 0, 0]) - || segments[0] == 0x2002 - || (segments[0] == 0x2001 && segments[1] == 0) - || segments[..6] == [0, 0, 0, 0, 0, 0] - } - } -} - -async fn resolve_network_url(url: &Url) -> Result, AdapterError> { - let raw_host = url - .host_str() - .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; - let host = normalized_host(raw_host); - if let Ok(ip) = host.parse::() { - if url.scheme() == "http" && ip.is_loopback() { - return Ok(vec![SocketAddr::new( - ip, - url.port_or_known_default().unwrap_or(80), - )]); - } - if !is_private_ip(ip) { - return Ok(vec![SocketAddr::new( - ip, - url.port_or_known_default().unwrap_or(443), - )]); - } - return Err(AdapterError::UnsafeEndpoint(url.to_string())); - } - let port = url - .port_or_known_default() - .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; - let addresses: Vec = tokio::net::lookup_host((host.as_str(), port)) - .await - .map_err(|_| AdapterError::UnsafeEndpoint(url.to_string()))? - .collect(); - validate_resolved_addresses(url, &addresses)?; - Ok(addresses) -} - -fn validate_resolved_addresses(url: &Url, addresses: &[SocketAddr]) -> Result<(), AdapterError> { - let raw_host = url - .host_str() - .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; - let host = normalized_host(raw_host); - if addresses.is_empty() { - return Err(AdapterError::UnsafeEndpoint(url.to_string())); - } - let is_local_http = url.scheme() == "http" && is_loopback_host(&host); - if url.scheme() == "http" && !is_local_http { - return Err(AdapterError::UnsafeEndpoint(url.to_string())); - } - if is_local_http { - if addresses.iter().any(|address| !address.ip().is_loopback()) { - return Err(AdapterError::UnsafeEndpoint(url.to_string())); - } - } else if addresses.iter().any(|address| is_private_ip(address.ip())) { - return Err(AdapterError::UnsafeEndpoint(url.to_string())); - } - Ok(()) -} - -async fn response_bytes( - mut response: reqwest::Response, - what: &'static str, - limit: usize, -) -> Result, AdapterError> { - if response - .content_length() - .is_some_and(|size| size > limit as u64) - { - return Err(AdapterError::TooLarge { what, limit }); - } - let mut body = Vec::new(); - while let Some(chunk) = response - .chunk() - .await - .map_err(|e| AdapterError::Request(format!("read {what}: {e}")))? - { - if body.len().saturating_add(chunk.len()) > limit { - return Err(AdapterError::TooLarge { what, limit }); - } - body.extend_from_slice(&chunk); - } - Ok(body) -} - -async fn read_source( - source: &str, - what: &'static str, - limit: usize, -) -> Result, AdapterError> { - if source.trim().is_empty() { - return Err(AdapterError::EmptyRecord); - } - if let Ok(url) = Url::parse(source) { - if matches!(url.scheme(), "http" | "https") { - validate_http_url(source) - .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?; - let addresses = resolve_network_url(&url).await?; - let response = pinned_http_client(&url, &addresses)? - .get(url) - .send() - .await - .map_err(|e| AdapterError::Request(format!("fetch {what}: {e}")))?; - let status = response.status(); - if !status.is_success() { - return Err(AdapterError::HttpStatus { what, status }); - } - return response_bytes(response, what, limit).await; - } - if source.contains("://") { - return Err(AdapterError::InvalidSource(source.to_owned())); - } - } - let body = tokio::fs::read(Path::new(source)) - .await - .map_err(|source| AdapterError::Read { what, source })?; - if body.len() > limit { - return Err(AdapterError::TooLarge { what, limit }); - } - Ok(body) -} - -async fn load_record(source: &str) -> Result { - let source = RecordSource::parse(source)?; - let source_text = match &source { - RecordSource::LocalPath(path) => path.to_string_lossy().into_owned(), - RecordSource::HttpUrl(url) => url.to_string(), - }; - let bytes = read_source(&source_text, "Agent Record", MAX_RECORD_BYTES).await?; - let record = if bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'[') { - let mut records: Vec = - serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode { - what: "Agent Record", - source, - })?; - if records.len() != 1 { - return Err(AdapterError::InvalidRecord(format!( - "expected exactly one Agent Record, got {}", - records.len() - ))); - } - records - .pop() - .ok_or_else(|| AdapterError::InvalidRecord("Agent Record collection is empty".into()))? - } else { - serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode { - what: "Agent Record", - source, - })? - }; - let (base, verification) = match source { - RecordSource::LocalPath(_) => (None, RecordVerification::OperatorReviewedLocal), - RecordSource::HttpUrl(url) if url.scheme() == "https" => { - (Some(url), RecordVerification::TlsOnly) - } - RecordSource::HttpUrl(url) => (Some(url), RecordVerification::OperatorReviewedLocal), - }; - Ok(ResolvedRecord { - record, - base, - content_digest: format!("sha256:{}", hex::encode(Sha256::digest(&bytes))), - verification, - }) -} - -fn descriptor_from_raw(value: &RawValue) -> Result { - let raw = value.get(); - if raw.trim_start().starts_with('[') { - let mut descriptors: Vec = serde_json::from_str(raw) - .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}")))?; - if descriptors.len() != 1 { - return Err(AdapterError::InvalidArtifact(format!( - "expected exactly one artifact descriptor, got {}", - descriptors.len() - ))); - } - descriptors - .pop() - .ok_or_else(|| AdapterError::InvalidArtifact("artifact descriptor is absent".into())) - } else { - serde_json::from_str(raw) - .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}"))) - } -} - -fn verify_descriptor(descriptor: &Descriptor, bytes: &[u8]) -> Result<(), AdapterError> { - let size = descriptor.size.ok_or_else(|| { - AdapterError::InvalidArtifact("OASF artifact descriptor requires size".into()) - })?; - if size != bytes.len() as u64 { - return Err(AdapterError::InvalidArtifact(format!( - "descriptor size {size} does not match {}", - bytes.len() - ))); - } - let digest = descriptor.digest.as_deref().ok_or_else(|| { - AdapterError::InvalidArtifact("OASF artifact descriptor requires digest".into()) - })?; - let Some(expected) = digest - .strip_prefix("sha256:") - .or_else(|| digest.strip_prefix("sha256-")) - else { - return Err(AdapterError::InvalidArtifact(format!( - "unsupported digest {digest:?}; expected sha256:" - ))); - }; - let actual = hex::encode(Sha256::digest(bytes)); - if !actual.eq_ignore_ascii_case(expected) { - return Err(AdapterError::InvalidArtifact(format!( - "sha256 digest mismatch: expected {expected}, got {actual}" - ))); - } - Ok(()) -} - -async fn descriptor_bytes( - descriptor: &Descriptor, - record_url: Option<&Url>, -) -> Result, AdapterError> { - let media_type = descriptor.media_type.as_deref().ok_or_else(|| { - AdapterError::InvalidArtifact("OASF artifact descriptor requires media_type".into()) - })?; - if !media_type.to_ascii_lowercase().contains("json") { - return Err(AdapterError::InvalidArtifact(format!( - "A2A artifact media type must be JSON, got {media_type:?}" - ))); - } - if let Some(value) = descriptor.json.as_ref() { - let bytes = value.get().as_bytes().to_vec(); - verify_descriptor(descriptor, &bytes)?; - return Ok(bytes); - } - if let Some(data) = descriptor.data.as_deref() { - let bytes = base64::engine::general_purpose::STANDARD - .decode(data) - .map_err(|e| { - AdapterError::InvalidArtifact(format!("descriptor data is not base64: {e}")) - })?; - if bytes.len() > MAX_ARTIFACT_BYTES { - return Err(AdapterError::TooLarge { - what: "A2A artifact", - limit: MAX_ARTIFACT_BYTES, - }); - } - verify_descriptor(descriptor, &bytes)?; - return Ok(bytes); - } - if let Some(raw_url) = descriptor.urls.first() { - if descriptor.digest.is_none() { - return Err(AdapterError::InvalidArtifact( - "remote artifact descriptors require a sha256 digest".into(), - )); - } - let url = if let Ok(url) = Url::parse(raw_url) { - url - } else if let Some(base) = record_url { - base.join(raw_url) - .map_err(|_| AdapterError::UnsafeEndpoint(raw_url.clone()))? - } else { - return Err(AdapterError::InvalidArtifact(format!( - "relative artifact URL {raw_url:?} requires an HTTP(S) record source" - ))); - }; - validate_http_url(url.as_str())?; - let bytes = read_source(url.as_str(), "A2A artifact", MAX_ARTIFACT_BYTES).await?; - verify_descriptor(descriptor, &bytes)?; - return Ok(bytes); - } - Err(AdapterError::InvalidArtifact( - "descriptor has no json, data, or urls".into(), - )) -} - -fn is_a2a_module(module: &OasfModule) -> bool { - module.name.as_deref() == Some("integration/a2a") - || module.id.as_ref().and_then(Value::as_u64) == Some(203) -} - -async fn resolve_card( - record: AgentRecord, - record_url: Option<&Url>, -) -> Result<(ResolvedAgent, CardSource), AdapterError> { - let module = record - .modules - .iter() - .find(|m| is_a2a_module(m)) - .ok_or_else(|| { - AdapterError::InvalidRecord("missing integration/a2a module (id 203)".into()) - })?; - let (card_value, source) = if let Some(artifact) = module.artifact.as_ref() { - let descriptor = descriptor_from_raw(artifact)?; - let bytes = descriptor_bytes(&descriptor, record_url).await?; - ( - serde_json::from_slice::(&bytes) - .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card JSON: {e}")))?, - CardSource::Artifact, - ) - } else if let Some(data) = module.data.as_ref().and_then(|data| data.card_data.clone()) { - (data, CardSource::DeprecatedCardData) - } else { - return Err(AdapterError::InvalidRecord( - "integration/a2a module has no artifact; deprecated data.card_data is also absent" - .into(), - )); - }; - let card: AgentCard = serde_json::from_value(card_value) - .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card shape: {e}")))?; - let mode = select_protocol_mode(&card)?; - Ok(( - ResolvedAgent { - record_name: record.name, - record_schema_version: record.schema_version, - card, - mode, - }, - source, - )) -} - -/// Select the declared JSON-RPC interface, with a named pre-1.0 compatibility path. -pub fn select_protocol_mode(card: &AgentCard) -> Result { - if let Some(interface) = card.supported_interfaces.iter().find(|i| { - i.protocol_binding - .as_deref() - .is_some_and(|binding| binding.to_ascii_lowercase().contains("jsonrpc")) - }) { - if let Some(endpoint) = interface.url.clone() { - validate_http_url(&endpoint)?; - return Ok(ProtocolMode::JsonRpc { - endpoint, - protocol_version: interface.protocol_version.clone(), - }); - } - } - if let Some(endpoint) = card.service_endpoint.clone() { - validate_http_url(&endpoint)?; - return Ok(ProtocolMode::VendorServiceEndpoint { endpoint }); - } - if let Some(endpoint) = card.url.clone() { - validate_http_url(&endpoint)?; - return Ok(ProtocolMode::JsonRpc { - endpoint, - protocol_version: Some("0.3".into()), - }); - } - Err(AdapterError::MissingEndpoint) -} - -fn protocol_request( - client: &Client, - mode: &ProtocolMode, - endpoint: &str, - extensions: &BTreeMap, -) -> reqwest::RequestBuilder { - let request = client.post(endpoint); - let request = match mode.a2a_version() { - Some(version) => request.header("A2A-Version", version), - None => request, - }; - if extensions.is_empty() { - request - } else { - request.header( - "A2A-Extensions", - extensions.keys().cloned().collect::>().join(", "), - ) - } -} - -fn parse_extensions_json(raw: Option<&str>) -> Result, AdapterError> { - let Some(raw) = raw.filter(|value| !value.trim().is_empty()) else { - return Ok(BTreeMap::new()); - }; - if raw.len() > MAX_EXTENSIONS_JSON_BYTES { - return Err(AdapterError::InvalidExtensionConfig(format!( - "configuration exceeds {MAX_EXTENSIONS_JSON_BYTES} bytes" - ))); - } - let extensions: BTreeMap = serde_json::from_str(raw) - .map_err(|error| AdapterError::InvalidExtensionConfig(error.to_string()))?; - if extensions.len() > MAX_EXTENSIONS { - return Err(AdapterError::InvalidExtensionConfig(format!( - "configuration exceeds {MAX_EXTENSIONS} extensions" - ))); - } - for uri in extensions.keys() { - validate_extension_uri(uri)?; - } - Ok(extensions) -} - -fn validate_extension_uri(uri: &str) -> Result<(), AdapterError> { - if uri.is_empty() || uri.len() > MAX_EXTENSION_URI_BYTES { - return Err(AdapterError::InvalidExtensionConfig( - "extension URI is empty or too long".into(), - )); - } - Url::parse(uri) - .map(|_| ()) - .map_err(|_| AdapterError::InvalidExtensionConfig(format!("invalid extension URI: {uri}"))) -} - -fn negotiate_extensions( - card: &AgentCard, - configured: &BTreeMap, - mode: &ProtocolMode, -) -> Result, AdapterError> { - if !configured.is_empty() && matches!(mode, ProtocolMode::VendorServiceEndpoint { .. }) { - return Err(AdapterError::InvalidExtensionConfig( - "A2A extensions require a standard A2A interface".into(), - )); - } - let mut advertised = HashSet::new(); - for extension in &card.capabilities.extensions { - validate_extension_uri(&extension.uri)?; - advertised.insert(extension.uri.as_str()); - if extension.required && !configured.contains_key(&extension.uri) { - return Err(AdapterError::RequiredExtension(extension.uri.clone())); - } - } - for uri in configured.keys() { - if !advertised.contains(uri.as_str()) { - return Err(AdapterError::UnsupportedExtension(uri.clone())); - } - } - Ok(configured.clone()) -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum PromptBlock { - Text { - text: String, - }, - #[serde(other)] - Unsupported, -} - -#[derive(Debug, Deserialize)] -struct PromptParams { - #[serde(rename = "sessionId")] - session_id: String, - prompt: Vec, -} - -#[derive(Debug, Deserialize)] -struct CancelParams { - #[serde(rename = "sessionId")] - session_id: String, -} - -fn prompt_text(blocks: &[PromptBlock]) -> Result { - let text = blocks - .iter() - .filter_map(|block| match block { - PromptBlock::Text { text } => Some(text.as_str()), - PromptBlock::Unsupported => None, - }) - .collect::>() - .join("\n"); - if text.trim().is_empty() { - return Err(AdapterError::Acp("prompt contains no text content".into())); - } - Ok(text) -} - -fn extract_text(value: &Value) -> Option { - if let Some(text) = value.get("text").and_then(Value::as_str) { - return Some(text.to_owned()); - } - if let Some(parts) = value.get("parts").and_then(Value::as_array) { - let joined = parts - .iter() - .filter_map(extract_text) - .collect::>() - .join("\n"); - if !joined.is_empty() { - return Some(joined); - } - } - if let Some(artifacts) = value.get("artifacts").and_then(Value::as_array) { - let joined = artifacts - .iter() - .rev() - .filter_map(extract_text) - .collect::>() - .join("\n"); - if !joined.is_empty() { - return Some(joined); - } - } - if let Some(history) = value.get("history").and_then(Value::as_array) { - for item in history.iter().rev() { - if item.get("role").and_then(Value::as_str) != Some("user") { - if let Some(text) = extract_text(item) { - return Some(text); - } - } - } - } - value.get("message").and_then(extract_text) -} - -async fn invoke( - resolved: &ResolvedAgent, - token: Option<&str>, - token_endpoint: Option<&str>, - extensions: &BTreeMap, - task_poll_secs: u64, - session_id: &str, - text: &str, -) -> Result { - validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; - let endpoint = Url::parse(resolved.mode.endpoint()) - .map_err(|_| AdapterError::UnsafeEndpoint(resolved.mode.endpoint().to_owned()))?; - let addresses = resolve_network_url(&endpoint).await?; - let client = pinned_http_client(&endpoint, &addresses)?; - let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); - let payload = request_payload( - &resolved.mode, - resolved.card.vendor_id.as_deref(), - id, - session_id, - text, - extensions, - ); - let mut request = protocol_request( - &client, - &resolved.mode, - resolved.mode.endpoint(), - extensions, - ) - .json(&payload); - if let Some(token) = token { - request = request.bearer_auth(token); - } - let response = request - .send() - .await - .map_err(|e| AdapterError::Request(e.to_string()))?; - let status = response.status(); - if !status.is_success() { - return Err(AdapterError::HttpStatus { - what: "A2A request", - status, - }); - } - let bytes = response_bytes(response, "A2A response", MAX_ARTIFACT_BYTES).await?; - let body: Value = serde_json::from_slice(&bytes) - .map_err(|e| AdapterError::Request(format!("decode A2A response: {e}")))?; - if let Some(error) = body.get("error") { - return Err(AdapterError::InvalidResponse(error.to_string())); - } - let result = body.get("result").unwrap_or(&body); - let result = result - .get("task") - .or_else(|| result.get("message")) - .unwrap_or(result); - if result.pointer("/status/state").is_some() { - let task_id = result - .get("id") - .and_then(Value::as_str) - .unwrap_or("unknown"); - if let Some(text) = task_outcome(result, task_id)? { - return Ok(text); - } - if task_id != "unknown" { - return poll_task( - resolved, - token, - token_endpoint, - task_id, - task_poll_secs, - &client, - extensions, - ) - .await; - } - return Err(AdapterError::InvalidResponse( - "A2A task response has no task id".into(), - )); - } - extract_text(result).ok_or_else(|| { - AdapterError::InvalidResponse("A2A response contains no message or task state".into()) - }) -} - -async fn poll_task( - resolved: &ResolvedAgent, - token: Option<&str>, - token_endpoint: Option<&str>, - task_id: &str, - task_poll_secs: u64, - client: &Client, - extensions: &BTreeMap, -) -> Result { - let started = std::time::Instant::now(); - let timeout = std::time::Duration::from_secs(task_poll_secs); - let mut poll_attempt = 0usize; - while started.elapsed() < timeout { - let remaining = timeout.saturating_sub(started.elapsed()); - let delay = std::time::Duration::from_secs( - TASK_POLL_BACKOFF_SECS[poll_attempt.min(TASK_POLL_BACKOFF_SECS.len() - 1)], - ) - .min(remaining); - tokio::time::sleep(delay).await; - poll_attempt = poll_attempt.saturating_add(1); - if started.elapsed() >= timeout { - break; - } - let id = REQUEST_ID.fetch_add(1, Ordering::Relaxed); - let params = match resolved.mode { - ProtocolMode::JsonRpc { .. } => json!({ "id": task_id }), - ProtocolMode::VendorServiceEndpoint { .. } => json!({ "taskId": task_id }), - }; - let payload = json!({ - "jsonrpc": "2.0", - "id": id, - "method": resolved.mode.method(true), - "params": params, - }); - validate_endpoint_binding(token, token_endpoint, resolved.mode.endpoint())?; - let mut request = - protocol_request(client, &resolved.mode, resolved.mode.endpoint(), extensions) - .json(&payload); - if let Some(token) = token { - request = request.bearer_auth(token); - } - let response = request - .send() - .await - .map_err(|e| AdapterError::Request(e.to_string()))?; - let status = response.status(); - if !status.is_success() { - return Err(AdapterError::HttpStatus { - what: "A2A task poll", - status, - }); - } - let bytes = response_bytes(response, "A2A task response", MAX_ARTIFACT_BYTES).await?; - let body: Value = serde_json::from_slice(&bytes) - .map_err(|e| AdapterError::Request(format!("decode A2A task response: {e}")))?; - if let Some(error) = body.get("error") { - return Err(AdapterError::InvalidResponse(error.to_string())); - } - let result = body.get("result").unwrap_or(&body); - let result = result - .get("task") - .or_else(|| result.get("message")) - .unwrap_or(result); - if let Some(text) = task_outcome(result, task_id)? { - return Ok(text); - } - } - Err(AdapterError::TaskTimeout(task_poll_secs)) -} - -fn validate_endpoint_binding( - token: Option<&str>, - expected_endpoint: Option<&str>, - actual_endpoint: &str, -) -> Result<(), AdapterError> { - let endpoints_match = match expected_endpoint { - Some(expected) => { - let expected = validate_http_url(expected)?; - let actual = validate_http_url(actual_endpoint)?; - expected == actual - } - None => token.is_none(), - }; - if !endpoints_match { - return Err(AdapterError::UnauthorizedTokenEndpoint( - actual_endpoint.to_owned(), - )); - } - Ok(()) -} - -fn task_outcome(result: &Value, task_id: &str) -> Result, AdapterError> { - let wire_state = result - .get("status") - .and_then(|status| status.get("state")) - .and_then(Value::as_str) - .ok_or_else(|| { - AdapterError::InvalidResponse(format!("A2A task {task_id} has no status state")) - })?; - let normalized_state = wire_state.trim().to_ascii_lowercase(); - let state = normalized_state - .strip_prefix("task_state_") - .unwrap_or(&normalized_state); - match state { - "completed" => { - Ok(Some(extract_text(result).unwrap_or_else(|| { - format!("A2A task {task_id} completed") - }))) - } - "accepted" | "submitted" | "working" | "pending" => Ok(None), - "failed" | "canceled" | "cancelled" | "rejected" | "input-required" | "input_required" => { - let detail = extract_text(result) - .map(|text| format!(": {text}")) - .unwrap_or_default(); - Err(AdapterError::InvalidResponse(format!( - "A2A task {task_id} ended in {state}{detail}" - ))) - } - other => Err(AdapterError::InvalidResponse(format!( - "A2A task {task_id} has unknown state {wire_state} (normalized as {other})" - ))), - } -} - -fn request_payload( - mode: &ProtocolMode, - agent_id: Option<&str>, - id: u64, - session_id: &str, - text: &str, - extensions: &BTreeMap, -) -> Value { - let mut params = match mode { - ProtocolMode::JsonRpc { - protocol_version, .. - } if protocol_version - .as_deref() - .is_some_and(|version| version.starts_with("1.")) => - { - json!({ - "message": { "messageId": format!("buzz-{id}"), "role": "ROLE_USER", "contextId": session_id, "parts": [{ "text": text }] }, - }) - } - ProtocolMode::JsonRpc { .. } => json!({ - "message": { "messageId": format!("buzz-{id}"), "role": "user", "contextId": session_id, "parts": [{ "kind": "text", "text": text }] }, - }), - ProtocolMode::VendorServiceEndpoint { .. } => json!({ - "agentId": agent_id, - "message": { "role": "user", "parts": [{ "type": "text", "text": text }] }, - "contextId": session_id, - }), - }; - if !extensions.is_empty() && matches!(mode, ProtocolMode::JsonRpc { .. }) { - if let Some(message) = params.get_mut("message").and_then(Value::as_object_mut) { - message.insert( - "extensions".into(), - Value::Array(extensions.keys().cloned().map(Value::String).collect()), - ); - message.insert( - "metadata".into(), - Value::Object( - extensions - .iter() - .map(|(uri, metadata)| (uri.clone(), metadata.clone())) - .collect(), - ), - ); - } - } - json!({ "jsonrpc": "2.0", "id": id, "method": mode.method(false), "params": params }) -} - -async fn send_json( - writer: &mut W, - value: Value, -) -> Result<(), AdapterError> { - let mut line = serde_json::to_vec(&value) - .map_err(|e| AdapterError::Acp(format!("encode response: {e}")))?; - line.push(b'\n'); - writer - .write_all(&line) - .await - .map_err(|e| AdapterError::Acp(format!("write response: {e}")))?; - writer - .flush() - .await - .map_err(|e| AdapterError::Acp(format!("flush response: {e}")))?; - Ok(()) -} - -enum AcpAction { - Response(Value), - Prompt { - id: Value, - session_id: String, - text: String, - }, - Cancel { - id: Option, - session_id: String, - }, -} - -fn handle_acp_message( - message: &Value, - sessions: &mut HashSet, - agent_name: &str, - configured_context_id: Option<&str>, -) -> Result, AdapterError> { - let method = message.get("method").and_then(Value::as_str); - if method == Some("session/cancel") { - let params: CancelParams = - serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null)) - .map_err(|e| AdapterError::Acp(format!("session/cancel params: {e}")))?; - return Ok(Some(AcpAction::Cancel { - id: message.get("id").cloned(), - session_id: params.session_id, - })); - } - let Some(id) = message.get("id").cloned() else { - return Ok(None); - }; - match method { - Some("initialize") => Ok(Some(AcpAction::Response(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { - "protocolVersion": message.pointer("/params/protocolVersion").and_then(Value::as_u64).unwrap_or(1).min(1), - "agentCapabilities": { - "loadSession": false, - "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, - "mcpCapabilities": { "http": false, "sse": false }, - }, - "agentInfo": { "name": agent_name, "version": "oasf-a2a" }, - } - })))), - Some("session/new") => { - let session_id = configured_context_id - .map(str::to_owned) - .unwrap_or_else(|| format!("a2a-{}", Uuid::new_v4())); - sessions.insert(session_id.clone()); - Ok(Some(AcpAction::Response(json!({ - "jsonrpc": "2.0", - "id": id, - "result": { "sessionId": session_id }, - })))) - } - Some("session/prompt") => { - let params: PromptParams = - serde_json::from_value(message.get("params").cloned().unwrap_or(Value::Null)) - .map_err(|e| AdapterError::Acp(format!("session/prompt params: {e}")))?; - if !sessions.contains(¶ms.session_id) { - return Ok(Some(AcpAction::Response(json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32602, "message": "unknown session" }, - })))); - } - let text = match prompt_text(¶ms.prompt) { - Ok(text) => text, - Err(error) => { - return Ok(Some(AcpAction::Response(json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32602, "message": error.to_string() }, - })))); - } - }; - Ok(Some(AcpAction::Prompt { - id, - session_id: params.session_id, - text, - })) - } - Some(method) => Ok(Some(AcpAction::Response(json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": -32601, "message": format!("method not found: {method}") }, - })))), - None => Ok(None), - } -} - -fn prompt_success(id: Value, session_id: &str, text: &str) -> [Value; 2] { - [ - json!({ - "jsonrpc": "2.0", - "method": "session/update", - "params": { - "sessionId": session_id, - "update": { - "sessionUpdate": "agent_message_chunk", - "content": { "type": "text", "text": text }, - } - } - }), - json!({ "jsonrpc": "2.0", "id": id, "result": { "stopReason": "end_turn" } }), - ] -} - -struct ActivePrompt { - id: Value, - session_id: String, - task: tokio::task::JoinHandle>, -} - -enum LoopEvent { - Input(Option, AdapterError>>), - PromptFinished(Result, tokio::task::JoinError>), -} - -/// Run the adapter over ACP JSON-RPC lines on stdin/stdout. -pub async fn run(config: AdapterConfig) -> Result<(), AdapterError> { - let record = load_record(&config.record).await?; - eprintln!( - "buzz-a2a-acp: resolved Agent Record {} ({})", - record.content_digest, - record.verification.label() - ); - let (resolved, source) = resolve_card(record.record, record.base.as_ref()).await?; - let extensions = negotiate_extensions(&resolved.card, &config.extensions, &resolved.mode)?; - if source == CardSource::DeprecatedCardData { - eprintln!( - "buzz-a2a-acp: using deprecated OASF integration/a2a data.card_data compatibility path" - ); - } - let mut sessions = HashSet::new(); - let mut lines = spawn_line_reader(BufReader::new(tokio::io::stdin())); - let mut writer = tokio::io::stdout(); - let mut active_prompt: Option = None; - loop { - let event = if let Some(active) = active_prompt.as_mut() { - tokio::select! { - line = lines.recv() => LoopEvent::Input(line), - result = &mut active.task => LoopEvent::PromptFinished(result), - } - } else { - LoopEvent::Input(lines.recv().await) - }; - match event { - LoopEvent::PromptFinished(result) => { - let Some(active) = active_prompt.take() else { - return Err(AdapterError::Acp( - "prompt completed without an active request".into(), - )); - }; - match result { - Ok(Ok(text)) => { - for value in prompt_success(active.id, &active.session_id, &text) { - send_json(&mut writer, value).await?; - } - } - Ok(Err(error)) => { - send_json( - &mut writer, - json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": error.to_string() } }), - ) - .await?; - } - Err(error) => { - send_json( - &mut writer, - json!({ "jsonrpc": "2.0", "id": active.id, "error": { "code": -32000, "message": format!("remote prompt task failed: {error}") } }), - ) - .await?; - } - } - } - LoopEvent::Input(None | Some(Ok(None))) => return Ok(()), - LoopEvent::Input(Some(Err(error))) => { - eprintln!("buzz-a2a-acp: ignored malformed ACP input: {error}"); - } - LoopEvent::Input(Some(Ok(Some(line)))) => { - let message: Value = match serde_json::from_str(line.trim()) { - Ok(message) => message, - Err(error) => { - eprintln!("buzz-a2a-acp: ignored malformed JSON-RPC line: {error}"); - continue; - } - }; - let action = match handle_acp_message( - &message, - &mut sessions, - resolved.card.name.as_deref().unwrap_or("remote-a2a-agent"), - config.context_id.as_deref(), - ) { - Ok(action) => action, - Err(error) => { - if let Some(id) = message.get("id").cloned() { - send_json( - &mut writer, - json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": error.to_string() } }), - ) - .await?; - } else { - eprintln!("buzz-a2a-acp: ignored invalid notification: {error}"); - } - continue; - } - }; - match action { - Some(AcpAction::Response(response)) => send_json(&mut writer, response).await?, - Some(AcpAction::Prompt { - id, - session_id, - text, - }) => { - if active_prompt.is_some() { - send_json( - &mut writer, - json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32001, "message": "another prompt is already active" } }), - ) - .await?; - continue; - } - let prompt_resolved = resolved.clone(); - let prompt_token = config.bearer_token.clone(); - let prompt_token_endpoint = config.bearer_token_endpoint.clone(); - let prompt_task_poll_secs = config.task_poll_secs; - let prompt_extensions = extensions.clone(); - let prompt_session_id = session_id.clone(); - let task = tokio::spawn(async move { - invoke( - &prompt_resolved, - prompt_token.as_deref(), - prompt_token_endpoint.as_deref(), - &prompt_extensions, - prompt_task_poll_secs, - &prompt_session_id, - &text, - ) - .await - }); - active_prompt = Some(ActivePrompt { - id, - session_id, - task, - }); - } - Some(AcpAction::Cancel { id, session_id }) => { - if active_prompt - .as_ref() - .is_some_and(|active| active.session_id == session_id) - { - let Some(active) = active_prompt.take() else { - return Err(AdapterError::Acp( - "matching prompt disappeared during cancellation".into(), - )); - }; - active.task.abort(); - send_json( - &mut writer, - json!({ "jsonrpc": "2.0", "id": active.id, "result": { "stopReason": "cancelled" } }), - ) - .await?; - if let Some(id) = id { - send_json( - &mut writer, - json!({ "jsonrpc": "2.0", "id": id, "result": {} }), - ) - .await?; - } - } else if let Some(id) = id { - send_json( - &mut writer, - json!({ "jsonrpc": "2.0", "id": id, "error": { "code": -32602, "message": "no active prompt for session" } }), - ) - .await?; - } - } - None => {} - } - } - } - } -} - -fn spawn_line_reader( - mut reader: R, -) -> tokio::sync::mpsc::Receiver, AdapterError>> -where - R: tokio::io::AsyncBufRead + Send + Unpin + 'static, -{ - let (sender, receiver) = tokio::sync::mpsc::channel(8); - tokio::spawn(async move { - loop { - let line = read_bounded_line(&mut reader).await; - let reached_eof = matches!(line, Ok(None)); - let transport_failed = matches!(line, Err(AdapterError::Read { .. })); - if sender.send(line).await.is_err() || reached_eof || transport_failed { - break; - } - } - }); - receiver -} - -async fn read_bounded_line( - reader: &mut R, -) -> Result, AdapterError> { - let mut bytes = Vec::new(); - loop { - let chunk = reader - .fill_buf() - .await - .map_err(|source| AdapterError::Read { - what: "ACP request", - source, - })?; - if chunk.is_empty() { - if bytes.is_empty() { - return Ok(None); - } - return Err(AdapterError::Acp("unterminated request at EOF".into())); - } - let take = chunk - .iter() - .position(|byte| *byte == b'\n') - .map_or(chunk.len(), |index| index + 1); - if bytes.len().saturating_add(take) > MAX_ACP_LINE_BYTES { - let ended = chunk[..take].ends_with(b"\n"); - reader.consume(take); - if !ended { - discard_until_newline(reader).await?; - } - return Err(AdapterError::Acp("request exceeds 1 MiB".into())); - } - bytes.extend_from_slice(&chunk[..take]); - reader.consume(take); - if bytes.ends_with(b"\n") { - bytes.pop(); - if bytes.ends_with(b"\r") { - bytes.pop(); - } - return String::from_utf8(bytes) - .map(Some) - .map_err(|_| AdapterError::Acp("request is not UTF-8".into())); - } - } -} - -async fn discard_until_newline( - reader: &mut R, -) -> Result<(), AdapterError> { - loop { - let chunk = reader - .fill_buf() - .await - .map_err(|source| AdapterError::Read { - what: "ACP request", - source, - })?; - if chunk.is_empty() { - return Ok(()); - } - let take = chunk - .iter() - .position(|byte| *byte == b'\n') - .map_or(chunk.len(), |index| index + 1); - let ended = chunk[..take].ends_with(b"\n"); - reader.consume(take); - if ended { - return Ok(()); - } - } -} - /// Run the adapter as a normal CLI process. Sprig uses this entry point for /// the `buzz-a2a-acp` multicall personality. pub fn run_cli() -> Result<(), String> { @@ -1636,7 +168,18 @@ pub fn run_cli() -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + use crate::a2a::negotiate_extensions; + use crate::net::pinned_http_client; + use crate::oasf::{load_record, resolve_card, CardSource}; + use reqwest::Client; + use serde_json::json; + use sha2::{Digest, Sha256}; + use std::collections::HashSet; use std::fs; + use std::net::{IpAddr, SocketAddr}; + use std::sync::atomic::Ordering; + use tokio::io::{AsyncWriteExt, BufReader}; + use url::Url; fn card(endpoint: &str) -> Value { json!({ "id": "example-agent", "name": "Example Agent", "serviceEndpoint": endpoint }) diff --git a/crates/buzz-a2a-acp/src/net.rs b/crates/buzz-a2a-acp/src/net.rs new file mode 100644 index 0000000000..dcd7e8eb67 --- /dev/null +++ b/crates/buzz-a2a-acp/src/net.rs @@ -0,0 +1,225 @@ +use crate::AdapterError; +use reqwest::Client; +use std::{ + net::{IpAddr, SocketAddr}, + path::Path, +}; +use url::Url; + +pub(super) fn pinned_http_client( + url: &Url, + addresses: &[SocketAddr], +) -> Result { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + let mut builder = Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(std::time::Duration::from_secs(30)); + // Pin every address that passed our policy check. This prevents reqwest + // from performing a second DNS lookup while preserving IPv4/IPv6 fallback. + if host.parse::().is_err() { + if addresses.is_empty() { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + builder = builder.resolve_to_addrs(&host, addresses); + } + builder + .build() + .map_err(|error| AdapterError::Request(format!("build HTTP client: {error}"))) +} + +pub(super) fn validate_http_url(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|_| AdapterError::UnsafeEndpoint(raw.to_owned()))?; + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(raw.to_owned()))?; + let host = normalized_host(raw_host); + if let Ok(ip) = host.parse::() { + // Local A2A runtimes are allowed over loopback HTTP. Private and + // link-local addresses remain rejected for every other scheme. + if url.scheme() == "http" && ip.is_loopback() { + return Ok(url); + } + if is_private_ip(ip) { + return Err(AdapterError::UnsafeEndpoint(raw.to_owned())); + } + } + if url.scheme() == "https" && host.eq_ignore_ascii_case("localhost") { + return Err(AdapterError::UnsafeEndpoint(raw.to_owned())); + } + match url.scheme() { + "https" => Ok(url), + "http" if is_loopback_host(&host) => Ok(url), + _ => Err(AdapterError::UnsafeEndpoint(raw.to_owned())), + } +} + +fn normalized_host(host: &str) -> String { + host.trim_start_matches('[') + .trim_end_matches(']') + .to_ascii_lowercase() +} + +fn is_loopback_host(host: &str) -> bool { + host.eq_ignore_ascii_case("localhost") + || host + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +pub(super) fn is_private_ip(ip: IpAddr) -> bool { + let ip = match ip { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(address)), + address => address, + }; + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + ip.is_loopback() + || ip.is_private() + || ip.is_link_local() + || ip.is_unspecified() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + } + IpAddr::V6(ip) => { + let segments = ip.segments(); + ip.is_loopback() + || ip.is_unspecified() + || ip.is_multicast() + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + // IPv4-transitional address ranges can encode private IPv4 + // targets while still presenting as IPv6 DNS answers. + || (segments[0] == 0x0064 + && segments[1] == 0xff9b + && segments[2..6] == [0, 0, 0, 0]) + || segments[0] == 0x2002 + || (segments[0] == 0x2001 && segments[1] == 0) + || segments[..6] == [0, 0, 0, 0, 0, 0] + } + } +} + +pub(super) async fn resolve_network_url(url: &Url) -> Result, AdapterError> { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + if let Ok(ip) = host.parse::() { + if url.scheme() == "http" && ip.is_loopback() { + return Ok(vec![SocketAddr::new( + ip, + url.port_or_known_default().unwrap_or(80), + )]); + } + if !is_private_ip(ip) { + return Ok(vec![SocketAddr::new( + ip, + url.port_or_known_default().unwrap_or(443), + )]); + } + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + let port = url + .port_or_known_default() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let addresses: Vec = tokio::net::lookup_host((host.as_str(), port)) + .await + .map_err(|_| AdapterError::UnsafeEndpoint(url.to_string()))? + .collect(); + validate_resolved_addresses(url, &addresses)?; + Ok(addresses) +} + +pub(super) fn validate_resolved_addresses( + url: &Url, + addresses: &[SocketAddr], +) -> Result<(), AdapterError> { + let raw_host = url + .host_str() + .ok_or_else(|| AdapterError::UnsafeEndpoint(url.to_string()))?; + let host = normalized_host(raw_host); + if addresses.is_empty() { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + let is_local_http = url.scheme() == "http" && is_loopback_host(&host); + if url.scheme() == "http" && !is_local_http { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + if is_local_http { + if addresses.iter().any(|address| !address.ip().is_loopback()) { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + } else if addresses.iter().any(|address| is_private_ip(address.ip())) { + return Err(AdapterError::UnsafeEndpoint(url.to_string())); + } + Ok(()) +} + +pub(super) async fn response_bytes( + mut response: reqwest::Response, + what: &'static str, + limit: usize, +) -> Result, AdapterError> { + if response + .content_length() + .is_some_and(|size| size > limit as u64) + { + return Err(AdapterError::TooLarge { what, limit }); + } + let mut body = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| AdapterError::Request(format!("read {what}: {error}")))? + { + if body.len().saturating_add(chunk.len()) > limit { + return Err(AdapterError::TooLarge { what, limit }); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +pub(super) async fn read_source( + source: &str, + what: &'static str, + limit: usize, +) -> Result, AdapterError> { + if source.trim().is_empty() { + return Err(AdapterError::EmptyRecord); + } + if let Ok(url) = Url::parse(source) { + if matches!(url.scheme(), "http" | "https") { + validate_http_url(source) + .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?; + let addresses = resolve_network_url(&url).await?; + let response = pinned_http_client(&url, &addresses)? + .get(url) + .send() + .await + .map_err(|error| AdapterError::Request(format!("fetch {what}: {error}")))?; + let status = response.status(); + if !status.is_success() { + return Err(AdapterError::HttpStatus { what, status }); + } + return response_bytes(response, what, limit).await; + } + if source.contains("://") { + return Err(AdapterError::InvalidSource(source.to_owned())); + } + } + let body = tokio::fs::read(Path::new(source)) + .await + .map_err(|source| AdapterError::Read { what, source })?; + if body.len() > limit { + return Err(AdapterError::TooLarge { what, limit }); + } + Ok(body) +} diff --git a/crates/buzz-a2a-acp/src/oasf.rs b/crates/buzz-a2a-acp/src/oasf.rs new file mode 100644 index 0000000000..3fc78dd015 --- /dev/null +++ b/crates/buzz-a2a-acp/src/oasf.rs @@ -0,0 +1,307 @@ +use crate::{ + net::{read_source, validate_http_url}, + select_protocol_mode, AdapterError, AgentCard, ResolvedAgent, MAX_ARTIFACT_BYTES, + MAX_RECORD_BYTES, +}; +use base64::Engine; +use serde::Deserialize; +use serde_json::{value::RawValue, Value}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use url::Url; + +#[derive(Debug, Deserialize)] +pub(super) struct AgentRecord { + #[serde(default)] + name: Option, + #[serde(default)] + schema_version: Option, + #[serde(default)] + modules: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecordSource { + LocalPath(PathBuf), + HttpUrl(Url), +} + +impl RecordSource { + fn parse(source: &str) -> Result { + if source.trim().is_empty() { + return Err(AdapterError::EmptyRecord); + } + if let Ok(url) = Url::parse(source) { + if matches!(url.scheme(), "http" | "https") { + validate_http_url(source) + .map_err(|_| AdapterError::InvalidSource(source.to_owned()))?; + return Ok(Self::HttpUrl(url)); + } + if source.contains("://") { + return Err(AdapterError::InvalidSource(source.to_owned())); + } + } + Ok(Self::LocalPath(PathBuf::from(source))) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RecordVerification { + OperatorReviewedLocal, + TlsOnly, +} + +impl RecordVerification { + pub(super) fn label(self) -> &'static str { + match self { + Self::OperatorReviewedLocal => "operator-reviewed-local", + Self::TlsOnly => "tls-only", + } + } +} + +pub(super) struct ResolvedRecord { + pub(super) record: AgentRecord, + pub(super) base: Option, + pub(super) content_digest: String, + pub(super) verification: RecordVerification, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum CardSource { + Artifact, + DeprecatedCardData, +} + +#[derive(Debug, Deserialize)] +struct OasfModule { + #[serde(default)] + name: Option, + #[serde(default)] + id: Option, + #[serde(default)] + artifact: Option>, + #[serde(default)] + data: Option, +} + +#[derive(Debug, Deserialize)] +struct A2aData { + #[serde(default)] + card_data: Option, + #[serde(default, rename = "card_schema_version")] + _card_schema_version: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Descriptor { + #[serde(default)] + digest: Option, + #[serde(default, rename = "media_type")] + media_type: Option, + #[serde(default)] + size: Option, + #[serde(default)] + data: Option, + #[serde(default)] + json: Option>, + #[serde(default)] + urls: Vec, +} + +pub(super) async fn load_record(source: &str) -> Result { + let source = RecordSource::parse(source)?; + let source_text = match &source { + RecordSource::LocalPath(path) => path.to_string_lossy().into_owned(), + RecordSource::HttpUrl(url) => url.to_string(), + }; + let bytes = read_source(&source_text, "Agent Record", MAX_RECORD_BYTES).await?; + let record = if bytes.iter().find(|byte| !byte.is_ascii_whitespace()) == Some(&b'[') { + let mut records: Vec = + serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode { + what: "Agent Record", + source, + })?; + if records.len() != 1 { + return Err(AdapterError::InvalidRecord(format!( + "expected exactly one Agent Record, got {}", + records.len() + ))); + } + records + .pop() + .ok_or_else(|| AdapterError::InvalidRecord("Agent Record collection is empty".into()))? + } else { + serde_json::from_slice(&bytes).map_err(|source| AdapterError::Decode { + what: "Agent Record", + source, + })? + }; + let (base, verification) = match source { + RecordSource::LocalPath(_) => (None, RecordVerification::OperatorReviewedLocal), + RecordSource::HttpUrl(url) if url.scheme() == "https" => { + (Some(url), RecordVerification::TlsOnly) + } + RecordSource::HttpUrl(url) => (Some(url), RecordVerification::OperatorReviewedLocal), + }; + Ok(ResolvedRecord { + record, + base, + content_digest: format!("sha256:{}", hex::encode(Sha256::digest(&bytes))), + verification, + }) +} + +fn descriptor_from_raw(value: &RawValue) -> Result { + let raw = value.get(); + if raw.trim_start().starts_with('[') { + let mut descriptors: Vec = serde_json::from_str(raw) + .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}")))?; + if descriptors.len() != 1 { + return Err(AdapterError::InvalidArtifact(format!( + "expected exactly one artifact descriptor, got {}", + descriptors.len() + ))); + } + descriptors + .pop() + .ok_or_else(|| AdapterError::InvalidArtifact("artifact descriptor is absent".into())) + } else { + serde_json::from_str(raw) + .map_err(|e| AdapterError::InvalidArtifact(format!("descriptor: {e}"))) + } +} + +fn verify_descriptor(descriptor: &Descriptor, bytes: &[u8]) -> Result<(), AdapterError> { + let size = descriptor.size.ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires size".into()) + })?; + if size != bytes.len() as u64 { + return Err(AdapterError::InvalidArtifact(format!( + "descriptor size {size} does not match {}", + bytes.len() + ))); + } + let digest = descriptor.digest.as_deref().ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires digest".into()) + })?; + let Some(expected) = digest + .strip_prefix("sha256:") + .or_else(|| digest.strip_prefix("sha256-")) + else { + return Err(AdapterError::InvalidArtifact(format!( + "unsupported digest {digest:?}; expected sha256:" + ))); + }; + let actual = hex::encode(Sha256::digest(bytes)); + if !actual.eq_ignore_ascii_case(expected) { + return Err(AdapterError::InvalidArtifact(format!( + "sha256 digest mismatch: expected {expected}, got {actual}" + ))); + } + Ok(()) +} + +pub(super) async fn descriptor_bytes( + descriptor: &Descriptor, + record_url: Option<&Url>, +) -> Result, AdapterError> { + let media_type = descriptor.media_type.as_deref().ok_or_else(|| { + AdapterError::InvalidArtifact("OASF artifact descriptor requires media_type".into()) + })?; + if !media_type.to_ascii_lowercase().contains("json") { + return Err(AdapterError::InvalidArtifact(format!( + "A2A artifact media type must be JSON, got {media_type:?}" + ))); + } + if let Some(value) = descriptor.json.as_ref() { + let bytes = value.get().as_bytes().to_vec(); + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + if let Some(data) = descriptor.data.as_deref() { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data) + .map_err(|e| { + AdapterError::InvalidArtifact(format!("descriptor data is not base64: {e}")) + })?; + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(AdapterError::TooLarge { + what: "A2A artifact", + limit: MAX_ARTIFACT_BYTES, + }); + } + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + if let Some(raw_url) = descriptor.urls.first() { + if descriptor.digest.is_none() { + return Err(AdapterError::InvalidArtifact( + "remote artifact descriptors require a sha256 digest".into(), + )); + } + let url = if let Ok(url) = Url::parse(raw_url) { + url + } else if let Some(base) = record_url { + base.join(raw_url) + .map_err(|_| AdapterError::UnsafeEndpoint(raw_url.clone()))? + } else { + return Err(AdapterError::InvalidArtifact(format!( + "relative artifact URL {raw_url:?} requires an HTTP(S) record source" + ))); + }; + validate_http_url(url.as_str())?; + let bytes = read_source(url.as_str(), "A2A artifact", MAX_ARTIFACT_BYTES).await?; + verify_descriptor(descriptor, &bytes)?; + return Ok(bytes); + } + Err(AdapterError::InvalidArtifact( + "descriptor has no json, data, or urls".into(), + )) +} + +fn is_a2a_module(module: &OasfModule) -> bool { + module.name.as_deref() == Some("integration/a2a") + || module.id.as_ref().and_then(Value::as_u64) == Some(203) +} + +pub(super) async fn resolve_card( + record: AgentRecord, + record_url: Option<&Url>, +) -> Result<(ResolvedAgent, CardSource), AdapterError> { + let module = record + .modules + .iter() + .find(|m| is_a2a_module(m)) + .ok_or_else(|| { + AdapterError::InvalidRecord("missing integration/a2a module (id 203)".into()) + })?; + let (card_value, source) = if let Some(artifact) = module.artifact.as_ref() { + let descriptor = descriptor_from_raw(artifact)?; + let bytes = descriptor_bytes(&descriptor, record_url).await?; + ( + serde_json::from_slice::(&bytes) + .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card JSON: {e}")))?, + CardSource::Artifact, + ) + } else if let Some(data) = module.data.as_ref().and_then(|data| data.card_data.clone()) { + (data, CardSource::DeprecatedCardData) + } else { + return Err(AdapterError::InvalidRecord( + "integration/a2a module has no artifact; deprecated data.card_data is also absent" + .into(), + )); + }; + let card: AgentCard = serde_json::from_value(card_value) + .map_err(|e| AdapterError::InvalidArtifact(format!("Agent Card shape: {e}")))?; + let mode = select_protocol_mode(&card)?; + Ok(( + ResolvedAgent { + record_name: record.name, + record_schema_version: record.schema_version, + card, + mode, + }, + source, + )) +} From 819c5d18f7fd208723a89195a6146996230500fb Mon Sep 17 00:00:00 2001 From: Tim Marman Date: Tue, 28 Jul 2026 09:19:00 -0700 Subject: [PATCH 05/99] fix(remote-team): publish A2A output through proxy Signed-off-by: Tim Marman --- crates/buzz-acp/src/acp.rs | 48 ++++++++++++ crates/buzz-acp/src/config.rs | 55 ++++++++++++- crates/buzz-acp/src/lib.rs | 3 + crates/buzz-acp/src/pool.rs | 142 ++++++++++++++++++++++++++++++++++ 4 files changed, 246 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index f0d6749679..9a4af88dd1 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -19,6 +19,7 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Maximum allowed size of a single NDJSON line from the agent's stdout. /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB +const MAX_AGENT_MESSAGE_BYTES: usize = 60 * 1024; /// Env var that tells a goose ACP child not to start its cron scheduler. /// Injected unconditionally by [`AcpClient::spawn`]; see the call site for why. @@ -204,6 +205,10 @@ pub struct AcpClient { /// deltas. Both goose and buzz-agent emit this notification; goose gates /// on client capability advertisement, buzz-agent emits unconditionally. goose_usage: UsageTracker, + /// Text emitted as ACP `agent_message_chunk` notifications during the + /// current prompt. Remote adapters can hand this buffer back to the host + /// for proxy-signed publication without receiving the proxy private key. + agent_message: String, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -529,9 +534,17 @@ impl AcpClient { active_run_id: None, steer_rx: None, goose_usage: UsageTracker::default(), + agent_message: String::new(), }) } + /// Take the text emitted by the agent during the current prompt. + pub fn take_agent_message(&mut self) -> Option { + let message = std::mem::take(&mut self.agent_message); + let message = message.trim(); + (!message.is_empty()).then(|| message.to_string()) + } + /// Attach a local observer feed to this ACP client. pub fn set_observer(&mut self, observer: Option, agent_index: usize) { self.observer = observer; @@ -721,6 +734,7 @@ impl AcpClient { idle_timeout: std::time::Duration, max_duration: std::time::Duration, ) -> Result { + self.agent_message.clear(); let params = build_prompt_params(session_id, prompt_blocks); let hard_deadline = tokio::time::Instant::now() + max_duration; self.current_hard_deadline = Some(hard_deadline); @@ -1576,6 +1590,15 @@ impl AcpClient { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { tracing::info!(target: "acp::stream", "{text}"); + let remaining = + MAX_AGENT_MESSAGE_BYTES.saturating_sub(self.agent_message.len()); + if remaining > 0 { + let mut end = remaining.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + self.agent_message.push_str(&text[..end]); + } } false } @@ -3268,6 +3291,31 @@ mod tests { .expect("spawn cat as inert client") } + #[tokio::test] + async fn agent_message_chunks_are_collected_for_host_publication() { + let mut client = spawn_inert_client().await; + for text in ["hello ", "from remote"] { + let message = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "test-session", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": text }, + }, + }, + }); + let _ = client.handle_session_update(&message); + } + + assert_eq!( + client.take_agent_message().as_deref(), + Some("hello from remote") + ); + assert_eq!(client.take_agent_message(), None); + } + /// Build a `session/update` JSON-RPC notification carrying a /// `session_info_update` with the given `_meta.goose.activeRunId` value. /// Pass `None` to omit the `activeRunId` field entirely. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 77b52cd432..1951691db3 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -669,6 +669,15 @@ fn validate_multiple_event_handling( } pub(crate) fn normalize_agent_command_identity(command: &str) -> String { + const BUNDLED_REMOTE_ADAPTER: &str = "buzz-a2a-acp"; + const BUNDLED_TARGET_SUFFIXES: &[&str] = &[ + "-aarch64-apple-darwin", + "-x86-64-apple-darwin", + "-aarch64-unknown-linux-gnu", + "-x86-64-unknown-linux-gnu", + "-x86-64-pc-windows-msvc", + ]; + let normalized = command.trim().replace('\\', "/"); let trimmed = normalized.trim_end_matches('/'); let basename = trimmed @@ -677,12 +686,22 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { .expect("rsplit always yields at least one element"); let lower = basename.to_ascii_lowercase(); let stem = lower.strip_suffix(".exe").unwrap_or(&lower); - stem.chars() + let identity: String = stem + .chars() .map(|character| match character { ' ' | '_' => '-', _ => character, }) - .collect() + .collect(); + + if BUNDLED_TARGET_SUFFIXES + .iter() + .any(|suffix| identity == format!("{BUNDLED_REMOTE_ADAPTER}{suffix}")) + { + BUNDLED_REMOTE_ADAPTER.to_string() + } else { + identity + } } fn default_agent_args(command: &str) -> Option> { @@ -1605,6 +1624,38 @@ mod tests { assert_eq!(normalize_agent_command_identity("///"), ""); } + #[test] + fn normalizes_bundled_remote_adapter_target_suffixes() { + assert_eq!( + normalize_agent_command_identity( + "/Applications/Buzz Alpha.app/Contents/MacOS/buzz-a2a-acp-aarch64-apple-darwin" + ), + "buzz-a2a-acp" + ); + assert_eq!( + normalize_agent_command_identity( + r"C:\Program Files\Buzz\buzz-a2a-acp-x86_64-pc-windows-msvc.exe" + ), + "buzz-a2a-acp" + ); + assert_eq!( + normalize_agent_command_identity("/opt/buzz/buzz-a2a-acp-aarch64-unknown-linux-gnu"), + "buzz-a2a-acp" + ); + } + + #[test] + fn preserves_unrecognized_target_suffixed_commands() { + assert_eq!( + normalize_agent_command_identity("/opt/tools/custom-agent-aarch64-apple-darwin"), + "custom-agent-aarch64-apple-darwin" + ); + assert_eq!( + normalize_agent_command_identity("/opt/tools/buzz-a2a-acp-not-a-target"), + "buzz-a2a-acp-not-a-target" + ); + } + #[test] fn strips_legacy_acp_arg_case_insensitively() { assert_eq!( diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b11d96d8f7..647db914c5 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1561,6 +1561,9 @@ async fn tokio_main() -> Result<()> { memory_enabled: config.memory_enabled, harness_name: crate::config::normalize_agent_command_identity(&config.agent_command), relay_url: config.relay_url.clone(), + publish_agent_output: crate::config::normalize_agent_command_identity( + &config.agent_command, + ) == "buzz-a2a-acp", }); if !config.memory_enabled { diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c51fe954f..f92e2f6c0c 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -532,6 +532,9 @@ pub struct PromptContext { /// the desktop keys per (agent, relay) pair, e.g. `session_config_captured`, /// mirroring the `managed_agent_runtime_lifecycle` frames. pub relay_url: String, + /// Publish ACP message chunks with the Buzz-local proxy identity. This is + /// enabled only for remote A2A adapters, which must not receive Buzz keys. + pub publish_agent_output: bool, } impl AgentPool { @@ -2031,6 +2034,19 @@ pub async fn run_prompt_task( Some(buzz_core::agent_turn_metric::StopReason::EndTurn), ) .await; + if let Err(error) = + publish_captured_agent_output(&mut agent, &ctx, batch.as_ref()).await + { + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::Error(error), + None, + ); + return; + } send_prompt_result( &result_tx, &turn_id, @@ -2050,6 +2066,20 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); + if let Err(error) = + publish_captured_agent_output(&mut agent, &ctx, batch.as_ref()).await + { + send_prompt_result( + &result_tx, + &turn_id, + agent, + source, + PromptOutcome::Error(error), + None, + ); + return; + } + let should_rotate = matches!( stop_reason, StopReason::MaxTokens | StopReason::MaxTurnRequests @@ -3591,6 +3621,77 @@ pub(crate) async fn post_failure_notice( } } +fn automatic_reply_thread_ref(batch: &FlushBatch) -> Option { + let triggering = &batch.events.last()?.event; + let parsed = crate::queue::parse_thread_tags(triggering); + let root_event_id = parsed + .root_event_id + .as_deref() + .and_then(|root| nostr::EventId::from_hex(root).ok()) + .unwrap_or(triggering.id); + Some(buzz_sdk::ThreadRef { + root_event_id, + parent_event_id: root_event_id, + }) +} + +async fn publish_captured_agent_output( + agent: &mut OwnedAgent, + ctx: &PromptContext, + batch: Option<&FlushBatch>, +) -> Result<(), AcpError> { + let message = agent.acp.take_agent_message(); + if !ctx.publish_agent_output { + return Ok(()); + } + let (Some(message), Some(batch)) = (message, batch) else { + return Ok(()); + }; + let thread_ref = automatic_reply_thread_ref(batch); + let builder = buzz_sdk::build_message( + batch.channel_id, + &message, + thread_ref.as_ref(), + &[], + false, + &[], + ) + .map_err(|error| AcpError::Protocol(format!("build remote agent reply: {error}")))?; + let event = builder + .sign_with_keys(&ctx.agent_keys) + .map_err(|error| AcpError::Protocol(format!("sign remote agent reply: {error}")))?; + + let mut last_error = None; + for delay in [ + Duration::ZERO, + Duration::from_millis(100), + Duration::from_millis(300), + ] { + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + match tokio::time::timeout(Duration::from_secs(5), ctx.rest_client.submit_event(&event)) + .await + { + Ok(Ok(_)) => { + tracing::info!( + target: "pool::prompt", + channel = %batch.channel_id, + event_id = %event.id, + "published remote agent output through Buzz proxy" + ); + return Ok(()); + } + Ok(Err(error)) => last_error = Some(error.to_string()), + Err(_) => last_error = Some("relay submission timed out".to_string()), + } + } + Err(AcpError::Protocol(format!( + "publish remote agent reply: {}", + last_error.unwrap_or_else(|| "unknown relay error".to_string()) + ))) +} + /// Best-effort: remove a reaction via a signed kind:5 (NIP-09) deletion event. /// /// Queries kind:7 reactions by our pubkey targeting the event, finds the matching @@ -4517,6 +4618,46 @@ mod tests { } } + #[test] + fn automatic_remote_reply_uses_trigger_as_root_for_top_level_message() { + let batch = one_event_batch(Uuid::new_v4()); + let triggering_id = batch.events[0].event.id; + + let thread_ref = automatic_reply_thread_ref(&batch).expect("reply target"); + + assert_eq!(thread_ref.root_event_id, triggering_id); + assert_eq!(thread_ref.parent_event_id, triggering_id); + } + + #[test] + fn automatic_remote_reply_stays_flat_in_existing_thread() { + let keys = Keys::generate(); + let root = EventBuilder::new(Kind::Custom(9), "root") + .sign_with_keys(&keys) + .unwrap(); + let root_hex = root.id.to_hex(); + let root_tag = Tag::parse(["e", root_hex.as_str(), "", "root"]).unwrap(); + let triggering = EventBuilder::new(Kind::Custom(9), "follow-up") + .tags([root_tag]) + .sign_with_keys(&keys) + .unwrap(); + let batch = FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![crate::queue::BatchEvent { + event: triggering, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let thread_ref = automatic_reply_thread_ref(&batch).expect("reply target"); + + assert_eq!(thread_ref.root_event_id, root.id); + assert_eq!(thread_ref.parent_event_id, root.id); + } + #[test] fn test_requeue_cancelled_batch_maps_control_signal_to_cancel_reason() { let cases = [ @@ -5370,6 +5511,7 @@ mod tests { memory_enabled: false, harness_name: "goose".to_string(), relay_url: "ws://127.0.0.1:3000".to_string(), + publish_agent_output: false, } } From 9495fccf6a6b808f7cc9f62ad933df35e4f2bd9e Mon Sep 17 00:00:00 2001 From: Tim Marman Date: Tue, 28 Jul 2026 10:46:07 -0700 Subject: [PATCH 06/99] fix(acp): preserve successful proxy turns Signed-off-by: Tim Marman --- crates/buzz-acp/src/acp.rs | 191 ++++++++++++-- crates/buzz-acp/src/config.rs | 14 ++ crates/buzz-acp/src/pool.rs | 458 ++++++++++++++++++++++++++++------ crates/buzz-acp/src/queue.rs | 4 +- 4 files changed, 572 insertions(+), 95 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 9a4af88dd1..37219baac1 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -20,6 +20,7 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB const MAX_AGENT_MESSAGE_BYTES: usize = 60 * 1024; +const AGENT_MESSAGE_TRUNCATION_MARKER: &str = "\n[response truncated by Buzz]"; /// Env var that tells a goose ACP child not to start its cron scheduler. /// Injected unconditionally by [`AcpClient::spawn`]; see the call site for why. @@ -209,6 +210,38 @@ pub struct AcpClient { /// current prompt. Remote adapters can hand this buffer back to the host /// for proxy-signed publication without receiving the proxy private key. agent_message: String, + agent_message_truncated: bool, +} + +fn append_agent_message_chunk(buffer: &mut String, truncated: &mut bool, text: &str) { + if *truncated { + return; + } + + let remaining = MAX_AGENT_MESSAGE_BYTES.saturating_sub(buffer.len()); + if text.len() <= remaining { + buffer.push_str(text); + return; + } + + let content_capacity = + MAX_AGENT_MESSAGE_BYTES.saturating_sub(AGENT_MESSAGE_TRUNCATION_MARKER.len()); + if buffer.len() > content_capacity { + let mut end = content_capacity; + while end > 0 && !buffer.is_char_boundary(end) { + end -= 1; + } + buffer.truncate(end); + } + + let available = content_capacity.saturating_sub(buffer.len()); + let mut end = available.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + buffer.push_str(&text[..end]); + buffer.push_str(AGENT_MESSAGE_TRUNCATION_MARKER); + *truncated = true; } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -376,6 +409,35 @@ fn build_client_capabilities() -> serde_json::Value { }) } +fn configure_sensitive_spawn_environment( + cmd: &mut tokio::process::Command, + is_remote_a2a_adapter: bool, + extra_env: &[(String, String)], +) { + // The A2A bearer token is adapter-only. Remove any ambient value from + // every subprocess, then restore only the explicitly scoped adapter value. + cmd.env_remove("BUZZ_A2A_BEARER_TOKEN"); + + if is_remote_a2a_adapter { + for key in [ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + ] { + cmd.env_remove(key); + } + if let Some((_, value)) = extra_env + .iter() + .find(|(key, _)| key == "BUZZ_A2A_BEARER_TOKEN") + { + cmd.env("BUZZ_A2A_BEARER_TOKEN", value); + } + } +} + impl AcpClient { /// Kill the agent subprocess and wait for it to exit (no zombies). /// @@ -433,19 +495,7 @@ impl AcpClient { // Ensure the child is killed when the AcpClient is dropped (best-effort). // Callers MUST still call shutdown().await for guaranteed cleanup. .kill_on_drop(true); - if is_remote_a2a_adapter { - for key in [ - "BUZZ_PRIVATE_KEY", - "NOSTR_PRIVATE_KEY", - "BUZZ_AUTH_TAG", - "BUZZ_API_TOKEN", - "BUZZ_ACP_PRIVATE_KEY", - "BUZZ_ACP_API_TOKEN", - "BUZZ_A2A_BEARER_TOKEN", - ] { - cmd.env_remove(key); - } - } + configure_sensitive_spawn_environment(&mut cmd, is_remote_a2a_adapter, extra_env); // Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER). // For most keys, operator precedence wins: skip injection if already set @@ -476,8 +526,7 @@ impl AcpClient { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } - if is_remote_a2a_adapter && key == "BUZZ_A2A_BEARER_TOKEN" { - cmd.env(key, value); + if key == "BUZZ_A2A_BEARER_TOKEN" { continue; } if std::env::var(key).is_err() { @@ -535,6 +584,7 @@ impl AcpClient { steer_rx: None, goose_usage: UsageTracker::default(), agent_message: String::new(), + agent_message_truncated: false, }) } @@ -545,6 +595,15 @@ impl AcpClient { (!message.is_empty()).then(|| message.to_string()) } + #[cfg(test)] + pub(crate) fn capture_agent_message_for_test(&mut self, text: &str) { + append_agent_message_chunk( + &mut self.agent_message, + &mut self.agent_message_truncated, + text, + ); + } + /// Attach a local observer feed to this ACP client. pub fn set_observer(&mut self, observer: Option, agent_index: usize) { self.observer = observer; @@ -735,6 +794,7 @@ impl AcpClient { max_duration: std::time::Duration, ) -> Result { self.agent_message.clear(); + self.agent_message_truncated = false; let params = build_prompt_params(session_id, prompt_blocks); let hard_deadline = tokio::time::Instant::now() + max_duration; self.current_hard_deadline = Some(hard_deadline); @@ -1590,14 +1650,18 @@ impl AcpClient { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { tracing::info!(target: "acp::stream", "{text}"); - let remaining = - MAX_AGENT_MESSAGE_BYTES.saturating_sub(self.agent_message.len()); - if remaining > 0 { - let mut end = remaining.min(text.len()); - while end > 0 && !text.is_char_boundary(end) { - end -= 1; - } - self.agent_message.push_str(&text[..end]); + let was_truncated = self.agent_message_truncated; + append_agent_message_chunk( + &mut self.agent_message, + &mut self.agent_message_truncated, + text, + ); + if !was_truncated && self.agent_message_truncated { + tracing::warn!( + target: "acp::stream", + cap_bytes = MAX_AGENT_MESSAGE_BYTES, + "captured agent output exceeded publication cap and was truncated" + ); } } false @@ -3291,6 +3355,69 @@ mod tests { .expect("spawn cat as inert client") } + fn command_env_override( + command: &tokio::process::Command, + name: &str, + ) -> Option> { + command + .as_std() + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new(name)) + .map(|(_, value)| value.map(|value| value.to_string_lossy().into_owned())) + } + + #[test] + fn adapter_spawn_env_removes_host_credentials_and_scopes_bearer_token() { + let mut command = tokio::process::Command::new("buzz-a2a-acp"); + let extra_env = vec![( + "BUZZ_A2A_BEARER_TOKEN".to_string(), + "adapter-token".to_string(), + )]; + + configure_sensitive_spawn_environment(&mut command, true, &extra_env); + + for secret in [ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + ] { + assert_eq!(command_env_override(&command, secret), Some(None)); + } + assert_eq!( + command_env_override(&command, "BUZZ_A2A_BEARER_TOKEN"), + Some(Some("adapter-token".to_string())) + ); + } + + #[test] + fn non_adapter_spawn_env_is_unchanged_except_for_adapter_bearer_token() { + let mut command = tokio::process::Command::new("ordinary-agent"); + let extra_env = vec![( + "BUZZ_A2A_BEARER_TOKEN".to_string(), + "must-not-leak".to_string(), + )]; + + configure_sensitive_spawn_environment(&mut command, false, &extra_env); + + for secret in [ + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + ] { + assert_eq!(command_env_override(&command, secret), None); + } + assert_eq!( + command_env_override(&command, "BUZZ_A2A_BEARER_TOKEN"), + Some(None) + ); + } + #[tokio::test] async fn agent_message_chunks_are_collected_for_host_publication() { let mut client = spawn_inert_client().await; @@ -3316,6 +3443,24 @@ mod tests { assert_eq!(client.take_agent_message(), None); } + #[test] + fn agent_message_truncation_preserves_utf8_boundary_and_marks_output() { + let content_capacity = MAX_AGENT_MESSAGE_BYTES - AGENT_MESSAGE_TRUNCATION_MARKER.len(); + let mut buffer = "a".repeat(content_capacity - 1); + let mut truncated = false; + + append_agent_message_chunk(&mut buffer, &mut truncated, &"é".repeat(32)); + + assert!(truncated); + assert!(buffer.is_char_boundary(buffer.len())); + assert!(buffer.ends_with(AGENT_MESSAGE_TRUNCATION_MARKER)); + assert!(buffer.len() <= MAX_AGENT_MESSAGE_BYTES); + assert_eq!( + buffer.len(), + content_capacity - 1 + AGENT_MESSAGE_TRUNCATION_MARKER.len() + ); + } + /// Build a `session/update` JSON-RPC notification carrying a /// `session_info_update` with the given `_meta.goose.activeRunId` value. /// Pass `None` to omit the `activeRunId` field entirely. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 1951691db3..53ece92eec 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -675,7 +675,9 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { "-x86-64-apple-darwin", "-aarch64-unknown-linux-gnu", "-x86-64-unknown-linux-gnu", + "-aarch64-pc-windows-msvc", "-x86-64-pc-windows-msvc", + "-universal-apple-darwin", ]; let normalized = command.trim().replace('\\', "/"); @@ -1642,6 +1644,18 @@ mod tests { normalize_agent_command_identity("/opt/buzz/buzz-a2a-acp-aarch64-unknown-linux-gnu"), "buzz-a2a-acp" ); + assert_eq!( + normalize_agent_command_identity( + r"C:\Program Files\Buzz\buzz-a2a-acp-aarch64-pc-windows-msvc.exe" + ), + "buzz-a2a-acp" + ); + assert_eq!( + normalize_agent_command_identity( + "/Applications/Buzz.app/Contents/MacOS/buzz-a2a-acp-universal-apple-darwin" + ), + "buzz-a2a-acp" + ); } #[test] diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index f92e2f6c0c..c690783fec 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1787,6 +1787,9 @@ pub async fn run_prompt_task( // (`prompt[0].text.startsWith("/")`) fires; the wrapped Buzz context // follows as a second block. let mut slash_command: Option = None; + // Resolve proxy publication routing from the same channel/profile context + // used to construct the prompt. + let mut proxy_reply_thread_ref: Option = None; let prompt_sections: Vec = if let Some(text) = prompt_text { // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. @@ -1813,6 +1816,8 @@ pub async fn run_prompt_task( let profile_lookup = fetch_prompt_profile_lookup(b, conversation_context.as_ref(), &ctx.rest_client).await; + proxy_reply_thread_ref = + automatic_reply_thread_ref(b, channel_info.as_ref(), profile_lookup.as_ref()); let known_names: Vec<&str> = profile_lookup .iter() @@ -2024,36 +2029,40 @@ pub async fn run_prompt_task( &source, &control_signal, ); - let usage = agent.acp.take_turn_usage(); - publish_agent_turn_metric( + let proxy_publication = publish_captured_agent_output_best_effort( + &mut agent, &ctx, - usage, - observer_channel_id, - &session_id, - &turn_id, - Some(buzz_core::agent_turn_metric::StopReason::EndTurn), + batch.as_ref(), + proxy_reply_thread_ref.as_ref(), ) .await; - if let Err(error) = - publish_captured_agent_output(&mut agent, &ctx, batch.as_ref()).await - { - send_prompt_result( - &result_tx, + let disposition = successful_turn_disposition( + StopReason::EndTurn, + proxy_publication, + ); + if disposition.publish_metric { + let usage = agent.acp.take_turn_usage(); + publish_agent_turn_metric( + &ctx, + usage, + observer_channel_id, + &session_id, &turn_id, - agent, - source, - PromptOutcome::Error(error), - None, - ); - return; + Some(buzz_core::agent_turn_metric::StopReason::EndTurn), + ) + .await; } + let retry_batch = disposition + .retry_batch + .then(|| batch.clone()) + .flatten(); send_prompt_result( &result_tx, &turn_id, agent, source, - PromptOutcome::Ok(StopReason::EndTurn), - None, // turn succeeded — batch was processed, no requeue + PromptOutcome::Ok(disposition.stop_reason), + retry_batch, ); return; } @@ -2066,19 +2075,14 @@ pub async fn run_prompt_task( Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); - if let Err(error) = - publish_captured_agent_output(&mut agent, &ctx, batch.as_ref()).await - { - send_prompt_result( - &result_tx, - &turn_id, - agent, - source, - PromptOutcome::Error(error), - None, - ); - return; - } + let proxy_publication = publish_captured_agent_output_best_effort( + &mut agent, + &ctx, + batch.as_ref(), + proxy_reply_thread_ref.as_ref(), + ) + .await; + let disposition = successful_turn_disposition(stop_reason.clone(), proxy_publication); let should_rotate = matches!( stop_reason, @@ -2112,25 +2116,28 @@ pub async fn run_prompt_task( agent.state.invalidate(&source); } - let core_stop = acp_stop_to_core(&stop_reason); - let usage = agent.acp.take_turn_usage(); - publish_agent_turn_metric( - &ctx, - usage, - observer_channel_id, - &session_id, - &turn_id, - Some(core_stop), - ) - .await; + let core_stop = acp_stop_to_core(&disposition.stop_reason); + if disposition.publish_metric { + let usage = agent.acp.take_turn_usage(); + publish_agent_turn_metric( + &ctx, + usage, + observer_channel_id, + &session_id, + &turn_id, + Some(core_stop), + ) + .await; + } + let retry_batch = disposition.retry_batch.then(|| batch.clone()).flatten(); send_prompt_result( &result_tx, &turn_id, agent, source, - PromptOutcome::Ok(stop_reason), - None, + PromptOutcome::Ok(disposition.stop_reason), + retry_batch, ); } Err(AcpError::AgentExited) => { @@ -3621,14 +3628,59 @@ pub(crate) async fn post_failure_notice( } } -fn automatic_reply_thread_ref(batch: &FlushBatch) -> Option { +fn automatic_reply_thread_ref( + batch: &FlushBatch, + channel_info: Option<&PromptChannelInfo>, + profile_lookup: Option<&PromptProfileLookup>, +) -> Option { let triggering = &batch.events.last()?.event; let parsed = crate::queue::parse_thread_tags(triggering); - let root_event_id = parsed - .root_event_id - .as_deref() - .and_then(|root| nostr::EventId::from_hex(root).ok()) - .unwrap_or(triggering.id); + let is_dm = channel_info + .map(|info| info.channel_type == "dm") + .unwrap_or(false); + + if is_dm { + let root = parsed.root_event_id.as_deref()?; + let root_event_id = match nostr::EventId::from_hex(root) { + Ok(root_event_id) => root_event_id, + Err(error) => { + tracing::warn!( + target: "pool::prompt", + channel = %batch.channel_id, + triggering_event_id = %triggering.id, + malformed_root = root, + %error, + "malformed DM root tag; re-rooting proxy reply at triggering event" + ); + triggering.id + } + }; + return Some(buzz_sdk::ThreadRef { + root_event_id, + parent_event_id: triggering.id, + }); + } + + let parsed_anchor = crate::queue::resolve_reply_anchor( + &triggering.pubkey.to_hex(), + &parsed, + &triggering.id.to_hex(), + profile_lookup, + )?; + let root_event_id = match nostr::EventId::from_hex(&parsed_anchor) { + Ok(root_event_id) => root_event_id, + Err(error) => { + tracing::warn!( + target: "pool::prompt", + channel = %batch.channel_id, + triggering_event_id = %triggering.id, + malformed_root = parsed_anchor, + %error, + "malformed root tag; re-rooting proxy reply at triggering event" + ); + triggering.id + } + }; Some(buzz_sdk::ThreadRef { root_event_id, parent_event_id: root_event_id, @@ -3636,27 +3688,20 @@ fn automatic_reply_thread_ref(batch: &FlushBatch) -> Option } async fn publish_captured_agent_output( - agent: &mut OwnedAgent, + message: Option<&str>, ctx: &PromptContext, batch: Option<&FlushBatch>, -) -> Result<(), AcpError> { - let message = agent.acp.take_agent_message(); + thread_ref: Option<&buzz_sdk::ThreadRef>, +) -> Result { if !ctx.publish_agent_output { - return Ok(()); + return Ok(false); } let (Some(message), Some(batch)) = (message, batch) else { - return Ok(()); + return Ok(false); }; - let thread_ref = automatic_reply_thread_ref(batch); - let builder = buzz_sdk::build_message( - batch.channel_id, - &message, - thread_ref.as_ref(), - &[], - false, - &[], - ) - .map_err(|error| AcpError::Protocol(format!("build remote agent reply: {error}")))?; + let builder = + buzz_sdk::build_message(batch.channel_id, message, thread_ref, &[], false, &[]) + .map_err(|error| AcpError::Protocol(format!("build remote agent reply: {error}")))?; let event = builder .sign_with_keys(&ctx.agent_keys) .map_err(|error| AcpError::Protocol(format!("sign remote agent reply: {error}")))?; @@ -3680,7 +3725,7 @@ async fn publish_captured_agent_output( event_id = %event.id, "published remote agent output through Buzz proxy" ); - return Ok(()); + return Ok(true); } Ok(Err(error)) => last_error = Some(error.to_string()), Err(_) => last_error = Some("relay submission timed out".to_string()), @@ -3692,6 +3737,69 @@ async fn publish_captured_agent_output( ))) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProxyPublication { + Skipped, + Published, + Failed, +} + +#[derive(Debug, PartialEq)] +struct SuccessfulTurnDisposition { + stop_reason: StopReason, + publish_metric: bool, + retry_batch: bool, +} + +fn successful_turn_disposition( + stop_reason: StopReason, + _proxy_publication: ProxyPublication, +) -> SuccessfulTurnDisposition { + SuccessfulTurnDisposition { + stop_reason, + publish_metric: true, + retry_batch: false, + } +} + +async fn publish_captured_agent_output_best_effort( + agent: &mut OwnedAgent, + ctx: &PromptContext, + batch: Option<&FlushBatch>, + thread_ref: Option<&buzz_sdk::ThreadRef>, +) -> ProxyPublication { + let captured_message = ctx + .publish_agent_output + .then(|| agent.acp.take_agent_message()) + .flatten(); + let result = + publish_captured_agent_output(captured_message.as_deref(), ctx, batch, thread_ref).await; + let error = match result { + Ok(true) => return ProxyPublication::Published, + Ok(false) => return ProxyPublication::Skipped, + Err(error) => error, + }; + + tracing::warn!( + target: "pool::prompt", + error = %error, + "remote turn succeeded but proxy publication failed" + ); + + let (Some(batch), Some(captured_message)) = (batch, captured_message.as_deref()) else { + return ProxyPublication::Failed; + }; + let Some(triggering) = batch.events.last() else { + return ProxyPublication::Failed; + }; + let thread_tags = crate::queue::parse_thread_tags(&triggering.event); + let notice = format!( + "The remote agent completed this turn, but Buzz could not publish its reply through the proxy identity.\n\n{captured_message}" + ); + post_failure_notice(&ctx.rest_client, batch.channel_id, &thread_tags, ¬ice).await; + ProxyPublication::Failed +} + /// Best-effort: remove a reaction via a signed kind:5 (NIP-09) deletion event. /// /// Queries kind:7 reactions by our pubkey targeting the event, finds the matching @@ -4623,7 +4731,7 @@ mod tests { let batch = one_event_batch(Uuid::new_v4()); let triggering_id = batch.events[0].event.id; - let thread_ref = automatic_reply_thread_ref(&batch).expect("reply target"); + let thread_ref = automatic_reply_thread_ref(&batch, None, None).expect("reply target"); assert_eq!(thread_ref.root_event_id, triggering_id); assert_eq!(thread_ref.parent_event_id, triggering_id); @@ -4652,12 +4760,136 @@ mod tests { cancel_reason: None, }; - let thread_ref = automatic_reply_thread_ref(&batch).expect("reply target"); + let thread_ref = automatic_reply_thread_ref(&batch, None, None).expect("reply target"); assert_eq!(thread_ref.root_event_id, root.id); assert_eq!(thread_ref.parent_event_id, root.id); } + #[test] + fn automatic_remote_reply_anchors_threaded_dm_to_triggering_event() { + let keys = Keys::generate(); + let root = EventBuilder::new(Kind::Custom(9), "root") + .sign_with_keys(&keys) + .unwrap(); + let root_hex = root.id.to_hex(); + let root_tag = Tag::parse(["e", root_hex.as_str(), "", "root"]).unwrap(); + let triggering = EventBuilder::new(Kind::Custom(9), "follow-up") + .tags([root_tag]) + .sign_with_keys(&keys) + .unwrap(); + let triggering_id = triggering.id; + let batch = FlushBatch { + channel_id: Uuid::new_v4(), + events: vec![crate::queue::BatchEvent { + event: triggering, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + let channel_info = PromptChannelInfo { + name: "dm".into(), + channel_type: "dm".into(), + }; + + let thread_ref = + automatic_reply_thread_ref(&batch, Some(&channel_info), None).expect("reply target"); + + assert_eq!(thread_ref.root_event_id, root.id); + assert_eq!(thread_ref.parent_event_id, triggering_id); + } + + #[test] + fn automatic_remote_reply_does_not_flatten_agent_only_thread() { + let batch = one_event_batch(Uuid::new_v4()); + let sender = batch.events[0].event.pubkey.to_hex(); + let mut profiles = PromptProfileLookup::new(); + profiles.insert( + sender, + PromptProfile { + is_agent: true, + ..PromptProfile::default() + }, + ); + + assert!(automatic_reply_thread_ref(&batch, None, Some(&profiles)).is_none()); + } + + #[tokio::test] + async fn publish_captured_agent_output_is_noop_when_disabled() { + let ctx = make_prompt_context_no_owner(); + let batch = one_event_batch(Uuid::new_v4()); + + let published = + publish_captured_agent_output(Some("remote output"), &ctx, Some(&batch), None) + .await + .expect("disabled publication is a no-op"); + + assert!(!published); + } + + #[tokio::test] + async fn proxy_output_is_published_exactly_once_when_finalized_twice() { + let (base_url, events, server) = recording_event_server(200).await; + let mut ctx = make_prompt_context_no_owner(); + ctx.publish_agent_output = true; + ctx.rest_client.base_url = base_url; + let batch = one_event_batch(Uuid::new_v4()); + let mut agent = owned_test_agent_with_message("remote output").await; + + let first = + publish_captured_agent_output_best_effort(&mut agent, &ctx, Some(&batch), None).await; + let second = + publish_captured_agent_output_best_effort(&mut agent, &ctx, Some(&batch), None).await; + + assert_eq!(first, ProxyPublication::Published); + assert_eq!(second, ProxyPublication::Skipped); + assert_eq!(events.lock().unwrap().len(), 1); + agent.acp.shutdown().await; + server.abort(); + } + + #[tokio::test] + async fn proxy_publish_failure_preserves_success_fate_and_reuses_signed_event() { + let (base_url, events, server) = recording_event_server(400).await; + let mut ctx = make_prompt_context_no_owner(); + ctx.publish_agent_output = true; + ctx.rest_client.base_url = base_url; + let batch = one_event_batch(Uuid::new_v4()); + let mut agent = owned_test_agent_with_message("valuable remote output").await; + + let publication = + publish_captured_agent_output_best_effort(&mut agent, &ctx, Some(&batch), None).await; + let disposition = successful_turn_disposition(StopReason::EndTurn, publication); + + assert_eq!(publication, ProxyPublication::Failed); + assert_eq!( + disposition, + SuccessfulTurnDisposition { + stop_reason: StopReason::EndTurn, + publish_metric: true, + retry_batch: false, + } + ); + { + let captured = events.lock().unwrap(); + assert_eq!( + captured.len(), + 4, + "three proxy attempts plus one best-effort failure notice" + ); + let retry_ids: Vec<&str> = captured[..3] + .iter() + .map(|event| event["id"].as_str().expect("signed event id")) + .collect(); + assert!(retry_ids.windows(2).all(|ids| ids[0] == ids[1])); + } + agent.acp.shutdown().await; + server.abort(); + } + #[test] fn test_requeue_cancelled_batch_maps_control_signal_to_cancel_reason() { let cases = [ @@ -5515,6 +5747,92 @@ mod tests { } } + async fn recording_event_server( + status: u16, + ) -> ( + String, + std::sync::Arc>>, + tokio::task::JoinHandle<()>, + ) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind event server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let server_events = events.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = Vec::new(); + let (body_start, content_length) = loop { + let mut chunk = [0_u8; 4096]; + let read = socket.read(&mut chunk).await.expect("read request"); + if read == 0 { + return; + } + request.extend_from_slice(&chunk[..read]); + let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n") + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + break (header_end + 4, content_length); + }; + while request.len() < body_start + content_length { + let mut chunk = [0_u8; 4096]; + let read = socket.read(&mut chunk).await.expect("read request body"); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + } + if content_length > 0 { + let body = &request[body_start..body_start + content_length]; + let event = serde_json::from_slice(body).expect("event JSON"); + server_events.lock().unwrap().push(event); + } + + let reason = if status == 200 { "OK" } else { "Bad Request" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write response"); + } + }); + (base_url, events, server) + } + + async fn owned_test_agent_with_message(message: &str) -> OwnedAgent { + let mut acp = AcpClient::spawn("cat", &[], &[], false) + .await + .expect("spawn inert test agent"); + acp.capture_agent_message_for_test(message); + OwnedAgent { + index: 0, + acp, + state: SessionState::default(), + model_capabilities: None, + desired_model: None, + model_overridden: false, + agent_name: "test".to_string(), + goose_system_prompt_supported: None, + protocol_version: 2, + } + } + // ── render_canvas_section ──────────────────────────────────────────────── #[test] diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf..e3611d825b 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1179,7 +1179,7 @@ fn append_new_thread_reply_instruction(s: &mut String, event_id: &str) { /// agent-only mentions must not force flattening. When a participant cannot be /// classified (no profile fetched), it is treated as human — humans must not /// lose thread visibility to a misclassification. -fn turn_is_human_facing( +pub(crate) fn turn_is_human_facing( sender_pubkey: &str, thread_tags: &ThreadTags, profile_lookup: Option<&PromptProfileLookup>, @@ -1206,7 +1206,7 @@ fn turn_is_human_facing( /// /// Returns `None` for agent↔agent turns, leaving the agent free to nest deeply /// (intentional for agent coordination). -fn resolve_reply_anchor( +pub(crate) fn resolve_reply_anchor( sender_pubkey: &str, thread_tags: &ThreadTags, triggering_event_id: &str, From 4fcd55a9991a02dd41c0637b14e5cb0cd88b992e Mon Sep 17 00:00:00 2001 From: kcao-gss Date: Tue, 28 Jul 2026 16:13:47 -0500 Subject: [PATCH 07/99] docs(contributing): document the Linux system libraries just ci requires (#3396) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The prerequisites table lists language toolchains (Rust, Node, pnpm, Flutter, Docker, `just`) but no system libraries. Hermit pins the former and not the latter, so following the setup section exactly on Linux still leaves `just ci` unable to run: it fails partway through its first dependency, `just check`, at `desktop-tauri-clippy`. ``` The system library `gdk-pixbuf-2.0` required by crate `gdk-pixbuf-sys` was not found. The file `gdk-pixbuf-2.0.pc` needs to be installed and the PKG_CONFIG_PATH environment variable must contain its parent directory. ``` The desktop crates link against GTK and WebKitGTK. CI installs those packages explicitly, so it never sees this — which is exactly why the gap is invisible from the maintainer side. Since `check` runs first in the `ci` chain, the failure also masks everything after it (`test-unit`, `desktop-test`, `web-build`, `mobile-test` never run), which makes it read as a broken repo rather than a missing dependency. ## Change Adds a `#### Linux: Tauri system libraries` subsection under Prerequisites with: - The apt list copied from `.github/workflows/ci.yml`, so a local run matches CI rather than drifting from it - A pointer to [Tauri's prerequisites](https://tauri.app/start/prerequisites/) for non-Debian distributions - A note that server-side contributors can skip it — `just fmt-check`, `just clippy`, `just test-unit`, and `just test` need no GTK Docs only. No TOC entry needed, since the TOC lists `##` headings and this is a `####` subsection. ## How I hit it Running `just ci` before pushing #3372, on Ubuntu under WSL2 with the Hermit toolchain active and all Docker services healthy. Everything the guide asks for was in place. The four `check` steps before `desktop-tauri-clippy` (`fmt-check`, `clippy`, `desktop-check`, `desktop-tauri-fmt-check`) passed, which is what makes the failure point specific rather than a general build problem. ## Closest existing work None found. I searched open and closed issues and PRs for `gdk-pixbuf`, `libgtk`, `webkit2gtk`, `system dependencies`, `prerequisites`, `just ci`, and `linux setup`. The Linux/GTK issues that exist (#2604, #2643, #2982, #2811, #2562) are all runtime bugs in shipped builds, not setup-path failures. ## Verification The package list is transcribed from `.github/workflows/ci.yml:152-163`; the same list appears in `release.yml` and `linux-canary.yml`. I have not installed the packages on my machine, so I can confirm the failure and the source of the fix but not that the list is exhaustive on a clean box — worth a second pair of eyes from anyone who has done a fresh Linux setup recently. Signed-off-by: Kyler Cao --- CONTRIBUTING.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 53ea0f11c8..db0aea637f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,6 +98,37 @@ Hermit pins Rust, `just`, Node, pnpm, and other tools to the versions in upfront. If you don't use Hermit, ensure your toolchain meets the minimum versions in the table above. +#### Linux: Tauri system libraries + +Hermit pins language toolchains, not system libraries. On Linux, the desktop +app's Rust crates link against GTK and WebKitGTK, so `just ci` (and any +`just desktop-tauri-*` recipe) needs these installed system-wide first. On +Debian/Ubuntu: + +```bash +sudo apt-get install -y --no-install-recommends \ + build-essential curl file libasound2-dev libayatana-appindicator3-dev \ + libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev \ + patchelf wget +``` + +This is the same list CI installs (see `.github/workflows/ci.yml`), so matching +it locally keeps your results comparable to CI. Other distributions ship these +under different package names — see the +[Tauri prerequisites](https://tauri.app/start/prerequisites/) for the +equivalents. + +Without them, `just ci` fails partway through `just check` with a pkg-config +error such as: + +``` +The system library `gdk-pixbuf-2.0` required by crate `gdk-pixbuf-sys` was not found. +``` + +If you're only touching the relay, CLI, or other server-side crates, you can +skip this and run the narrower recipes instead — `just fmt-check`, `just +clippy`, `just test-unit`, and `just test` need no GTK. + ### First-Time Setup ```bash From 913d564ce0f35924291bf3eeab6508517a6d8d1f Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Tue, 28 Jul 2026 14:17:17 -0700 Subject: [PATCH 08/99] fix(desktop): stabilize flaky DM expansion E2E ordering assertions (#2004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes 4 flaky DM expansion E2E tests in Desktop Smoke shard 1 that were failing non-deterministically on CI (also reproducing on `main` at run `29526844596`). **Failing tests:** - `channels.spec.ts:652` — creates the DM before preparing a persona mention - `channels.spec.ts:760` — routes an agent mention from an existing DM to the expanded conversation - `channels.spec.ts:815` — routes a relay-agent mention from an existing DM to the expanded conversation - `channels.spec.ts:940` — drops an expanded DM after the first message fails ## Root Cause Race condition: under fast CI execution, mock command completions (create_managed_agent, open_dm) can resolve in non-deterministic order, causing assertions to observe stale or mid-transition state. ## Fix - **:652** — Move the `new-message-recipient-popover` hidden assertion after `chat-title` settles (both names present), so it runs post-transition rather than mid-transition. - **:760, :940** — Add `createManagedAgentDelayMs: 100` to ensure persona provisioning doesn't collapse into the same tick as the expanded-DM open/start sequence. - **:815** — Add `openDmDelayMs: 100` so the two open_dm calls resolve in deterministic order. ## Validation All 4 tests pass with `--repeat-each=3` (12/12 green) locally. Biome lint clean. ## Scope Test-only change: 12 insertions, 1 deletion in `desktop/tests/e2e/channels.spec.ts`. --- Investigated by Ferret, reviewed by Grumplestiltzkin. Signed-off-by: Cameron Hotchkies Co-authored-by: Goose --- desktop/tests/e2e/channels.spec.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 9c5bbfe807..9da4022420 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -712,7 +712,6 @@ test("creates the DM before preparing a persona mention", async ({ page }) => { page.getByTestId(`new-dm-selected-${TEST_IDENTITIES.charlie.pubkey}`), ).toBeDisabled(); await expect(page.getByTestId("new-dm-search")).toBeDisabled(); - await expect(page.getByTestId("new-message-recipient-popover")).toBeHidden(); await expect .poll(async () => commandCount(await readCommandLog(page), "create_managed_agent"), @@ -720,6 +719,9 @@ test("creates the DM before preparing a persona mention", async ({ page }) => { .toBeGreaterThan(baselineCreateCount); await expect(page.getByTestId("chat-title")).toContainText("charlie"); await expect(page.getByTestId("chat-title")).toContainText("Fizz"); + // Assert popover hidden after chat-title settles — by this point the send + // flow has completed and the UI has fully transitioned away from the popover. + await expect(page.getByTestId("new-message-recipient-popover")).toBeHidden(); const sendCommands = (await readCommandLog(page)).slice( baselineCommands.length, @@ -782,8 +784,11 @@ test("creates the DM before preparing a persona mention", async ({ page }) => { test("routes an agent mention from an existing DM to the expanded conversation", async ({ page, }) => { + // Delay persona provisioning so the follow-up expanded-DM open/start sequence + // cannot collapse into the same fast CI tick before assertions observe it. await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], + createManagedAgentDelayMs: 100, }); await page.goto("/"); @@ -837,7 +842,10 @@ test("routes an agent mention from an existing DM to the expanded conversation", test("routes a managed relay-agent mention from an existing DM to the expanded conversation", async ({ page, }) => { + // Delay the expanded open_dm call so routing/navigation settles + // deterministically under fast CI execution. await installMockBridge(page, { + openDmDelayMs: 100, managedAgents: [ { pubkey: DM_RELAY_AGENT_PUBKEY, @@ -967,8 +975,11 @@ test("does not reroute an expanded DM after the channel pane unmounts", async ({ test("drops an expanded DM after the first message fails", async ({ page }) => { const retryMessage = "Retry without the agent"; const sendError = "Mock first DM send failed."; + // Delay persona provisioning so the follow-up expanded-DM open/start sequence + // cannot collapse into the same fast CI tick before assertions observe it. await installMockBridge(page, { activePersonaIds: ["builtin:fizz"], + createManagedAgentDelayMs: 100, sendMessageErrors: [sendError], }); await page.goto("/"); From 3ece4461df8a7b9663a8e68327483b8377d4086d Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 17:20:29 -0400 Subject: [PATCH 09/99] feat(desktop): apply WebKit rendering workarounds at startup on Linux (#3271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On some Linux GPU/driver/compositor combinations, WebKitGTK's dmabuf renderer aborts the web process during startup, so Buzz comes up with no window at all and the user has no way to fix it. Setting `WEBKIT_DISABLE_DMABUF_RENDERER=1` avoids the abort by falling back to the shared-memory buffer path. WebKit reads each of its rendering variables exactly once per process, so the choice has to be made before anything initializes — there is no runtime toggle and no second chance later in the same process. This decides up front from two cheap preflight signals rather than reacting to a crash: - **NVIDIA GPU** — any DRM device under `/sys/class/drm` reporting PCI vendor `0x10de`, the driver family behind most upstream reports. - **AppImage** — the `APPIMAGE` environment variable. linuxdeploy's AppRun hook pins `GDK_BACKEND=x11`, and the dmabuf renderer buys nothing on that XWayland path. Either signal disables the dmabuf renderer. Neither signal leaves the environment untouched. ## Escape hatches `--safe-rendering` forces the safest configuration for one launch — `WEBKIT_DISABLE_DMABUF_RENDERER` plus `WEBKIT_DISABLE_COMPOSITING_MODE` — for a machine neither signal recognises. Any user assignment of a variable this module may set stands the heuristic down **wholesale**. Presence is the test, not truthiness, so `VAR=0` and `VAR=` both count: a user asking for the dmabuf renderer *on* gets it, even on a machine the heuristic would have opted out. `--safe-rendering` against such an assignment is refused with a diagnostic naming both the assignment and the key to unset, and exits non-zero — the flag and the environment are two incompatible answers to one question, and neither is guessed. ## Placement `webkit_rendering::apply()` runs at the top of `fn main()`, before `buzz_lib::run()`. That is the only point where the process is still single threaded with no GTK object alive, which is what makes `std::env::set_var` sound; the module doc and the call site both say so. The whole module is `#[cfg(target_os = "linux")]` — macOS and Windows compile none of it. The decision is a pure function of argv, an injected environment lookup, and an injected DRM root, so all of it is unit-testable without mutating the process environment. Closes #2338. Upstream: [tauri#9394](https://github.com/tauri-apps/tauri/issues/9394). Same approach and same variable as [clash-verge-rev](https://github.com/clash-verge-rev/clash-verge-rev/blob/main/src-tauri/src/utils/linux/workarounds.rs) `workarounds.rs` and [screenpipe](https://github.com/screenpipe/screenpipe/blob/main/apps/screenpipe-app-tauri/src-tauri/src/linux_webkit_env.rs) `linux_webkit_env.rs`. Signed-off-by: Will Pfleger --- desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/main.rs | 10 + desktop/src-tauri/src/webkit_rendering.rs | 208 +++++++++++++++ .../src-tauri/src/webkit_rendering/tests.rs | 250 ++++++++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 desktop/src-tauri/src/webkit_rendering.rs create mode 100644 desktop/src-tauri/src/webkit_rendering/tests.rs diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 08887a6bfb..35f4eae866 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -29,6 +29,8 @@ mod secret_store; mod shutdown; mod templates; mod util; +#[cfg(target_os = "linux")] +pub mod webkit_rendering; use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 13cb6c1b70..ebcc127683 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -2,5 +2,15 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Before anything else: WebKitGTK reads its rendering environment once at + // process start, and this is the only point where the process is still + // single threaded and no GTK object exists yet, which is what makes + // `std::env::set_var` sound. + #[cfg(target_os = "linux")] + if let Err(diagnostic) = buzz_lib::webkit_rendering::apply() { + eprintln!("buzz-desktop: {diagnostic}"); + std::process::exit(1); + } + buzz_lib::run() } diff --git a/desktop/src-tauri/src/webkit_rendering.rs b/desktop/src-tauri/src/webkit_rendering.rs new file mode 100644 index 0000000000..905da5eeed --- /dev/null +++ b/desktop/src-tauri/src/webkit_rendering.rs @@ -0,0 +1,208 @@ +//! WebKit rendering workarounds for Linux, applied before WebKit initializes. +//! +//! WebKitGTK's dmabuf renderer aborts the web process during startup on some +//! GPU/driver/compositor combinations, so Buzz comes up with no window at all +//! and the user has no way to fix it (#2338, upstream tauri#9394). Setting +//! `WEBKIT_DISABLE_DMABUF_RENDERER=1` avoids the abort by falling back to the +//! shared-memory buffer path. +//! +//! WebKit reads each of these variables exactly once per process, so the choice +//! has to be made before anything initializes — there is no runtime toggle and +//! no second chance later in the same process. This module therefore decides +//! from cheap preflight signals instead of reacting to a crash: +//! +//! * an NVIDIA GPU, the driver family behind most upstream reports; and +//! * AppImage packaging, where linuxdeploy's AppRun hook pins `GDK_BACKEND=x11` +//! and the dmabuf renderer buys nothing on that XWayland path (#2338). +//! +//! `--safe-rendering` is the manual escape hatch for a machine neither signal +//! recognises; it also disables accelerated compositing, for that launch only. +//! +//! This is the shape the Tauri ecosystem converged on: clash-verge-rev's +//! `utils/linux/workarounds.rs` and screenpipe's `linux_webkit_env.rs` both set +//! the same variable from the same signals at the same point in startup. + +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +/// Force the safest rendering configuration for this launch. +const SAFE_RENDERING: &str = "--safe-rendering"; + +/// PCI vendor ID reported by NVIDIA devices under `/sys/class/drm`. +const NVIDIA_PCI_VENDOR: &str = "0x10de"; + +/// Where DRM devices advertise their PCI vendor. +const DRM_ROOT: &str = "/sys/class/drm"; + +/// Drops the zero-copy dmabuf buffer path. The workaround for #2338. +const DISABLE_DMABUF: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; +/// Drops accelerated compositing as well. `--safe-rendering` only. +const DISABLE_COMPOSITING: &str = "WEBKIT_DISABLE_COMPOSITING_MODE"; + +/// What the heuristic applies: the #2338 workaround alone, matching the +/// ecosystem precedents. `DISABLE_COMPOSITING` is deliberately not here — no +/// report has isolated it as necessary, and it costs more rendering than this. +const HEURISTIC: [&str; 1] = [DISABLE_DMABUF]; + +/// What `--safe-rendering` applies, which is also every variable this module may +/// set and therefore every variable a user assignment takes away from it. Being +/// the same list is the invariant: nothing outside it is ever written, so a user +/// value for any other WebKit variable is not a conflict. +const OWNED: [&str; 2] = [DISABLE_DMABUF, DISABLE_COMPOSITING]; + +/// Reads one environment variable. Injected so the decision is testable without +/// mutating the process environment. `OsString` rather than `String` because +/// presence is the test — a non-UTF-8 assignment is still the user's. +type EnvLookup<'a> = &'a dyn Fn(&str) -> Option; + +/// What this launch should do about its rendering environment. +#[derive(Debug, PartialEq, Eq)] +enum Plan { + /// Set each of these to `1`, then report `why`. + Apply { + vars: &'static [&'static str], + why: String, + }, + /// Change nothing, and report `why`. + Leave { why: String }, + /// The request cannot be delivered. Report it and exit non-zero rather than + /// starting an app that silently ignores what the user asked for. + Fatal { diagnostic: String }, +} + +/// Applies the workaround for this launch. +/// +/// Must be called from `main()` before `crate::run()`: WebKit memoizes these +/// variables at process start, and `std::env::set_var` is only sound while the +/// process is still single threaded, which it is nowhere else in Buzz. +/// +/// `Err` carries a user-facing diagnostic; the caller reports it and exits. +pub fn apply() -> Result<(), String> { + match plan( + std::env::args_os(), + &|key| std::env::var_os(key), + Path::new(DRM_ROOT), + ) { + Plan::Apply { vars, why } => { + for var in vars { + // Safe here and only here — see the doc comment above. + std::env::set_var(var, "1"); + } + let applied: Vec = vars.iter().map(|var| format!("{var}=1")).collect(); + eprintln!("buzz-desktop: {} — {why}", applied.join(" ")); + Ok(()) + } + Plan::Leave { why } => { + eprintln!("buzz-desktop: WebKit rendering left as-is — {why}"); + Ok(()) + } + Plan::Fatal { diagnostic } => Err(diagnostic), + } +} + +/// The whole decision, as a pure function of argv, the environment, and the DRM +/// device tree. +fn plan( + args: impl IntoIterator>, + env: EnvLookup<'_>, + drm_root: &Path, +) -> Plan { + let safe_rendering = args + .into_iter() + .any(|arg| arg.as_ref() == OsStr::new(SAFE_RENDERING)); + let user_set = user_set(env); + + if !user_set.is_empty() { + // A user who has assigned one of these has taken over the decision, so + // the heuristic stands down wholesale — writing the *other* variable + // behind their back would be exactly the surprise they opted out of. + return match safe_rendering { + // Two incompatible answers to one question, and no basis for + // picking: honouring the flag would overwrite configuration the + // user typed, honouring the environment would silently ignore a + // rescue flag from a user whose app does not start. + true => Plan::Fatal { + diagnostic: conflict(&user_set), + }, + false => Plan::Leave { + why: format!("{} set in the environment", describe(&user_set)), + }, + }; + } + + if safe_rendering { + return Plan::Apply { + vars: &OWNED, + why: format!("{SAFE_RENDERING} requested, this launch only"), + }; + } + + let signals = [ + (nvidia_gpu(drm_root), "NVIDIA GPU"), + (env("APPIMAGE").is_some(), "AppImage"), + ]; + let hits: Vec<&str> = signals + .iter() + .filter_map(|(hit, label)| hit.then_some(*label)) + .collect(); + + match hits.is_empty() { + true => Plan::Leave { + why: "no NVIDIA GPU and not an AppImage".to_string(), + }, + false => Plan::Apply { + vars: &HEURISTIC, + why: hits.join(", "), + }, + } +} + +/// Owned variables the environment already carries, keyed by name. +/// +/// Presence is the test, not truthiness: `VAR=0` and `VAR=` are both genuine +/// user assignments, and both take the decision away from this module. +fn user_set(env: EnvLookup<'_>) -> Vec<(&'static str, OsString)> { + OWNED + .iter() + .filter_map(|key| env(key).map(|value| (*key, value))) + .collect() +} + +/// User assignments rendered as `KEY=value`, for a log line or a diagnostic. +fn describe(user_set: &[(&str, OsString)]) -> String { + let shown: Vec = user_set + .iter() + .map(|(key, value)| format!("{key}={}", value.to_string_lossy())) + .collect(); + shown.join(", ") +} + +/// Whether any DRM device reports NVIDIA's PCI vendor ID. An unreadable device +/// tree is not a hit — the workaround has a real cost, so it needs evidence. +fn nvidia_gpu(drm_root: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(drm_root) else { + return false; + }; + entries.flatten().any(|entry| { + std::fs::read_to_string(entry.path().join("device/vendor")) + .is_ok_and(|vendor| vendor.trim().eq_ignore_ascii_case(NVIDIA_PCI_VENDOR)) + }) +} + +/// The diagnostic for `--safe-rendering` against a user-set owned variable. +/// +/// The message both shows what is set and names the keys to unset — the two +/// things a user whose app will not start needs in order to act on it. +fn conflict(user_set: &[(&str, OsString)]) -> String { + let keys: Vec<&str> = user_set.iter().map(|(key, _)| *key).collect(); + format!( + "{SAFE_RENDERING} cannot be applied: {} already set in the environment. \ + Either unset {} and run {SAFE_RENDERING} again, or keep that \ + environment and drop the flag.", + describe(user_set), + keys.join(", "), + ) +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/webkit_rendering/tests.rs b/desktop/src-tauri/src/webkit_rendering/tests.rs new file mode 100644 index 0000000000..5be1612b21 --- /dev/null +++ b/desktop/src-tauri/src/webkit_rendering/tests.rs @@ -0,0 +1,250 @@ +//! Behaviour of the preflight decision. +//! +//! Every case goes through `plan`, which takes argv, the environment, and the +//! DRM root as arguments — so nothing here mutates the process environment and +//! the tests are order-independent. + +use super::*; + +const NO_ARGS: [&str; 0] = []; + +/// A `/sys/class/drm` stand-in. `vendors` are written as `card/device/vendor` +/// with the trailing newline the kernel emits. +fn drm(vendors: &[&str]) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("tempdir"); + for (index, vendor) in vendors.iter().enumerate() { + let device = root.path().join(format!("card{index}")).join("device"); + std::fs::create_dir_all(&device).expect("device dir"); + std::fs::write(device.join("vendor"), format!("{vendor}\n")).expect("vendor"); + } + root +} + +fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option { + let owned: Vec<(String, OsString)> = pairs + .iter() + .map(|(key, value)| (key.to_string(), OsString::from(value))) + .collect(); + move |key| { + owned + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, value)| value.clone()) + } +} + +/// The variables a plan would set, or `None` for a plan that sets nothing. +fn applied(plan: &Plan) -> Option<&[&str]> { + match plan { + Plan::Apply { vars, .. } => Some(vars), + _ => None, + } +} + +// ── Detection ─────────────────────────────────────────────────────────────── + +#[test] +fn test_nvidia_gpu_disables_the_dmabuf_renderer() { + let drm = drm(&["0x10de"]); + let plan = plan(NO_ARGS, &env_from(&[]), drm.path()); + + assert_eq!( + applied(&plan), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); + let Plan::Apply { why, .. } = &plan else { + unreachable!() + }; + assert!(why.contains("NVIDIA"), "{why}"); +} + +#[test] +fn test_an_nvidia_gpu_alongside_another_vendor_still_counts() { + // Hybrid graphics: the integrated GPU enumerates first, and WebKit may + // still land on the discrete one. + let drm = drm(&["0x8086", "0x10de"]); + + assert_eq!( + applied(&plan(NO_ARGS, &env_from(&[]), drm.path())), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); +} + +#[test] +fn test_the_vendor_id_match_ignores_case() { + let drm = drm(&["0x10DE"]); + + assert_eq!( + applied(&plan(NO_ARGS, &env_from(&[]), drm.path())), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); +} + +#[test] +fn test_an_appimage_launch_disables_the_dmabuf_renderer() { + // No NVIDIA GPU: the AppImage signal has to carry this on its own, which is + // #2338's reporter (Intel Mesa under the AppRun's pinned XWayland backend). + let drm = drm(&["0x8086"]); + let env = env_from(&[("APPIMAGE", "/home/u/Buzz.AppImage")]); + let plan = plan(NO_ARGS, &env, drm.path()); + + assert_eq!( + applied(&plan), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); + let Plan::Apply { why, .. } = &plan else { + unreachable!() + }; + assert!(why.contains("AppImage"), "{why}"); +} + +#[test] +fn test_a_plain_non_nvidia_launch_changes_nothing() { + let drm = drm(&["0x8086", "0x1002"]); + + assert!(matches!( + plan(NO_ARGS, &env_from(&[]), drm.path()), + Plan::Leave { .. } + )); +} + +#[test] +fn test_an_unreadable_drm_tree_is_not_treated_as_a_hit() { + // Containers and hardened kernels can hide `/sys/class/drm` entirely. The + // workaround costs real rendering performance, so absent evidence is not + // evidence — this must not become an unconditional export. + let missing = std::path::Path::new("/nonexistent/class/drm"); + + assert!(matches!( + plan(NO_ARGS, &env_from(&[]), missing), + Plan::Leave { .. } + )); +} + +#[test] +fn test_a_device_without_a_vendor_file_is_skipped_not_fatal() { + // `/sys/class/drm` also contains connector entries (`card0-HDMI-A-1`) and + // `renderD*` nodes, which have no `device/vendor` under them. + let root = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(root.path().join("card0-HDMI-A-1")).expect("connector"); + let device = root.path().join("card1").join("device"); + std::fs::create_dir_all(&device).expect("device dir"); + std::fs::write(device.join("vendor"), "0x10de\n").expect("vendor"); + + assert_eq!( + applied(&plan(NO_ARGS, &env_from(&[]), root.path())), + Some(&["WEBKIT_DISABLE_DMABUF_RENDERER"][..]) + ); +} + +// ── User environment ──────────────────────────────────────────────────────── + +#[test] +fn test_a_user_set_variable_disables_the_heuristic_wholesale() { + // `0` is the value a truthiness check would drop: the user is asking for the + // dmabuf renderer *on*, on a machine the heuristic would have opted out. + let drm = drm(&["0x10de"]); + let env = env_from(&[(DISABLE_DMABUF, "0")]); + let plan = plan(NO_ARGS, &env, drm.path()); + + let Plan::Leave { why } = &plan else { + panic!("a user assignment must not be overwritten: {plan:?}"); + }; + assert!(why.contains("WEBKIT_DISABLE_DMABUF_RENDERER=0"), "{why}"); +} + +#[test] +fn test_an_empty_assignment_is_still_a_user_assignment() { + let drm = drm(&["0x10de"]); + let env = env_from(&[(DISABLE_DMABUF, "")]); + + assert!(matches!( + plan(NO_ARGS, &env, drm.path()), + Plan::Leave { .. } + )); +} + +#[test] +fn test_a_user_set_compositing_variable_also_stands_the_heuristic_down() { + // The heuristic never sets this one, but it is still ours to set under + // `--safe-rendering`, so a user value takes the whole decision away rather + // than leaving us free to write the sibling variable. + let drm = drm(&["0x10de"]); + let env = env_from(&[(DISABLE_COMPOSITING, "1")]); + + assert!(matches!( + plan(NO_ARGS, &env, drm.path()), + Plan::Leave { .. } + )); +} + +// ── --safe-rendering ──────────────────────────────────────────────────────── + +#[test] +fn test_safe_rendering_applies_the_safest_set_without_any_hardware_signal() { + // The escape hatch exists for the machine neither signal recognises, so it + // must not depend on either one. + let drm = drm(&["0x8086"]); + let args = ["buzz://channel/1", SAFE_RENDERING]; + let plan = plan(args, &env_from(&[]), drm.path()); + + assert_eq!( + applied(&plan), + Some( + &[ + "WEBKIT_DISABLE_DMABUF_RENDERER", + "WEBKIT_DISABLE_COMPOSITING_MODE" + ][..] + ) + ); +} + +#[test] +fn test_an_unrelated_flag_is_not_mistaken_for_safe_rendering() { + let drm = drm(&["0x8086"]); + + assert!(matches!( + plan(["--safe-renderingX"], &env_from(&[]), drm.path()), + Plan::Leave { .. } + )); +} + +#[test] +fn test_safe_rendering_against_a_user_set_variable_is_fatal_not_guessed() { + let drm = drm(&["0x8086"]); + let env = env_from(&[(DISABLE_DMABUF, "0")]); + let plan = plan([SAFE_RENDERING], &env, drm.path()); + + let Plan::Fatal { diagnostic } = &plan else { + panic!("the flag and the environment disagree; neither may be guessed: {plan:?}"); + }; + // The message has to name what is set and what to unset, or the user whose + // app will not start cannot act on it. + assert!(diagnostic.contains(SAFE_RENDERING), "{diagnostic}"); + assert!( + diagnostic.contains("WEBKIT_DISABLE_DMABUF_RENDERER=0"), + "{diagnostic}" + ); +} + +#[test] +fn test_a_non_utf8_user_assignment_is_reported_not_ignored() { + // Presence is the test, so this still stands the heuristic down; the + // diagnostic must name the key rather than dropping the whole entry. + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + let drm = drm(&["0x10de"]); + let invalid = OsString::from_vec(vec![0xff, 0xfe]); + let env = |key: &str| match key == DISABLE_DMABUF { + true => Some(invalid.clone()), + false => None, + }; + + let Plan::Fatal { diagnostic } = plan([SAFE_RENDERING], &env, drm.path()) else { + panic!("a non-UTF-8 assignment is still a user assignment"); + }; + assert!(diagnostic.contains(DISABLE_DMABUF), "{diagnostic}"); + } +} From f25e6dd6aa4a1c5eff0facc260b8e25d05a2b02a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 17:21:05 -0400 Subject: [PATCH 10/99] feat(acp): steer claude-code and codex agents via _session/steering (#3007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mid-turn steering was reachable only through goose's `_goose/unstable/session/steer`, which requires an `expectedRunId` sourced from `_meta.goose.activeRunId`. claude-agent-acp and codex-acp never emit a run id, so every mid-turn mention to those harnesses bailed at the run-id guard before writing a byte and degraded to cancel + merge, destroying in-flight tool calls. Both adapters ship `_session/steering` (params `{sessionId, prompt}`, result `{outcome}`) and advertise it as `_meta.steering.supported` on the `initialize` response. This adds it as a second steer transport selected at write time, reusing the existing withhold/release, ack routing, and cancel+merge fallback machinery unchanged. ## Transport selection | `active_run_id` | `steering_supported` | Transport | |---|---|---| | `Some(run_id)` | any | `_goose/unstable/session/steer` + `expectedRunId` (unchanged) | | `None` | `true` | `_session/steering` with `{sessionId, prompt}` | | `None` | `false` | ack `ExpectedRunIdMissing`, write nothing (unchanged) | goose keeps priority when both are present — `expectedRunId` is strictly more precise about *which* run is being steered. ## Two load-bearing safety properties **The advertised capability is the only gate — never error-code probing.** codex-acp's `extMethod` answers unrecognized extension methods with a bare `{}`, which is a JSON-RPC *success* rather than `-32601`. Buzz maps a steer success to `queue.remove_event`, so probing an unknown method would silently delete the user's message with no error, no fallback, and no log line. **An `outcome` must be positively recognized.** Only `injected` and `startedNewTurn` count as delivery. Anything else — codex's `failed`, an unknown value, or a missing `outcome` entirely — is `SteerError::OutcomeRejected`, which releases the withheld event and fires the cancel+merge fallback. This makes the silent-loss path above unreachable even if an adapter mis-advertises. `startedNewTurn` acks `Success`, because the message really was delivered and must not be redelivered, but deliberately does **not** renew the read loop's hard deadline: the turn Buzz was awaiting had already settled, and renewing would extend the clock on a finished turn. ## Notes for reviewers - `SteerError::OutcomeRejected` needs no new arm in the `PoolEvent::SteerAck` match — the existing catch-all `Ok(SteerAck::Err(_)) => (true, false, true)` already gives release + fallback, and the two `AgentError` arms above it match that variant specifically, so they do not shadow it. - Comments that described the old goose-only "try-and-tolerate" `-32601` behavior are corrected; that assumption was never valid for codex-acp. - No CI job runs `buzz-acp` tests. The full package suite was run locally: **617 passing, 0 failing**. Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- crates/buzz-acp/src/acp.rs | 690 +++++++++++++++++++++++++++++++++--- crates/buzz-acp/src/lib.rs | 67 ++-- crates/buzz-acp/src/pool.rs | 38 +- 3 files changed, 712 insertions(+), 83 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 23f0345e96..d629e6a037 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -191,6 +191,17 @@ pub struct AcpClient { /// Other agents may leave this unset — readers must treat `None` as /// "no active run to steer into" and fall back to cancel+merge. active_run_id: Option, + /// Whether the agent advertised `_meta.steering.supported: true` in its + /// `initialize` response, meaning it implements the cross-adapter + /// [`ACP_STEER_METHOD`] extension. + /// + /// Set once by [`initialize`](Self::initialize); `false` for agents that + /// omit the key. This is the **only** gate on writing an + /// [`ACP_STEER_METHOD`] request. It must never be replaced by error-code + /// probing: codex-acp answers unrecognized extension methods with `{}` — + /// a JSON-RPC *success*, not `-32601` — which the main loop would read as + /// a delivered steer and drop the user's message from the queue. + steering_supported: bool, /// Per-turn channel for receiving goose-native non-cancelling steer /// requests from the main loop. Installed by /// [`install_steer_rx`](Self::install_steer_rx) at dispatch and @@ -348,6 +359,38 @@ pub(crate) fn build_codex_config_env( Ok(Some(serde_json::Value::Object(base).to_string())) } +/// goose's non-standard mid-turn steer method. Requires `expectedRunId`, so it +/// is only usable once a `session_info_update` has supplied +/// `_meta.goose.activeRunId`. Emitted by goose and buzz-agent only. +const GOOSE_STEER_METHOD: &str = "_goose/unstable/session/steer"; + +/// The cross-adapter mid-turn steer method, shipped by claude-agent-acp +/// (`src/acp-agent.ts:200`) and codex-acp (`src/AcpExtensions.ts:11`). +/// Params are `{sessionId, prompt}` — no run id — and the result is +/// `{outcome}`. Gated on [`AcpClient::steering_supported`]. +const ACP_STEER_METHOD: &str = "_session/steering"; + +/// `outcome` value meaning the steer was applied to the turn Buzz is waiting +/// on, which therefore keeps running. +const STEER_OUTCOME_INJECTED: &str = "injected"; + +/// `outcome` value meaning the turn Buzz was steering had already finished, so +/// the adapter began a fresh turn carrying the message. Still a delivery +/// success, but the awaited turn is over — see the steer-response arm for why +/// this must not renew the hard deadline. +const STEER_OUTCOME_STARTED_NEW_TURN: &str = "startedNewTurn"; + +/// Which wire method carried an in-flight steer request, recorded so the +/// response arm decodes the shape that method actually returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SteerTransport { + /// [`GOOSE_STEER_METHOD`] — any success result is a delivered steer. + Goose, + /// [`ACP_STEER_METHOD`] — success carries an `outcome` that must be + /// positively recognized before the steer counts as delivered. + AcpExtension, +} + fn build_client_capabilities() -> serde_json::Value { serde_json::json!({ // Signal to ACP adapters that Buzz can hand users to terminal-native @@ -508,6 +551,7 @@ impl AcpClient { observer_agent_index: None, observer_context: ObserverContext::default(), active_run_id: None, + steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), }) @@ -550,11 +594,20 @@ impl AcpClient { /// /// Must be called exactly once, before any other ACP method. /// The caller may inspect `agentCapabilities` in the returned value. + /// + /// Records `_meta.steering.supported` into + /// [`steering_supported`](Self::steering_supported) so the read loop's steer + /// arm can choose [`ACP_STEER_METHOD`] for adapters that implement it. + /// Parsed here rather than at each call site so no caller can forget it. pub async fn initialize(&mut self) -> Result { // Requesting version 2 is an intentional temporary pin — we are squatting // on ACP v2 ahead of the upstream ACP RFD. Revisit when that RFD merges. let params = build_initialize_params(); let result = self.send_request("initialize", params).await?; + self.steering_supported = result + .pointer("/_meta/steering/supported") + .and_then(|v| v.as_bool()) + .unwrap_or(false); tracing::debug!(target: "acp::init", "initialize response: {result}"); Ok(result) } @@ -792,6 +845,15 @@ impl AcpClient { self.active_run_id.as_deref() } + /// Whether the agent advertised the [`ACP_STEER_METHOD`] extension at + /// `initialize` time (`_meta.steering.supported`). + /// + /// The read loop's steer arm reads the field directly; this accessor exists + /// for the supervisor's post-initialize log line. + pub fn steering_supported(&self) -> bool { + self.steering_supported + } + /// Consume and return the per-turn usage record computed from the most /// recent `_goose/unstable/session/update` notification. /// @@ -1233,14 +1295,18 @@ impl AcpClient { // so the ack_tx oneshot is never leaked silently). let mut steer_rx = self.steer_rx.take(); - // Tracks the in-flight steer write: `(request_id, ack_tx)`. While - // `Some`, the steer arm is gated off so we don't stack writes, + // Tracks the in-flight steer write: `(request_id, transport, ack_tx)`. + // While `Some`, the steer arm is gated off so we don't stack writes, // and a response matching `id` is routed to the ack_tx instead - // of being treated as the prompt result. Drained on every return - // path with `PromptCompletedNeutral` so callers are never left - // hanging. - let mut pending_steer: Option<(u64, tokio::sync::oneshot::Sender)> = - None; + // of being treated as the prompt result. `transport` records which + // method was written so the response arm decodes the result shape + // that method actually returns. Drained on every return path with + // `PromptCompletedNeutral` so callers are never left hanging. + let mut pending_steer: Option<( + u64, + SteerTransport, + tokio::sync::oneshot::Sender, + )> = None; let now = Instant::now(); let mut idle_deadline = now + idle_timeout; @@ -1265,7 +1331,7 @@ impl AcpClient { // exists). Check the classified deadline here so a steady- // stream agent is still bounded. if Instant::now() >= next_deadline { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { // Prompt is timing out — release the withheld event via // PromptCompletedNeutral (no fallback signal: there is // no in-flight turn to signal once we return, and @@ -1300,39 +1366,64 @@ impl AcpClient { None => None, } }, if pending_steer.is_none() => { - // Selected: build steer params at write time using the - // lexical `session_id` and the freshest `active_run_id`. + // Selected: choose the steer transport and build its + // params at write time using the lexical `session_id` + // and the freshest `active_run_id`. // // `active_run_id` is updated by `session/update` // notifications inside this very loop; reading it here // (rather than snapshotting at dispatch) guarantees the // value matches what goose's run-id check will compare - // against. If it's `None`, no `session/update` has - // arrived yet so we cannot form a valid `expectedRunId` - // — ack `ExpectedRunIdMissing` and drop the request - // without writing anything. The main loop maps this to - // the universal cancel+merge `Steer` fallback. - match self.active_run_id.clone() { + // against. + // + // Transport precedence: + // Some(run_id) → GOOSE_STEER_METHOD. goose + // wins whenever a run id exists: `expectedRunId` is + // strictly more precise about *which* run is steered. + // None + steering_supported → ACP_STEER_METHOD, the + // cross-adapter extension (claude-agent-acp, + // codex-acp), which takes no run id. + // None + !steering_supported → write nothing and ack + // `ExpectedRunIdMissing`; the main loop maps this to + // the universal cancel+merge `Steer` fallback. + // + // The capability flag is the ONLY gate on writing + // ACP_STEER_METHOD. Probing an unknown method is unsafe: + // codex-acp answers unrecognized extension methods with + // `{}` — a JSON-RPC success — which would be read as a + // delivered steer and silently drop the user's message. + let prompt_block_refs: Vec<&str> = + req.prompt_blocks.iter().map(String::as_str).collect(); + let selected = match (&self.active_run_id, self.steering_supported) { + (Some(run_id), _) => Some(( + SteerTransport::Goose, + GOOSE_STEER_METHOD, + build_goose_steer_params(session_id, run_id, &prompt_block_refs), + )), + (None, true) => Some(( + SteerTransport::AcpExtension, + ACP_STEER_METHOD, + build_acp_steer_params(session_id, &prompt_block_refs), + )), + (None, false) => None, + }; + match selected { None => { tracing::warn!( - "goose-native steer: no active_run_id at write time \ - (no session/update seen yet) — falling back to cancel+merge" + "steer: no active_run_id and agent did not advertise \ + {ACP_STEER_METHOD} — falling back to cancel+merge" ); let _ = req.ack_tx.send(crate::pool::SteerAck::Err( crate::pool::SteerError::ExpectedRunIdMissing, )); } - Some(run_id) => { + Some((transport, method, params)) => { let id = self.next_id; self.next_id += 1; - let prompt_block_refs: Vec<&str> = - req.prompt_blocks.iter().map(String::as_str).collect(); - let params = - build_steer_params(session_id, &run_id, &prompt_block_refs); let msg = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "method": "_goose/unstable/session/steer", + "method": method, "params": params, }); tracing::debug!( @@ -1342,11 +1433,11 @@ impl AcpClient { ); match self.write_ndjson(&msg).await { Ok(()) => { - pending_steer = Some((id, req.ack_tx)); + pending_steer = Some((id, transport, req.ack_tx)); } Err(e) => { tracing::warn!( - "goose-native steer write failed: {e} — releasing withheld event" + "steer write failed ({method}): {e} — releasing withheld event" ); let _ = req.ack_tx.send(crate::pool::SteerAck::Err( crate::pool::SteerError::Transport(e.to_string()), @@ -1365,7 +1456,7 @@ impl AcpClient { // would catch this anyway, but firing the deadline arm // here makes the wakeup immediate (no extra reader poll // round-trip when stdout is idle). - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } if idle_fires_first { @@ -1389,13 +1480,13 @@ impl AcpClient { match read_result { None => { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::AgentExited); } Some(Err(LinesCodecError::MaxLineLengthExceeded)) => { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::Protocol( @@ -1403,7 +1494,7 @@ impl AcpClient { )); } Some(Err(e)) => { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(AcpError::Io(std::io::Error::other(e))); @@ -1446,13 +1537,14 @@ impl AcpClient { // share the `no method` guard. if let Some(id) = msg.get("id") { if msg.get("method").is_none() { - if let Some((steer_id, _)) = pending_steer.as_ref() { + if let Some((steer_id, _, _)) = pending_steer.as_ref() { if *id == serde_json::json!(*steer_id) { // Take the ack_tx out and route the // response. We do not return — keep // reading until the prompt response // arrives. - let (_, ack_tx) = pending_steer.take().expect("just checked"); + let (_, transport, ack_tx) = + pending_steer.take().expect("just checked"); let ack = if let Some(error) = msg.get("error") { let code = error .get("code") @@ -1463,16 +1555,83 @@ impl AcpClient { crate::pool::SteerError::AgentError { code, message }, ) } else { - let renew_now = Instant::now(); - let new_deadline = renew_now + max_duration; - if new_deadline > hard_deadline { - hard_deadline = new_deadline; - self.current_hard_deadline = Some(new_deadline); - tracing::info!( - "steer success: renewed hard deadline ({max_duration:?} from now)" - ); + // Success result. Whether it counts as + // a delivered steer — and whether the + // turn Buzz awaits is still running — + // depends on the transport. + let outcome = match transport { + // goose returns no outcome field; + // a success response means the + // steer landed in the live run. + SteerTransport::Goose => Some(STEER_OUTCOME_INJECTED), + // The outcome must be positively + // recognized. An unknown or absent + // value (codex-acp answers + // unrecognized ext methods with a + // bare `{}`) is a rejection, never + // a delivery — treating it as + // success would drop the event. + SteerTransport::AcpExtension => msg + .pointer("/result/outcome") + .and_then(|v| v.as_str()) + .filter(|o| { + *o == STEER_OUTCOME_INJECTED + || *o == STEER_OUTCOME_STARTED_NEW_TURN + }), + }; + match outcome { + Some(STEER_OUTCOME_STARTED_NEW_TURN) => { + // Delivered, but into a NEW + // turn: the one this read loop + // is awaiting had already + // finished. Renewing the hard + // deadline here would extend + // the clock on a settled turn, + // so leave it alone and let the + // prompt response land on its + // original budget. + tracing::info!( + "steer accepted as {STEER_OUTCOME_STARTED_NEW_TURN}: \ + awaited turn had ended — hard deadline not renewed" + ); + crate::pool::SteerAck::Success + } + Some(_) => { + let renew_now = Instant::now(); + let new_deadline = renew_now + max_duration; + if new_deadline > hard_deadline { + hard_deadline = new_deadline; + self.current_hard_deadline = Some(new_deadline); + tracing::info!( + "steer success: renewed hard deadline ({max_duration:?} from now)" + ); + } + crate::pool::SteerAck::Success + } + None => { + // Report the raw string when + // there is one, so logs read + // `failed` not `"failed"`; + // fall back to the JSON for a + // non-string value. + let reported = match msg.pointer("/result/outcome") + { + None => "".to_string(), + Some(serde_json::Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + }; + tracing::warn!( + "steer rejected: {ACP_STEER_METHOD} returned \ + unrecognized outcome {reported} — releasing \ + withheld event for cancel+merge" + ); + crate::pool::SteerAck::Err( + crate::pool::SteerError::OutcomeRejected { + outcome: reported, + }, + ) + } } - crate::pool::SteerAck::Success }; let _ = ack_tx.send(ack); continue; @@ -1480,13 +1639,13 @@ impl AcpClient { } if *id == serde_json::json!(expected_id) { if let Some(error) = msg.get("error") { - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx .send(crate::pool::SteerAck::PromptCompletedNeutral); } return Err(agent_error_from_json(error)); } - if let Some((_, ack_tx)) = pending_steer.take() { + if let Some((_, _, ack_tx)) = pending_steer.take() { let _ = ack_tx.send(crate::pool::SteerAck::PromptCompletedNeutral); } @@ -1545,7 +1704,10 @@ impl AcpClient { /// Takes `&mut self` (not `&self`) because some updates carry agent state /// the client must observe — notably goose's `session_info_update` with /// `_meta.goose.activeRunId`, which seeds [`active_run_id`](Self::active_run_id) - /// so callers can target `_goose/unstable/session/steer` at the correct run. + /// so the steer arm can target `_goose/unstable/session/steer` at the + /// correct run. Agents that never emit it (claude-agent-acp, codex-acp) + /// leave it `None` and are steered via `_session/steering` instead, which + /// needs no run id. fn handle_session_update(&mut self, msg: &serde_json::Value) -> bool { let update = &msg["params"]["update"]; let update_type = update @@ -1810,22 +1972,44 @@ fn build_prompt_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json:: /// matches goose's *current* run (it advances on each `session/update`). /// See [`crate::pool::SteerRequest`] for why this is the read loop's job /// and not the main loop's. -fn build_steer_params( +fn build_goose_steer_params( session_id: &str, expected_run_id: &str, prompt_blocks: &[&str], ) -> serde_json::Value { - let blocks: Vec = prompt_blocks - .iter() - .map(|text| serde_json::json!({ "type": "text", "text": text })) - .collect(); serde_json::json!({ "sessionId": session_id, "expectedRunId": expected_run_id, - "prompt": blocks, + "prompt": steer_prompt_blocks(prompt_blocks), }) } +/// Build the params for an [`ACP_STEER_METHOD`] request. +/// +/// Wire shape: +/// ```json +/// { "sessionId": "...", "prompt": [{"type":"text","text":"..."}, ...] } +/// ``` +/// +/// Deliberately carries **no** `expectedRunId`: the cross-adapter method +/// steers whatever turn is currently running and neither claude-agent-acp nor +/// codex-acp emits a run id to target. +fn build_acp_steer_params(session_id: &str, prompt_blocks: &[&str]) -> serde_json::Value { + serde_json::json!({ + "sessionId": session_id, + "prompt": steer_prompt_blocks(prompt_blocks), + }) +} + +/// Render steer body strings as ACP `text` content blocks. Shared by both +/// steer transports so the prompt shape cannot drift between them. +fn steer_prompt_blocks(prompt_blocks: &[&str]) -> Vec { + prompt_blocks + .iter() + .map(|text| serde_json::json!({ "type": "text", "text": text })) + .collect() +} + /// Build a JSON-RPC permission response with `outcome: "selected"`. fn permission_response_selected(id: &serde_json::Value, option_id: &str) -> serde_json::Value { serde_json::json!({ @@ -3550,6 +3734,412 @@ mod tests { } } + // ── Cross-harness steer transport tests ─────────────────────────────── + // + // These cover the `_session/steering` transport added alongside the + // goose-native method: capability capture at `initialize`, write-time + // transport selection, and outcome decoding. Wire-shape assertions read + // the actual serialized request bytes via `capture_steer_request` rather + // than inferring the shape from response-id routing. + + /// Spawn a client whose script captures the first line written to its + /// stdin into `capture_path`, then emits `response` (already-serialized + /// JSON-RPC) and idles. + /// + /// The steer request is the first thing this read loop writes, so the + /// captured line IS the steer request bytes. + async fn spawn_steer_capture_script( + capture_path: &std::path::Path, + response: &str, + ) -> AcpClient { + let script = format!( + "read -r line; printf '%s' \"$line\" > {capture}; \ + printf '%s\\n' '{response}'; sleep 10", + capture = capture_path.display(), + response = response, + ); + spawn_script(&script).await + } + + /// Drive one steer through the read loop and return + /// `(captured_request_bytes, ack)`. + /// + /// `capture_path` may be absent afterwards when the arm wrote nothing — + /// callers assert on that. The read loop is expected to exit via a + /// timeout or EOF; the ack is what these tests care about. + async fn run_one_steer( + client: &mut AcpClient, + capture_path: &std::path::Path, + ) -> (Option, crate::pool::SteerAck) { + let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1); + client.install_steer_rx(steer_rx); + + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); + let send_task = tokio::spawn(async move { + steer_tx + .send(crate::pool::SteerRequest { + prompt_blocks: vec!["steer body".into()], + ack_tx, + }) + .await + .expect("steer_tx send should succeed"); + }); + + let idle = std::time::Duration::from_millis(800); + let max_dur = std::time::Duration::from_secs(10); + let hard_deadline = tokio::time::Instant::now() + max_dur; + let _ = client + .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur) + .await; + send_task.await.expect("send_task should complete"); + + let ack = ack_rx + .await + .expect("ack oneshot must have received a SteerAck"); + (std::fs::read_to_string(capture_path).ok(), ack) + } + + /// Unique temp path for one test's captured request bytes. + fn capture_path(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("buzz-acp-steer-capture"); + std::fs::create_dir_all(&dir).expect("create capture dir"); + let path = dir.join(format!("{name}.json")); + let _ = std::fs::remove_file(&path); + path + } + + /// Mark a client as having advertised `_meta.steering.supported` without + /// running a real `initialize` handshake. The capability-parsing tests + /// cover the handshake itself. + fn set_steering_supported(client: &mut AcpClient) { + client.steering_supported = true; + } + + /// Run `initialize` against a script that replies with `init_result` as + /// the JSON-RPC result, and return the resulting `steering_supported`. + async fn steering_supported_after_initialize(init_result: &str) -> bool { + let script = format!( + "read -r _init; printf '%s\\n' '{{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{result}}}'; \ + sleep 5", + result = init_result, + ); + let mut client = spawn_script(&script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + client.steering_supported() + } + + /// Test 1a: an adapter advertising `_meta.steering.supported: true` + /// (claude-agent-acp `src/acp-agent.ts:1444`, codex-acp + /// `src/CodexAcpServer.ts:247`) is recorded as steering-capable. + #[tokio::test] + async fn initialize_records_steering_supported_when_advertised() { + let supported = steering_supported_after_initialize( + r#"{"protocolVersion":2,"agentCapabilities":{},"_meta":{"steering":{"supported":true}}}"#, + ) + .await; + assert!( + supported, + "_meta.steering.supported: true must set steering_supported" + ); + } + + /// Test 1b: no `_meta` at all (goose, buzz-agent, any older adapter) must + /// leave the capability off — this is what keeps a steer off the wire for + /// agents that never implemented it. + #[tokio::test] + async fn initialize_leaves_steering_unsupported_when_meta_absent() { + let supported = + steering_supported_after_initialize(r#"{"protocolVersion":2,"agentCapabilities":{}}"#) + .await; + assert!( + !supported, + "absent _meta must leave steering_supported false" + ); + } + + /// Test 1c: an explicit `supported: false` is respected, not treated as + /// "the key exists so it must work". + #[tokio::test] + async fn initialize_leaves_steering_unsupported_when_explicitly_false() { + let supported = steering_supported_after_initialize( + r#"{"protocolVersion":2,"_meta":{"steering":{"supported":false}}}"#, + ) + .await; + assert!( + !supported, + "_meta.steering.supported: false must leave steering_supported false" + ); + } + + /// Test 2: no `active_run_id` + capability advertised → the bytes on the + /// wire are an `_session/steering` request carrying `sessionId` and + /// `prompt`, and carrying **no** `expectedRunId` (the adapters reject + /// unknown required fields, and there is no run id to report anyway). + #[tokio::test] + async fn acp_steer_request_omits_expected_run_id_and_carries_session_and_prompt() { + let capture = capture_path("acp_shape"); + let mut client = spawn_steer_capture_script( + &capture, + r#"{"jsonrpc":"2.0","id":0,"result":{"outcome":"injected"}}"#, + ) + .await; + set_steering_supported(&mut client); + assert!( + client.active_run_id().is_none(), + "precondition: no active_run_id" + ); + + let (written, ack) = run_one_steer(&mut client, &capture).await; + + let written = written.expect("steer request must have been written"); + let msg: serde_json::Value = + serde_json::from_str(&written).expect("written line must be valid JSON"); + assert_eq!( + msg["method"].as_str(), + Some(ACP_STEER_METHOD), + "must use the cross-adapter steer method; wrote: {written}" + ); + assert_eq!(msg["params"]["sessionId"].as_str(), Some("sess-test")); + assert_eq!( + msg["params"]["prompt"][0]["text"].as_str(), + Some("steer body"), + "prompt must carry the steer body as a text block" + ); + assert!( + msg["params"].get("expectedRunId").is_none(), + "_session/steering must not carry expectedRunId; wrote: {written}" + ); + assert!( + matches!(ack, crate::pool::SteerAck::Success), + "injected outcome must ack Success, got {ack:?}" + ); + } + + /// Test 3: goose keeps priority. With both an `active_run_id` and the + /// advertised capability, the goose method wins — `expectedRunId` is + /// strictly more precise about which run is being steered. + #[tokio::test] + async fn goose_transport_wins_when_both_run_id_and_capability_present() { + let capture = capture_path("goose_priority"); + let mut client = + spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await; + set_steering_supported(&mut client); + let update = session_info_update_msg(Some(serde_json::json!("run-77"))); + let _ = client.handle_session_update(&update); + + let (written, ack) = run_one_steer(&mut client, &capture).await; + + let written = written.expect("steer request must have been written"); + let msg: serde_json::Value = + serde_json::from_str(&written).expect("written line must be valid JSON"); + assert_eq!( + msg["method"].as_str(), + Some(GOOSE_STEER_METHOD), + "goose method must win when a run id exists; wrote: {written}" + ); + assert_eq!(msg["params"]["expectedRunId"].as_str(), Some("run-77")); + // A bare `{}` result is a success on the goose transport (goose sends + // no `outcome`) — the OutcomeRejected guard applies only to + // `_session/steering`. + assert!( + matches!(ack, crate::pool::SteerAck::Success), + "goose success result must ack Success, got {ack:?}" + ); + } + + /// Test 7: codex-acp's third outcome, `failed` + /// (`src/AcpExtensions.ts:92`), is a delivery rejection despite being a + /// JSON-RPC success — release the event and fall back. + #[tokio::test] + async fn acp_steer_failed_outcome_acks_outcome_rejected() { + let capture = capture_path("outcome_failed"); + let mut client = spawn_steer_capture_script( + &capture, + r#"{"jsonrpc":"2.0","id":0,"result":{"outcome":"failed"}}"#, + ) + .await; + set_steering_supported(&mut client); + + let (_written, ack) = run_one_steer(&mut client, &capture).await; + + match ack { + crate::pool::SteerAck::Err(crate::pool::SteerError::OutcomeRejected { outcome }) => { + assert_eq!( + outcome, "failed", + "rejected outcome must report what the agent said, unquoted" + ); + } + other => panic!("expected Err(OutcomeRejected), got {other:?}"), + } + } + + /// Test 8: **codex `extMethod` silent-loss regression guard.** codex-acp's + /// ext dispatcher answers unrecognized methods with a bare `{}` — a + /// JSON-RPC *success*, not `-32601` (`src/CodexAcpServer.ts:255-258`). + /// Buzz maps `SteerAck::Success` to `queue.remove_event`, so decoding + /// `{}` as success would delete the user's message with no error, no + /// fallback, and no log. An absent `outcome` must therefore be a + /// rejection, which releases the event and fires cancel+merge. + #[tokio::test] + async fn acp_steer_missing_outcome_acks_outcome_rejected_and_never_drops_event() { + let capture = capture_path("outcome_absent"); + let mut client = + spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await; + set_steering_supported(&mut client); + + let (_written, ack) = run_one_steer(&mut client, &capture).await; + + match ack { + crate::pool::SteerAck::Err(crate::pool::SteerError::OutcomeRejected { outcome }) => { + assert_eq!( + outcome, "", + "a result with no outcome field must be reported as absent" + ); + } + other => panic!( + "expected Err(OutcomeRejected) for a bare {{}} success — \ + anything else risks dropping the event, got {other:?}" + ), + } + } + + /// Test 5: `injected` renews the hard deadline, so the turn survives past + /// its original one. Mirrors + /// `steer_success_renews_hard_deadline_and_survives_past_original` for + /// the `_session/steering` transport. + /// + /// Timeline: original hard deadline at t≈1s; steer response at t≈0.5s + /// renews it to t≈3.5s; prompt response at t≈1.5s lands inside it. + #[tokio::test] + async fn acp_steer_injected_renews_hard_deadline_and_survives_past_original() { + let script = "sleep 0.5; \ + echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"outcome\":\"injected\"}}'; \ + sleep 1; \ + echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'"; + let mut client = spawn_script(script).await; + set_steering_supported(&mut client); + + let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1); + client.install_steer_rx(steer_rx); + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); + let send_task = tokio::spawn(async move { + steer_tx + .send(crate::pool::SteerRequest { + prompt_blocks: vec!["steer body".into()], + ack_tx, + }) + .await + .expect("steer_tx send should succeed"); + }); + + let idle = std::time::Duration::from_secs(10); + let max_dur = std::time::Duration::from_secs(3); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let result = client + .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur) + .await; + send_task.await.expect("send_task should complete"); + + assert!( + result.is_ok(), + "injected must renew the deadline so the prompt response still lands, got {result:?}" + ); + assert_eq!(result.unwrap()["done"], serde_json::json!(true)); + let ack = ack_rx.await.expect("ack must be received"); + assert!( + matches!(ack, crate::pool::SteerAck::Success), + "injected must ack Success, got {ack:?}" + ); + } + + /// Test 6: **red/green for the no-renewal rule.** `startedNewTurn` means + /// the turn Buzz was steering had already ended and the adapter began a + /// fresh, detached one. It acks `Success` (the message WAS delivered, so + /// the event must not be redelivered) but must NOT renew the hard + /// deadline — that clock belongs to a turn which is already settled. + /// + /// Same timeline as the `injected` test, so the only difference is the + /// outcome string: original hard deadline at t≈1s, steer response at + /// t≈0.5s, prompt response at t≈1.5s. With renewal the prompt response + /// would land and this returns `Ok`; without renewal the original + /// deadline fires first and we get `HardTimeout`. + #[tokio::test] + async fn acp_steer_started_new_turn_acks_success_without_renewing_hard_deadline() { + let script = "sleep 0.5; \ + echo '{\"jsonrpc\":\"2.0\",\"id\":0,\"result\":{\"outcome\":\"startedNewTurn\"}}'; \ + sleep 1; \ + echo '{\"jsonrpc\":\"2.0\",\"id\":999,\"result\":{\"done\":true}}'"; + let mut client = spawn_script(script).await; + set_steering_supported(&mut client); + + let (steer_tx, steer_rx) = tokio::sync::mpsc::channel::(1); + client.install_steer_rx(steer_rx); + let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); + let send_task = tokio::spawn(async move { + steer_tx + .send(crate::pool::SteerRequest { + prompt_blocks: vec!["steer body".into()], + ack_tx, + }) + .await + .expect("steer_tx send should succeed"); + }); + + let idle = std::time::Duration::from_secs(10); + let max_dur = std::time::Duration::from_secs(3); + let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(1); + let result = client + .read_until_response_with_idle_timeout("sess-test", 999, idle, hard_deadline, max_dur) + .await; + send_task.await.expect("send_task should complete"); + + // The original deadline must still fire — renewal here would extend + // the clock on a turn the adapter has already finished. + assert!( + matches!(result, Err(AcpError::HardTimeout { .. })), + "startedNewTurn must NOT renew the hard deadline, so the original \ + one must still fire; got {result:?}" + ); + // Delivery still succeeded, so the withheld event must be dropped + // rather than released — hence Success, not an Err. + let ack = ack_rx.await.expect("ack must be received"); + assert!( + matches!(ack, crate::pool::SteerAck::Success), + "startedNewTurn is a delivery success, got {ack:?}" + ); + } + + /// Test 4 (companion to the existing + /// `native_steer_with_no_active_run_id_acks_expected_run_id_missing`): + /// no run id AND no advertised capability means nothing is written at + /// all. This is the gate that keeps a steer off the wire for adapters + /// that never implemented either method. + #[tokio::test] + async fn steer_writes_nothing_when_no_run_id_and_capability_absent() { + let capture = capture_path("no_transport"); + let mut client = + spawn_steer_capture_script(&capture, r#"{"jsonrpc":"2.0","id":0,"result":{}}"#).await; + assert!(!client.steering_supported(), "precondition: not advertised"); + assert!( + client.active_run_id().is_none(), + "precondition: no active_run_id" + ); + + let (written, ack) = run_one_steer(&mut client, &capture).await; + + assert!( + written.is_none(), + "no transport available must write nothing; wrote: {written:?}" + ); + match ack { + crate::pool::SteerAck::Err(crate::pool::SteerError::ExpectedRunIdMissing) => {} + other => panic!("expected Err(ExpectedRunIdMissing), got {other:?}"), + } + } + // ── Goose usage notification integration ────────────────────────────── /// Build a `_goose/unstable/session/update` JSON-RPC notification. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b11d96d8f7..d63f720c65 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1140,9 +1140,7 @@ fn any_respawn_in_flight(crash_history: &[SlotCircuit]) -> bool { /// Result of a background respawn task. struct RespawnResult { index: usize, - /// Tuple: (initialized client, protocol version, supports_goose_steer). - /// The third element is always `true` — the supervisor uses - /// try-and-tolerate for the steer extension. + /// Tuple: (initialized client, protocol version, agent name). result: Result<(AcpClient, u32, String)>, } @@ -2228,18 +2226,18 @@ async fn tokio_main() -> Result<()> { owner_cache.get(), ); if let Some(signal) = signal { - // Try-and-tolerate fork: when the mode - // wants a Steer, attempt the non-cancelling - // path first for any agent. On accept, + // Non-cancelling fork: when the mode + // wants a Steer, attempt the + // non-cancelling path first. On accept, // withhold the queued event and spawn an // ack watcher; the main loop's // `PoolEvent::SteerAck` arm decides // success/release/fallback. On reject - // (including `-32601 method_not_found` - // from agents that don't implement the - // extension), fall through to the universal - // cancel+merge `Steer` signal so the event - // still reaches the agent. + // (including agents that advertise no + // steer transport at all), fall through + // to the universal cancel+merge `Steer` + // signal so the event still reaches the + // agent. let native_attempted = matches!(signal, ControlSignal::Steer) && try_native_steer( &mut pool, @@ -2419,14 +2417,26 @@ async fn tokio_main() -> Result<()> { event_id, ack, })) => { - // Goose-native steer attempt resolved. Locked semantics - // (Eva + Max + Perci, unanimous on Option X): + // Mid-turn steer attempt resolved (either transport: + // `_goose/unstable/session/steer` or `_session/steering`). + // Locked semantics (Eva + Max + Perci, unanimous on Option X): // // Success // The agent received the steer via the non-cancelling // path. Drop the withheld event so normal dispatch // never redelivers it. // + // Also covers `_session/steering`'s `startedNewTurn` + // outcome: the message was delivered, but into a fresh + // turn because the one being steered had already + // finished. Delivery is what this arm keys on, so the + // event is still dropped. The read loop deliberately + // does NOT renew its hard deadline in that case (the + // awaited turn is settled), while + // `extend_in_flight_deadline` below still applies — + // the agent really is running more work, so the + // channel's in-flight budget should reflect it. + // // Err(_) where the write never landed (Transport / // ExpectedRunIdMissing): // Delivery state of the underlying message is "never @@ -2434,6 +2444,16 @@ async fn tokio_main() -> Result<()> { // queue front AND issue the cancel+merge fallback so // the message still reaches the agent. // + // Err(OutcomeRejected { .. }) + // A `_session/steering` request returned a JSON-RPC + // success whose `outcome` was not `injected` or + // `startedNewTurn` (codex's `failed`, an unknown value, + // or a bare `{}` with no `outcome` at all). The steer + // did not land, so this is treated exactly like a write + // that never happened: release withheld AND fire the + // cancel+merge fallback. Handled by the catch-all + // `Err(_)` arm below. + // // Err(AgentError { code: -32601, .. }) // The agent returned method_not_found — it does not // implement the steer extension. Release withheld AND @@ -2490,9 +2510,9 @@ async fn tokio_main() -> Result<()> { Ok(pool::SteerAck::Err(pool::SteerError::AgentError { .. })) => { (true, false, false) } - // Transport / ExpectedRunIdMissing: write never landed. - // Release and fire the cancel+merge fallback so the - // message still reaches the agent. + // Transport / ExpectedRunIdMissing / OutcomeRejected: the + // steer did not land. Release and fire the cancel+merge + // fallback so the message still reaches the agent. Ok(pool::SteerAck::Err(_)) => (true, false, true), Ok(pool::SteerAck::PromptCompletedNeutral) => (true, false, false), Err(_recv_err) => (true, false, false), @@ -2926,15 +2946,15 @@ fn dispatch_pending( let ctx_clone = Arc::clone(ctx); let agent_index = agent.index; - // Goose-native non-cancelling steer seam: snapshot capability before - // the agent moves into `run_prompt_task`, and install the per-turn - // steer receiver on the read loop so the main loop's mode-gate fork + // Mid-turn non-cancelling steer seam: install the per-turn steer + // receiver on the read loop so the main loop's mode-gate fork // (see the `if accepted && queue.is_channel_in_flight(...)` block // in the relay event branch of the main `select!` loop) can drive // it via the matching sender stored in `TaskMeta.steer_tx`. - // Install the steer channel for every prompt task — the supervisor - // uses try-and-tolerate: it attempts the steer for any agent and - // treats `-32601 method_not_found` as "fall back to cancel+merge". + // Installed for every prompt task: the read loop picks the steer + // transport at write time from `active_run_id` and the agent's + // advertised `_session/steering` capability, and acks + // `ExpectedRunIdMissing` (→ cancel+merge) when it has neither. let (tx, rx) = tokio::sync::mpsc::channel::(1); agent.acp.install_steer_rx(rx); let steer_tx = Some(tx); @@ -3783,7 +3803,8 @@ async fn initialize_agent_pool( .and_then(|info| info.get("name")) .and_then(|v| v.as_str()) .unwrap_or("unknown"), - "agent initialized — non-cancelling steer enabled (try-and-tolerate)" + steering_supported = acp.steering_supported(), + "agent initialized" ); acp.observe( "agent_initialized", diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 0c51fe954f..b1fd68d044 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -309,10 +309,13 @@ pub enum ControlSignal { /// for that — only a function parameter pass-through. /// /// If `active_run_id` is `None` at write time (no `session/update` seen yet -/// — e.g. agents that never emit run-id metadata), the steer cannot form a -/// valid `expectedRunId` and the read loop acks -/// [`SteerError::ExpectedRunIdMissing`]. The main loop maps this to the -/// "Err-before-pending" bucket: no withhold/mark was established at +/// — e.g. agents that never emit run-id metadata), the goose-native method +/// cannot form a valid `expectedRunId`, and the read loop falls back to the +/// cross-adapter `_session/steering` method when the agent advertised +/// `_meta.steering.supported` at `initialize`. That method takes no run id, so +/// no freshness concern applies to it. When neither transport is available the +/// read loop acks [`SteerError::ExpectedRunIdMissing`]. The main loop maps that +/// to the "Err-before-pending" bucket: no withhold/mark was established at /// `pool::send_steer` time because the request was rejected before any /// write, so the watcher only needs to release nothing and fall back to the /// universal `ControlSignal::Steer` cancel+merge path. @@ -326,7 +329,8 @@ pub struct SteerRequest { pub ack_tx: tokio::sync::oneshot::Sender, } -/// Why a goose-native steer failed. +/// Why a mid-turn steer failed, on either transport +/// (`_goose/unstable/session/steer` or `_session/steering`). /// /// String and integer fields are intentionally `Debug`-only — read by /// `tracing` macros in the main loop's `PoolEvent::SteerAck` arm via @@ -349,14 +353,28 @@ pub enum SteerError { /// Transport-level failure: write error, read EOF, JSON-RPC framing /// violation, etc. The string carries the underlying `AcpError`'s display. Transport(String), - /// At steer-write time `AcpClient::active_run_id` was `None`, so the - /// read loop couldn't form a valid `expectedRunId`. The read loop drops - /// the request without writing anything; the main loop should release - /// any withheld event and fall back to the universal cancel+merge + /// At steer-write time neither steer transport was available: no + /// `expectedRunId` (`AcpClient::active_run_id` was `None`, so the + /// goose-native method could not be formed) and the agent did not + /// advertise the cross-adapter `_session/steering` extension. The read + /// loop drops the request without writing anything; the main loop should + /// release any withheld event and fall back to the universal cancel+merge /// `ControlSignal::Steer` path. This is in the same "Err-before-pending" /// bucket as `Transport` write failures: no in-process state was /// established, so no in-process cleanup is needed. ExpectedRunIdMissing, + /// A `_session/steering` request returned a JSON-RPC *success* whose + /// `outcome` was not one of the two recognized delivery outcomes + /// (`injected`, `startedNewTurn`) — including `failed` (codex-acp) and a + /// missing `outcome` entirely. `outcome` carries what the agent actually + /// reported, for logs. + /// + /// The steer did NOT land, so the main loop must release the withheld + /// event and fire the cancel+merge fallback — exactly like a write that + /// never happened. Treating an unrecognized success as delivery would + /// drop the user's message: codex-acp answers unrecognized extension + /// methods with a bare `{}` success rather than `-32601`. + OutcomeRejected { outcome: String }, /// The read loop never got to dispatch the steer because the prompt /// completed first. Delivery state for the underlying message is /// unknown after prompt completion — the main loop must treat this as @@ -369,7 +387,7 @@ pub enum SteerError { PromptCompleted, } -/// Outcome of a goose-native steer, sent from the read loop back to the +/// Outcome of a mid-turn steer, sent from the read loop back to the /// main loop's ack watcher. #[derive(Debug)] pub enum SteerAck { From 12d63c67be276d9d11a436374b61469a00bb3808 Mon Sep 17 00:00:00 2001 From: Dave Grochowski Date: Tue, 28 Jul 2026 17:44:53 -0400 Subject: [PATCH 11/99] release(chart): publish 0.1.7 (#3393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Publish chart 0.1.7 after the feature PR merged from a fork and therefore intentionally skipped the internal-branch auto-tag job. ## What - Trigger the `chart-release/0.1.7` release lane - Update the quickstart example to reference chart 0.1.7 ## Risk Assessment Low — the chart implementation is already merged and tested; this PR creates its immutable release tag and OCI artifact. ## References - Chart implementation: https://github.com/block/buzz/pull/3322 - `helm unittest` 0.8.2: 43/43 tests passed - Local pre-push checks passed Generated with Amp Signed-off-by: David Grochowski Co-authored-by: Amp --- deploy/charts/buzz/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 7e75d81a2e..a7c4bcf63b 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -12,7 +12,7 @@ This chart has two operating profiles selected by values: ## Quickstart (eval only) ```sh -helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.0 \ +helm install buzz oci://ghcr.io/block/buzz/charts/buzz --version 0.1.7 \ --create-namespace --namespace buzz \ --set quickstart=true \ --set postgresql.enabled=true \ From 826bed4821f035841193b7660655736171e66211 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 28 Jul 2026 17:59:02 -0400 Subject: [PATCH 12/99] chore(ci): bump desktop smoke E2E timeout to 30 minutes (#3409) Three main-branch runs today had shards killed at exactly 20m17s ("exceeded the maximum execution time of 20m0s"); the killed shard was actively passing tests seconds before the cap. Shard runtime has grown to the limit. 30 matches the other desktop jobs in the same workflow. Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd18179ee4..49601ae980 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,7 +213,7 @@ jobs: desktop-smoke-e2e: name: Desktop Smoke E2E (${{ matrix.shard }}) runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 needs: [changes] if: github.event_name == 'push' || needs.changes.outputs.desktop == 'true' || needs.changes.outputs.desktop-rust == 'true' || needs.changes.outputs.rust == 'true' strategy: From 9227bdf58ad6664ae3c1078888f2181ec19c4da4 Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 28 Jul 2026 16:17:36 -0600 Subject: [PATCH 13/99] fix(ci): ratchet file sizes against the base tree (#3352) ## Summary - replace the whole-tree file-size gate with a stateless differential ratchet - allow inherited files over 1,000 lines to hold or shrink, but never grow - delete the 44-entry numeric override ledger and run the same policy across Desktop, Web, and Mobile CI - fail closed when the local base cannot be resolved and cover policy, Git status parsing, and base resolution in unit tests This removes the shared mutable policy state that caused unrelated PRs to fail after neighboring merges. It does **not** by itself prevent two stale green PRs from becoming invalid when combined; that requires merge queue or up-to-date branch enforcement. ### Related issue None found. This follows the design discussion in the linked Buzz channel. ### Testing - `node --test scripts/check-file-sizes-core.test.mjs` (6/6) - Desktop, Web, and Mobile ratchet entrypoints - `just desktop-check` - `just web-check` - Mobile analysis - `git diff --check` The repository pre-push suite also exposed an unrelated existing Mobile widget failure in `ChannelDetailPage keeps follow mode off while a tall newest message stays visible`; it reproduces in isolation and this branch does not touch Mobile widget behavior. Signed-off-by: Wes Co-authored-by: Carl --- .github/workflows/ci.yml | 16 + desktop/scripts/check-file-sizes.mjs | 671 ------------------------- mobile/scripts/check-file-sizes.mjs | 8 - scripts/check-file-sizes-core.mjs | 224 ++++++--- scripts/check-file-sizes-core.test.mjs | 109 ++++ web/scripts/check-file-sizes.mjs | 1 - 6 files changed, 267 insertions(+), 762 deletions(-) create mode 100644 scripts/check-file-sizes-core.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49601ae980..d4826d985f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,15 +48,21 @@ jobs: - 'scripts/run-tests.sh' - 'justfile' desktop: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'desktop/**' - '!desktop/src-tauri/**' - 'pnpm-lock.yaml' desktop-rust: - 'desktop/src-tauri/**' web: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'web/**' - 'pnpm-lock.yaml' mobile: + - 'scripts/check-file-sizes-core.mjs' + - 'scripts/check-file-sizes-core.test.mjs' - 'mobile/**' - 'scripts/mobile-release.sh' - 'scripts/mobile-worktree-overrides.sh' @@ -76,6 +82,8 @@ jobs: scripts/test-mobile-release-candidate-publisher.sh - name: Mobile worktree identity contract run: scripts/test-mobile-worktree-overrides.sh + - name: File size ratchet unit tests + run: node --test scripts/check-file-sizes-core.test.mjs rust-lint: name: Rust Lint @@ -130,6 +138,8 @@ jobs: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -751,6 +761,8 @@ jobs: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Get pnpm store directory id: pnpm-cache @@ -784,6 +796,8 @@ jobs: contents: read steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Compute Hermit cache key id: hermit-bin-hash @@ -822,6 +836,8 @@ jobs: with: path: ~/.pub-cache key: pub-${{ runner.os }}-${{ hashFiles('mobile/pubspec.lock') }} + - name: File size ratchet + run: node mobile/scripts/check-file-sizes.mjs - name: Format check run: cd mobile && dart format --output=none --set-exit-if-changed . - name: Analyze diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 3983fa591d..326587b87f 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -46,679 +46,8 @@ const rules = [ }, ]; -// TEMP — these files exceed the 1000-line limit and are queued to be split. -// Do not add to this list; split the file instead. Remove each entry as its -// file is broken up. Tracked as a follow-up. -const overrides = new Map([ - // Inherited from origin/main: #2630 (agent emoji picker search) grew this - // file to 1026 lines with no override; this branch does not touch the file. - // Narrow ratchet so unrelated branches stay green; queued to split upstream. - ["src/features/agents/ui/AgentCreationPreview.tsx", 1026], - // Native Builderlab auth/community commands add a small registration surface - // to the existing Tauri composition root. The implementation lives in - // builderlab.rs; this narrowly ratchets the command wiring while lib.rs is - // queued for a broader composition-root split. Bumped for the - // archive/unarchive/transfer community-management commands (web parity). - ["src-tauri/src/lib.rs", 1013], - // persona-events rebase: build_deploy_payload threads `state` for the - // read-time relay-URL workspace fallback while keeping the create-time env - // pin (the credential-leak guard). Load-bearing feature growth from the - // rebase, queued to split with the rest of this list. - // persona-refresh-on-spawn: re-snapshot + retain_managed_agent_pending call - // in start_local_agent_with_preflight adds ~23 lines. Queued to split. - // rebase onto main (2026-06-25): main's agents.rs grew by ~17 lines since - // config-bridge: get_agent_config_surface/write_agent_config_field/put_agent_session_config - // commands add ~40 lines. Queued to split. - // branch cut; override bumped to cover the merged total. Queued to split. - // persona-blank-fallback: persona_snapshot_with_agent_config_fallback call - // sites add ~4 lines (extra fallback params + inline comments). build_deploy_payload - // fix (blank-persona provider/model fallback) adds ~6 lines. Bug fix. - // archive/mod_tests.rs carries the full test module for archive/mod.rs: - // unit tests + 4 real-relay integration tests (ignored, live-relay only). - // Production logic in mod.rs is now ~527 lines (under 1000). mod_tests.rs - // is test-only content; the override covers the test growth accumulated - // across the local-archive + agent-metric-archive PR series. store_tests.rs - // (~731 lines) is under 1000 so needs no override. - ["src-tauri/src/archive/mod_tests.rs", 1208], - // unified-agent-model 1A.1: profile reconcile split to agents_profile.rs, - // ratcheting 1443 -> 1295. Queued to split further in the A2 fold. - // global-agent-config: resolve_deploy_model_provider + visibility exports - // add ~40 lines on top of the 1A.1 ratchet. Queued to split. - // +29 (1340 -> 1369, main): agent-config-resolver — start_local_agent_with_preflight - // uses resolve_effective_relay_mesh_model_id at both preflight call sites; - // preview_prospective_persona_snapshot helper extracted; orphan guard threaded - // through restore path; start_local_agent_pairs_with_preflight resolver - // preflight. Load-bearing feature changes; queued to split. - // +47 (#2773): review fix — load_global_agent_config hoisted out of - // build_managed_agent_summary into callers, dangling-harness summaries render - // the deleted id, and spawn errors surface as sentences (tests included). - // +1: merge of the two deltas above (actual post-merge count). - ["src-tauri/src/commands/agents.rs", 1418], - // agent-lifecycle-fixes: cascade-delete in delete_persona restructured into - // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for - // retry-safety. Load-bearing reviewer-required change; queued to split. - // Consolidation removed the legacy persona-card import/export codecs. - // #1418 read-path fix: get_thread_replies' blocker fix (shared TIMELINE_KINDS - // const + build_thread_replies_filter helper, mirroring the channel sibling so - // the two p-gate filters can't drift) plus two guard unit tests. The file was - // already at 995; this load-bearing correctness fix crossed 1000. Not generic - // debt growth. Approved override; queued to split with the rest of this list. - ["src-tauri/src/commands/messages.rs", 1082], - // Residual repos_dir integration in ensure_nest_at: REPOS is provisioned - // outside NEST_DIRS (it may be a symlink), so it needs its own create + - // chmod-only-when-real-dir handling plus integration test coverage. The - // self-contained repos_dir functions and their unit tests live in repos.rs; - // this is the seam that must stay in nest.rs. Approved override; still queued - // to split with the rest of this list. - // dev-nest namespace: OnceLock> + init_nest_dir + constants - // added to plumb the dev/prod discriminator. Load-bearing for the D2 nest fix. - // dev-build CLI symlink: cli_link_name helper + is_dev param on - // ensure_cli_symlink + prod/dev test variants add ~68 lines. Load-bearing; - // queued to split with the rest of this list. - // +4 lines: adopt shared create_symlink wrapper (behavior-preserving refactor - // for multi-line rustfmt expansion of the skills symlink call site). - // unified-agent-model 1A.1: inline test module moved to nest/tests.rs, - // ratcheting 1575 -> 679 (under the 1000 default; entry kept as a ratchet). - // observer-archive dev-default: path_is_dev_nest + nest_is_dev getters - // (+25 lines) so observer_archive_default_enabled() keys off the dev nest. - // Load-bearing; spends banked ratchet headroom, still well under 1000. - ["src-tauri/src/managed_agents/nest.rs", 704], - // keyring-dev-isolation: agent key migration added copy_agent_keys_between_stores - // and load_readonly support; file grew past 1000 default. Queued to split. - // +7 for try_delete_agent_key result-returning seam (snapshot-import rollback). - // +48 (1335 -> 1383): agents-everywhere pair re-key — pair-scoped runtime - // receipts (write_agent_runtime_receipt atomic JSON + remove/read_all - // helpers) replace the pubkey-keyed PID file, plus the hashed pair-scoped - // runtime log path. Load-bearing crash-recovery surface; queued to split. - // harness-log reader fix: the inline test module moved to storage_tests.rs - // (`#[path]`-included), ratcheting 1383 -> 826. Both halves are now under the - // 1000 default; entries kept as ratchets. - ["src-tauri/src/managed_agents/storage.rs", 826], - ["src-tauri/src/managed_agents/storage_tests.rs", 701], - // config-bridge setup-payload env-boundary fix adds readiness wiring in - // spawn_agent_child; load-bearing security fix, queued to split. - ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], - // config-bridge-aware requirements: goose_requirements + injection tests - // (4 new tests in goose_file_config_tests module) + test-determinism fixes - // for the 3 existing goose tests that previously read real disk config. - // New file in this PR; queued to split. - // +2 readiness integration tests for flat-DATABRICKS_HOST canonicalization fix. - // +1 cargo fmt whitespace reformat (readiness.rs closures inline after rebase). - // +2 unit tests for cli_login_requirements resolve_command integration (DMG PATH fix). - // Doctor-CTA: reworked cli_login_requirements to carry AcpAvailabilityStatus, - // skip login probe for not-installed/adapter-missing/cli-missing states, and - // added 4 unit tests covering each arm. Load-bearing discoverability fix. - // Updated existing codex_not_ready test to use make_cli_runtime stub. - // +4 lines: #1640 persona-env-vars-refresh rebase added availability-classification - // growth in the live-persona env merge path. Feature plumbing, not generic debt. - // Windows-CI portability: replaced POSIX true/false probes with current_exe() - // stand-in + present_binary_str()/static_commands() helpers (+29 lines). - // Tests now pass on windows-latest CI shard without POSIX shell utilities. - // databricks-v1-to-v2-migration: databricks-v2 hyphen-alias added to all - // host/credential match arms + 30+ readiness tests for provider aliases, - // missing-host, and DATABRICKS_MODEL fallback. Load-bearing correctness fix. - // #1613 augmented-PATH readiness probes grew the file +3 past the prior cap. - // +16: resolve_effective_agent_env + global-config readiness wiring (#1448). - // +1 rebase merge: GlobalAgentConfig import added alongside AcpAvailabilityStatus. - // +2 rebase onto #1667: behavioral quad fields in AgentDefinition/ManagedAgentRecord. - // +3 rebase onto main (#1568 + #1613): identity-import-keyring + augmented-PATH probes. - // +18: CliConfigInvalid requirement surface for config-parse probe classification — - // new Requirement variant + updated cli_login_requirements + 3 new probe-layer tests. - // Load-bearing UX fix (bad config → clear diagnostic, not "run codex login"). - // codex-acp-package-swap: AdapterOutdated version-probe in cli_login_requirements - // (+22 lines). Load-bearing — blocks login gate for deprecated 0.16.x adapter. - // code-reviewer fix-round: codex readiness gate tests — 2 new tests for - // outdated-adapter and garbage-version-output paths through the codex id gate - // (+140 lines: make_codex_runtime helper, PATH_MUTEX serializer, 2 test fns). - // Load-bearing test coverage; queued to split with the file generally. - // +1: pub(crate) mod cli_probe declaration for doctor auth probe access. - // +3: auth_probe_args: None + login_hint: None added to make_cli_runtime and - // make_codex_runtime stubs (new KnownAcpRuntime fields). - // Git Bash readiness is intentionally colocated with buzz-agent's other - // setup-mode requirements. The Windows-only requirement and serialization - // test add eight lines; split remains queued with the existing file debt. - // Windows Doctor install fix: cli_install_commands_windows field added to test stubs. - // team-instructions-first-class: ManagedAgentRecord fixture gains the new - // team_id field (+1 line). - ["src-tauri/src/managed_agents/readiness.rs", 1863], - // Windows PATH-correctness fix: 3 #[cfg(windows)] test functions covering - // .cmd shim rejection, .bat shim rejection, and .exe acceptance for - // configure_runtime_cli (fix #2397). Test-only growth; queued to split. - // +7 (main): this PR's resolver tests land on top of main's #2397 Windows - // shim tests, plus main's restart_eligible orphan-gate tests. - // +34: BYOH custom-harness sweep condition unit tests — 3 tests validating - // the OR-gate fix for custom-binary orphan cleanup. - // +26: BYOH pass-2 I3 — 2 collector-decision tests for receipt path - // ownership (valid_agent_runtime_receipt uses buzz_sweep_owns_process). - ["src-tauri/src/managed_agents/runtime/tests.rs", 1320], - // runtime.rs re-entered the list after the #1968 merge: main's - // definition-authoritative resolver comments grew it to 982, and the BYOH - // typed harness-descriptor resolution in spawn_agent_child landed on top at - // 1020. The session-title env write in spawn_agent_child adds 12. - // Queued to shrink with the next runtime split pass (#2974 follow-up). - // +1: #3023 credential-helper slash normalization (MinGW bash treats - // backslashes as escapes). - ["src-tauri/src/managed_agents/runtime.rs", 1033], - // applyWorkspace reposDir parameter plus the validateReposDir binding, - // threaded through Tauri invokes for configurable repos_dir, plus the - // harness-persona-sync `harnessOverride` create-input bit — load-bearing - // parameter plumbing, not generic debt growth. Approved override; still - // queued to split. Read-path lanes 1+2 add server-side fetch bindings - // (getThreadReplies + getChannelMessagesBefore) and paged people-search - // reachability — load-bearing reachability plumbing, not generic debt. - // #1418 read-path fix: +3 doc-only lines correcting the getThreadReplies - // contract (replies-only, root excluded — the query keys on root_event_id, - // which root rows lack). Documentation accuracy, not code growth. - // linux-updater isAutoUpdateSupported() binding + onboarding has_profile_event field. - // config-bridge-aware requirements: getRuntimeFileConfig command adds ~15 lines. - // +26 lines from PRs landing on main between prior rebase and this rebase. - // baked-env-required-badge: getBakedBuildEnvKeys wrapper adds ~16 lines. Queued to split. - // restart-badge: started the queued split — start/stopManagedAgent moved to - // tauriManagedAgents.ts; limit ratcheted down 1388 → 1380 to bank the headroom. - // identity-import-keyring: identity wrappers (RawIdentity, getIdentity, getNsec, - // importIdentity, persistCurrentIdentity) moved to tauriIdentity.ts; - // limit ratcheted down 1380 → 1360 to bank the headroom (absorbs main-side - // growth landed between the split and the rebase). - // mention-alias fix: profile wrappers (RawProfile/RawUserProfileSummary types, - // getProfile/updateProfile/getUserProfile/getUsersBatch/searchUsers) moved to - // tauriProfiles.ts; limit ratcheted down 1360 → 1241 to bank the headroom. - // baked-env fold-in: getBakedBuildEnv + BakedEnvEntry type adds ~28 lines. - // doctor-npm-eacces-preflight: hint field on RawInstallStepResult + mapper - // passthrough (+2 lines). - // doctor-install-reliability: node_required + auth_status + login_hint fields - // added to RawAcpRuntimeCatalogEntry + fromRawAcpRuntimeCatalogEntry mapper (+8). - // codex-install-auto-restart: restarted_count + failed_restart_count added to - // RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2). - // Git Bash Doctor discovery adds the raw Tauri response and its camelCase - // mapper. This is the existing API boundary; split remains queued. - // team-instructions-first-class: createManagedAgent Tauri bridge threads the - // new teamId input through to the backend (+1 line). - // +2 for model_source field in RawManagedAgent + fromRawManagedAgent mapping. - ["src/shared/api/tauri.ts", 1307], - // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). - // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). - // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ - // loginHint fields on AcpRuntimeCatalogEntry (+14 lines). Load-bearing new feature. - // agent-lifecycle-fixes: GlobalAgentConfigSaveResult type grows with - // failed_restart_count (+2 lines). Queued to split with the rest of this list. - // mcp-readonly-view rebase: PR2 MCP config surface FE-type fields force +1 over the grandfathered ceiling. - // Git Bash prerequisite payload adds four fields to the shared Tauri API - // contract. This is the canonical type location; split remains queued. - // signout-wipe: resetFailed field added to Identity type (+6 lines). - // team-instructions-first-class: CreateManagedAgentInput.teamId (+2, incl. - // doc comment) and AgentTeam/CreateTeamInput/UpdateTeamInput.instructions - // (+3) — the new team-id spawn link and the runtime-layered instructions - // field. - // byoh-env-roundtrip: AcpRuntimeCatalogEntry.definitionEnv field + JSDoc - // (+12 lines) so the edit form can read back existing env vars on save. - // Load-bearing correctness fix. Queued to split. - // +2: AcpRuntimeCatalogEntry.requiresExternalCli field added by main - // (#2680) to indicate runtimes that need a separate CLI install. - // +6: ManagedAgent.runtime record-level pin + JSDoc so the harness delete - // confirmation can count referencing agents (review fix for #2773). - // +21: CatalogSourceCoordinate + the `catalogSource` fields on AgentPersona - // and CreatePersonaInput. The coordinate is the only identifier a catalog - // copy keeps, so it is what stops the catalog re-offering "Add" for an - // already-added foreign entry. Queued to split. - ["src/shared/api/types.ts", 1079], - // harness-persona-sync feature growth, queued to split in the resolver-unify - // refactor followup. discovery.rs is dominated by the new test module - // (the effective_agent_command / divergent / create-time override matrix); - // alias-preservation coverage extends that matrix so create-time persona - // agents keep an installed runtime alias when the primary command is absent. - // Load-bearing, not generic debt. - // config-bridge: schema-driven field extraction adds ~26 lines. Queued to split. - // config-parity: max_tokens_env_var + context_limit_env_var fields added to - // KnownAcpRuntime (2 fields × 4 runtimes + discovery tests = ~13 lines). - // Load-bearing — required for buzz-agent normalized config parity. - // same-runtime-pin: update_time_agent_command_override + its override / - // same-runtime / alias / sentinel / non-override / persona-less test matrix - // (~135 lines, mostly tests) so a deliberate Custom pin survives the update - // path instead of being dropped back to inherit. Load-bearing, not debt. - // unified-agent-model 1A.1: inline test module moved to discovery/tests.rs, - // ratcheting 1259 -> 802 (under the 1000 default; entry kept as a ratchet). - // agent-config-propagation: the agent_command_override decision family - // (divergent / create-time / update-time / apply) moved to - // discovery/overrides.rs; ratcheting 802 -> 685 to bank the headroom. - // codex-acp-package-swap: probe_codex_acp_major_version (+24 lines) + - // AdapterOutdated version-gate in discover_acp_runtimes (+22 lines). Both - // load-bearing — required to detect the deprecated 0.16.x adapter and - // prevent silent relay breakage after the spawn-contract change. - // codex-acp-package-swap follow-up: tempfile-based bounded stdout read - // (+18 lines), codex_adapter_availability/is_outdated helpers (+16 lines), - // cross-platform probe contract. All load-bearing — required for correct - // probe behaviour on Windows and descendant-process edge cases. - // doctor-install-reliability: refreshable login_shell_path cache, - // find_nvm_default_bin + parse_semver_tag helpers, auth probe cache + - // probe_auth_status/cached_auth_status, runtime_needs_npm, probe_args_for, - // PartialEntry struct, and updated discover_acp_runtimes with parallel auth - // probes. Load-bearing fresh-install reliability fixes. (+289 lines) - // doctor-install-reliability review fixes: LoginShellPath enum + double-checked - // locking, is_safe_nvm_tag security validation, classify_probe_output helper, - // auth_probe_args on KnownAcpRuntime (removes probe_args_for indirection), - // process-level timeout replacing inner-thread pattern. (+75 lines) - // codex-install-auto-restart review-fixes: availability_drift pure predicate - // + updated adapter_availability_cached() signature (Option return, cold=None) - // prevents false restart badge on newly restarted agents. Correctness fix; - // load-bearing — required by Thufir's IMPORTANT findings. (+15 lines) - // Windows Doctor install fix: cli_install_commands_windows field, impl block - // for cli_install_commands_for_os(), command_basenames() + .cmd/.bat resolution, - // Windows well-known dirs in common_binary_paths(), login_shell_candidates(), - // path_candidates_from_env_raw(). Load-bearing Windows platform support. - // +13: fetch_login_shell_path_inner Windows guard (POSIX PATH → None). - // resolve_git_bash made pub(crate) for Windows test access. - // +1: login_shell_candidates doc comment expanded for resolve_bash_path. - // Buzz-managed Node path helpers and resolution tests moved to - // managed_node_paths.rs and discovery/tests/managed_path_resolution.rs; - // ratcheting 1366 -> 1392 after adding the managed-path probes to discovery. - // +17: BYOH custom harness catalog merge phase-3 — append custom definitions - // from custom_harnesses_dir with PATH-probe availability; source tagging. - // +148: BYOH F2/F3 — PRESET_HARNESSES static data (6 presets), Phase 2.5 in - // discover_acp_runtimes_from (PATH-probe each preset, build catalog entries, - // populate loaded-harness registry), record/effective command resolution now - // checks loaded registry for preset/custom ids. Queued to split presets out. - // +3: BYOH F5 — seen_ids rejects preset/builtin collisions from custom files. - // +79: BYOH pass-2 C1 — 4 registry lifecycle tests (warm→spawn, delete→ - // dangling, immediate save+start, edit with rename); try_record_agent_command - // typed error for dangling ids wired into spawn; readiness/spawn_hash now - // include definition env floor. - // +7: BYOH pass-2 I2 env round-trip — definition_env field populated in - // custom catalog entries + 2 discriminating tests (custom env preserved, - // builtin env empty). Load-bearing edit round-trip fix. - // +16: BYOH scope addition — Hermes Agent + OpenClaw preset entries (two - // data-only PresetHarness structs; no new logic or test functions). - // +29: rebase over main (#2680) — discover_acp_runtime_phase1 extracted - // helper + discover_acp_runtime_availability; both load-bearing for - // post-install verification. Semantic composition with BYOH changes. - // +17: merge of main (#2767) — codex_adapter_is_outdated_with_path split out - // so Codex adapter planning takes an explicit PATH. Auto-merged cleanly; only - // the ceiling needed composing with the BYOH growth above. - // +13: review fix for #2773 — discovery publishes the registry by re-reading - // the harness dir under persist_mutex (publish_harness_registry_from_dir call - // + doc comment), closing the stale-snapshot clobber race. - // +35: review round 2 (#2773) — cfg(test) pre_publish_test_hook seam so the - // stale-publish regression is pinned through the REAL discover_acp_runtimes_from - // path (Wren's finding: the seam-only tests stayed green under a stale-publish - // mutant). Test-only code, zero release-build footprint. - // +55: #2773 follow-up — PresetHarness.underlying_cli (Amp's amp-acp wraps - // the amp CLI) + preset_catalog_entry helper: adapter presence alone keeps - // deciding Available (adapter-present/CLI-absent stays selectable, Wren's - // regression catch); underlying_cli is consulted only when the adapter is - // absent, so AdapterMissing replaces the misleading NotInstalled. Includes - // the deliberate-divergence doc comments; net after the inline preset - // entries.push block collapsed into the helper. - // +6: legacy Goose Windows install dir (%USERPROFILE%\goose) probed in - // common_binary_paths so pre-#2680 standalone installs are discoverable. - // +19: codex-acp minimum-version gate — MIN_CODEX_ACP_VERSION plus the strict - // three-component parse in probe_codex_acp_version, so an outdated 1.x adapter - // is offered a reinstall instead of classifying as Available on major alone. - ["src-tauri/src/managed_agents/discovery.rs", 1860], - // BYOH — save_custom_harness_to_dir (backup-swap atomic write) + save_and_warm / - // delete_and_warm (persist-mutex serialization for concurrent-safe registry - // refresh, B-6). Also: id/collision/load/registry tests (from the file base) + - // B-4 real persistence tests (create, same-id edit, rename, backup cleanup) + - // B-3 env validation boundary tests (malformed key, reserved shape, NUL, - // size limit, ownership marker). Load-bearing correctness/security coverage; - // queued to extract helper module once the feature stabilizes. - // +153: review fix for #2773 — collision/dup filtering moved into - // load_custom_harnesses so warm + discovery inherit identical shadowing - // rules, publish_harness_registry_from_dir (mutex-scoped publish seam), and - // comma-in-args validation at validate_harness_definition, with tests. - // +34: review round 2 (#2773) — Dawn's mutation finding: the loader-boundary - // collision/dedup enforcement was untested (deleting it left the suite green). - // load_applies_id_collision_check now drives the real loader against a real - // shadowing file, plus a dedup twin; both verified to kill the mutants. - ["src-tauri/src/managed_agents/custom_harnesses.rs", 1232], - // rebase over codex-acp-package-swap: its version-probe tests union with the - // doctor-install-reliability nvm/login-shell/semver tests — each side alone - // stayed under the 1000 default; the union exceeds it. - // Windows Doctor install fix: command_basenames, cli_install_commands_for_os, - // and login_shell_candidates tests. Load-bearing platform-awareness coverage. - // +132: pass 2 — five cfg(windows) behavioral tests: command_basenames .cmd/.bat - // candidates, cli_install_commands_for_os PowerShell selection, login_shell_path - // None regression, .cmd shim resolution, no-git-bash error hint. - // +32: deterministic .cmd resolver + no-registry + install_shell_from tests. - // Managed-path resolution test split to discovery/tests/managed_path_resolution.rs. - // +227: BYOH pass-2 C1 — 4 registry lifecycle tests (warm→spawn, delete→dangling, - // immediate save+start, edit with rename) added to discovery/tests.rs. - // +64: BYOH pass-2 I2 env round-trip — 2 discriminating tests proving custom - // catalog entries carry definition_env and builtins do not. - // +90: review fix for #2773 — deterministic interleaving regressions for the - // discovery publish race (save-during-discovery survives publish; - // delete-during-discovery stays gone). - // +103: review round 2 (#2773) — production-path interleaving regressions: - // discovery_publish_path_survives_mid_flight_save / _drops_mid_flight_delete - // drive the real discover_acp_runtimes_from with a save/delete landed via the - // pre_publish_test_hook; verified to red under a stale-publish mutant. - // +18: flake fix — lock_path_mutex + registry_test_lock guards (with lock- - // order comments) on the four tests that drive discovery's global caches. - // +84: #2773 follow-up — preset_catalog_entry coverage (Amp-shaped adapter - // preset: AdapterMissing when CLI present, NotInstalled both-missing, - // Available both-present AND adapter-present/CLI-absent — the selectability - // regression guard), bound to an injectable resolver so the tests stay - // PATH-independent. - // +51: codex-acp minimum-version gate — probe_codex_acp_version assertions carry - // the full (major, minor, patch) triple instead of a bare major, plus - // below-the-floor and uncomparable-version (partial / prerelease) classification - // regressions for the fail-closed parse. - // +2 (1922 -> 1924): the AgentDefinition and ManagedAgentRecord fixtures each - // set the new mandatory `catalog_source` field. - ["src-tauri/src/managed_agents/discovery/tests.rs", 1924], - // identity-import-keyring: the identity resolution state machine's behavioral - // matrix (46 tests over FakeIdentityStore — probe × marker × file cells, - // adoption / read-back-corruption / marker-failure arms, recovery-mode - // gating). Load-bearing regression coverage for silent identity rotation, - // not generic debt growth. Approved override; split if the matrix grows. - ["src-tauri/src/app_state_tests.rs", 1420], - // migration_tests.rs carries the harness-sync migration coverage plus the - // patch_json_records owner-only writeback regression test (SECURITY.md:90 - // crash-safe 0o600 fallback). Load-bearing security + feature coverage, not - // generic debt growth. Approved override; still queued to split. Event-sync - // (persona/team event reconcile) tests were split out to event_sync_tests.rs - // and the limit ratcheted 1410 → 1110. - // unified-agent-model 1A.1: materialize tests live with their module in - // migration/materialize.rs; ratchet held at 1110. - ["src-tauri/src/migration_tests.rs", 1110], - ["src-tauri/src/nostr_convert.rs", 1126], - // degraded-network resilience: relay.rs grew past 1000 with the addition of - // relay_error_message hint-capping (oversized-hint test via loopback TCP) and - // the relay_admission freshness-verification test. The loopback mock was - // hardened (std::net + request-read-before-write) adding ~10 lines. - // Queued to split test helpers to relay/tests.rs. - // +30 (1047 -> 1077): agents-everywhere pair re-key — query_relay_at_with_keys - // (NIP-98 signed /query with explicit agent keys + optional x-auth-tag) for - // bounded-auth agent relay-membership discovery. Load-bearing; queued to - // split alongside the test-helper split. - ["src-tauri/src/relay.rs", 1077], - // degraded-network resilience: visibleChannelId field + getter/setter, NOTICE - // handler for relay back-pressure, and rate-limit gate imports add ~74 lines - // of load-bearing degraded-network recovery code. Queued to split. - ["src/shared/api/relayClientSession.ts", 1096], - // Boot-time event sync (persona/team/agent event reconcile) was split out - // to event_sync.rs, ratcheting this limit 1575 → 1310. Remaining content is - // the pre-identity data migrations; still queued to split further. - // unified-agent-model 1A.1: materialize_agent_runtimes split to - // migration/materialize.rs, ratcheting 1310 -> 1297. - // databricks-v1-to-v2-migration: reconcile_databricks_v1_to_v2 migration - // + inner fn with baked-env gate + 26 tests. Load-bearing correctness fix. - // am review fix: also clear stale V1 model field on provider rewrite + - // new model-clear test. Load-bearing chimera fix. - // keyring-dev-isolation: run_boot_migrations wires agent-key migration. - ["src-tauri/src/migration.rs", 1436], - // onMarkRead + isUnread prop threading (mirrors the onMarkUnread prop - // already here) for the single-toggle mark-read/unread menu item — a small - // overage from load-bearing per-message plumbing, not generic debt growth. - // Approved override; still queued to split with the rest of this list. - ["src/features/messages/ui/MessageThreadPanel.tsx", 1006], - // AgentConfigPanel footer fold into ProfileFieldGroup for the config-bridge - // panel — a small overage from load-bearing UI plumbing, not generic debt - // growth. Approved override; still queued to split with the rest of this list. - // +135 for AgentInfoFocusedView/DiagnosticsFocusedView/ChannelsFocusedView - // props restored after 826d735fe removal (UserProfilePanel.tsx still needs them). - ["src/features/profile/ui/UserProfilePanelSections.tsx", 1140], - // +14 for openEditAgent event subscription (config-nudge card "Open Edit Agent" action). - // +11 for editAgentFocus state + initialFocus prop threading (deep-link granularity). - ["src/features/profile/ui/UserProfilePanel.tsx", 1025], - // PersistBackend enum + marker-on-keyring-success plumbing and its three - // fail-closed regression tests (silent identity rotation on keyring outage). - // A small overage from load-bearing security plumbing on a file already at - // 893 lines, not generic debt growth. Approved override; still queued to split. - // cross-process keychain race fix (D3): interprocess lock + BlobLockGuard + - // uid-keyed lockfile path + behavioral tests add ~303 lines. Load-bearing - // security fix for the lost-update race that stranded agent keys. - // identity-import-keyring: KeyringLockedScreen, RecoveryScreen, - // load_readonly + load_all_readonly + store_all for safe cross-service reads. - // sign-out wipe: delete_all() method removes the entire keychain blob under - // the interprocess advisory lock; +8 lines. Load-bearing; queued to split. - // signout-wipe phase 2: delete_all_with_legacy_cleanup replaces delete_all; - // reads blob keys + deletes per-key legacy entries to prevent resurrection. - // + regression test for per-key resurrection via real OS keychain. - // Net growth ~36+32 lines over prior cap. Load-bearing correctness fix. - // signout-wipe pass-2 (F2): delete_all_with_legacy_cleanup DPK deletes now - // observable (propagate real errors); verify_fully_wiped checks all three - // keychain shapes (main blob, DPK blob, per-key "identity"). +73 lines. - ["src-tauri/src/secret_store.rs", 1307], - // keyring-dev-isolation: keyring_service() fn (7 lines) replaces the const - // to return "buzz-desktop-dev" in debug builds. Load-bearing isolation fix. - // +10 (1042 -> 1052): media_fetch_client with redirect::Policy::none() so a - // relay 3xx cannot forward the minted auth header cross-origin (SSRF fix). - // +16 (1052 -> 1068): extracted that client into `build_media_fetch_client()` - // -> Result so the fail-closed invariant is testable (no silent redirect- - // following fallback; startup panics loudly instead). The function belongs - // here beside `build_app_state` and its sibling client; its doc comment - // carries the load-bearing SSRF rationale. Extraction would only relocate, - // not reduce, the security-critical code. - // +5 (1068 -> 1073): merge with main, which independently added the - // managed_agent_profile_reconcile_enabled flag (field + doc + init) under - // its own 1042-line override. Union of two separately approved additions. - // +8 (1073 -> 1081): agents-everywhere pair re-key — managed_agent_processes - // and session_config_cache re-keyed by ManagedAgentRuntimeKey, the runtime - // transition lock doc broadened to cover all protected-PID transitions, and - // clear_agent_session_caches (per-pubkey retain) added alongside the - // per-key clear. Load-bearing identity-contract change; queued to split. - // +4 (1081 -> 1085): mesh recovery keeps one app-scoped state object beside - // the embedded runtime and coordinator. Probe/re-arm logic lives in - // mesh_llm/recovery.rs rather than growing AppState or command modules. - ["src-tauri/src/app_state.rs", 1085], - // multi-slot splitting + no-op suppression (#1309): the ReadStateManager - // class grew from ~700 lines to ~1019 with the addition of - // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, - // publishOneSlot, deleteExtraSlots, and the no-op suppression integration - // test. Load-bearing feature growth, queued to split publishSplitSlots path - // into readStateManagerSplit.ts. - ["src/features/channels/readState/readStateManager.ts", 1030], - // review feedback on #1492 restored the two-line load-bearing comment - // documenting why `lastMessageAt` must not be an `activeReadAt` fallback - // (reply-inclusive; would clear unread state early). The file was already - // at the 1000 ceiling; comment-only overage, not code growth. Queued to - // split with the rest of this list. - // member-agent-flags: messageProfiles merge + ref stabilisation split out to - // useMessageProfiles.ts, ratcheting 1002 -> 972 (under the 1000 default; - // entry kept as a ratchet). +7 rebase onto main (#1698 timeline-window - // growth), 972 -> 979. - ["src/features/channels/ui/ChannelScreen.tsx", 979], - // forced-unread persistence: markChannelUnread now writes through to - // forcedUnreadStore (localStorage) so the sidebar badge survives reload and - // the rail observer can read it. Three clear points added (markChannelRead, - // markAllChannelsRead, drainSyncedAdvances). Load-bearing fix, not generic - // debt growth. Queued to split with the rest of this list. - ["src/features/channels/useUnreadChannels.ts", 1022], - // Shared UI was added to this guard after splitting globals/markdown so - // large shared renderers cannot grow further while follow-up splits land. - // +33 for config-nudge detect-and-render + author-auth gate (normalizePubkey guard). - ["src/shared/ui/markdown.tsx", 2152], - // +15 (2199 -> 2214): the video right-click Download/Copy menu's props, - // hook wiring, and render slot. The stateful menu logic (~52 lines) was - // extracted to useVideoContextMenu.tsx; what remains here is the component's - // public interface (downloadUrl/filename props) and cannot move out. - ["src/shared/ui/VideoPlayer.tsx", 2214], - ["src/shared/ui/sidebar.tsx", 1042], - // permission-outcome (fix #1381 regression): pendingPermissions state map, - // describePermissionOutcome helper, jsonRpcId key helper (handles both - // string and finite-number JSON-RPC ids per spec), and the acp_write - // response correlation branch are all tightly coupled to the existing - // request handler. Load-bearing logic growth, not generic debt. Queued to - // split into a dedicated permission module in the next transcript refactor. - // +123: observer parity — 4 new named session/update classifier cases - // (current_mode_update, usage_update, available_commands_update, - // config_option_update) + replaceLifecycleItem helper for usage coalescing + - // system-prompt ordering fix (turnId: null for per-channel items). - // +35: session/new reposition-on-refire fix — removeItem helper + - // upsertMetadata restart branch (remove+sealOpenMessages+push instead of - // replaceItem in-place) so system-prompt anchor moves to stream tail. - // Load-bearing feature growth; queued to split in next transcript refactor. - ["src/features/agents/ui/agentSessionTranscript.ts", 1202], - // catalog module; agent_models.rs retains the thin wrapper (~50 lines). - // File still exceeds 1000 due to OpenAI/Anthropic discovery + subprocess - // fallback. Queued to split into dedicated discovery modules. - // Kept activity-feed design fixture: realistic prompt context and tool-heavy - // chatter for render-class test/reference coverage. Queued to split with the - // rest of this list if it grows further. - // +2: baked build env folded under merged_env in both get_agent_models and - // discover_agent_models so in-process discovery sees baked provider config on - // a GUI-launched DMG (the discovery_env_with_baked_floor fold). - // +3: provider tri-state applied in update_managed_agent handler - // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). - // +8: harness_override thread-through in update_managed_agent so a deliberate - // Custom pin routes to update_time_agent_command_override (comment + call). - // +22 (1079 -> 1101, main): Finding 2 — model discovery now resolves through - // resolve_effective_model_provider instead of raw record bytes, plus - // apply_model_provider_prompt_update's linked-instance write-guard - // extraction and its regression tests. - // +4 (1101 -> 1105): rebase onto agents-everywhere — agents.rs function - // signatures updated for ManagedAgentRuntimeKey-keyed runtimes map. - // +1 (#2773): model_discovery_error helper routes dangling-harness - // resolution errors through user_facing_harness_error (sentence, not raw - // DANGLING_HARNESS_ID sentinel) for the get_agent_models surface. The PR's - // descriptor path also deletes saved_agent_model_discovery_config, whose - // callers now use resolve_effective_model_provider + the descriptor env - // directly (net wash after the merge of the deltas above). - // +38 (1114 -> 1152): agent_model_discovery_config extracted as a pure, - // test-bindable seam (struct + helper + docs) so the linked-agent - // regression test kills the stale-record mutation at get_agent_models' - // consumption point (review finding, Wren + Dawn). - ["src-tauri/src/commands/agent_models.rs", 1152], - // global-agent-config: get_agent_config_surface / write_agent_config_field / - // put_agent_session_config commands + GlobalAgentConfig serde types. New file - // in this PR; queued to split with the command module refactor. - // +17: baked-env-global-unify: BUZZ_AGENT_THINKING_EFFORT added to - // is_safe_to_reveal allowlist + baked_env_thinking_effort_is_unmasked test. - // +1: doctor-install-reliability: login_hint: None added to goose_runtime test stub. - // +1: doctor-install-reliability review fixes: auth_probe_args: None added to stub. - // +11 (1021 -> 1032): agents-everywhere pair re-key — session-cache reads in - // get_agent_config_surface derive the ManagedAgentRuntimeKey (relay-URL - // fallback resolution) and put_agent_session_config gains a relay_url param. - // Load-bearing identity plumbing; queued to split. - // +18 (1032 -> 1050): review fix — put_agent_session_config reads the pair - // relay from the harness-attached payload relayUrl (with effective-relay - // fallback for older harnesses) instead of a required arg the frontend - // wrapper never passed, which silently broke the session-config cache. - // +60 (1050 -> 1110): agent-config-resolver — resolve_config_surface now - // clears a linked instance's own system_prompt/model/provider before - // computing had_* so stale materialized snapshot bytes can never be tagged - // BuzzExplicit and shadow the definition/global fallthrough; the dead - // persona-model re-tag branch replaced; two new regression tests added. - // +2 (1110 -> 1112): the agent_record and persona_with_model test fixtures - // each set the new mandatory `catalog_source` field. - ["src-tauri/src/commands/agent_config.rs", 1112], - // codex-install-auto-restart review-fixes: should_restart_after_install - // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy - // cache tests replaced with 6 pure availability_drift predicate tests; - // dead-pid non-happy-path added. All load-bearing correctness fixes. - // (+17 lines net vs previous 1330 limit; rustfmt expanded some call sites) - // Git Bash Doctor discovery exposes a narrow async Tauri command at the - // existing discovery boundary. The ten-line addition preserves the platform - // neutral frontend contract; split remains queued. - // Windows Doctor install fix: resolve_install_shell() + install_shell_command() - // returns Result (Windows Git Bash resolution, CREATE_NO_WINDOW, taskkill timeout - // kill), cli_install_commands_for_os() callsite, unit tests for shell selection - // and per-OS install command accessor. Load-bearing Windows platform support. - // +53: pass 2 — three cfg(windows) install shell tests (resolve succeeds with - // Git, error hint content, install_shell_command succeeds). - // +8: install_shell_from pure seam extracted for deterministic testing. - // +287: is_powershell_command + install_powershell_command + build_install_command - // route PowerShell CLI installs natively on Windows (bypasses Git Bash PATH - // poisoning that resolved GNU tar instead of bsdtar → Codex install failure). - // Includes unit tests for detection, routing, and -Command body preservation. - // +16: test_powershell_command_goose_catalog_dequoted proves the \$→$ escape - // fix for the Goose Windows installer (PR #2680 interaction with #2750). - // +10: pass an explicit PATH through Codex adapter install planning so unit - // tests avoid the process-global login-shell PATH cache. - // +59 (main): run install commands under `pipefail` so a failing `curl` in a - // `curl … | bash` install fails the `cli` step instead of being masked by - // `bash`'s exit 0, plus tests for the arg shape and the real pipeline status. - // +81 (main): install_shell_args re-exports the composed PATH inside the command - // body so login startup files can't clear or reorder it, plus an isolated - // hostile-profile regression the pure composition tests structurally miss. - // +42 (main): gate that re-export off Windows, where join_paths is `;`-separated - // and bash would collapse it into one entry, plus a platform-shape test. - // +126 (#2773): BYOH — save_custom_harness (validate, atomic write, return - // entry) + delete_custom_harness (id-guard, builtin reject, remove file) - // commands; discover_acp_providers updated to pass AppHandle + - // custom_harnesses dir. - // +30: BYOH F5 — atomic-write-file dep, original_id rename/delete support. - // +13: BYOH pass-2 C1 — warm_harness_registry_from_dir call in save and - // delete commands now verifies transactional registry refresh. - // +2: BYOH pass-2 I2 env round-trip — definition_env carried through save - // return value so the frontend immediately has the updated env. - // +1: rebase over main (#2680) — requires_external_cli: false added to - // save_custom_harness catalog entry construction (new required field). - // -359: install command execution (spawn, output drain under timeout, retry - // with backoff, output truncation) extracted to agent_discovery/install_exec.rs - // alongside its tests, matching the managed_node.rs / post_install_verification.rs - // split. The entries above describe the file's history, not its current shape. - // +27: codex-acp minimum-version gate — test_plan_adapter_install_updates_older_ - // 1x_codex_binary pins that a 1.x adapter below the floor still plans a reinstall. - ["src-tauri/src/commands/agent_discovery.rs", 1835], - // draft-persistence predicate: submit-time `loadDraft` check + inline comment - // + deps-array entry in submitMessage closes the never-persisted-boundary - // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to - // split MessageComposer into submit/edit/media sub-modules. - // +18: pendingImetaForPersistRef (local snapshot ref) + synchronous restore - // path writes in the draft-key effect body, fixing the image-drop bug on - // top-level nav switch (StrictMode simulate-unmount race on remount). - // +12 autoSubmitDraftKey/onAutoSubmitComplete props + onAutoSubmitCompleteRef - // + mount-only useEffect for the Drafts-panel "Send message" confirm-dialog - // flow. Load-bearing feature growth; queued to split with the rest of this - // list. - // +3: onLinkShortcutRef wiring (ref decl + editor option + assignment) for - // the ⌘K link-editor shortcut, mirroring the existing onEditLinkRef - // pattern. Queued to split with the rest of this list. - // +35: persistent audience scope/hook wiring and chip component handoff. The - // chip markup lives separately; remaining lines connect existing composer - // send state to the audience store. Queued with the existing split. - // +23: edit-to-add-mention notify (8ace8eed) — onEditSave/edit-branch - // mentionPubkeys threading + two snapshot refs (extractMentionPubkeys, - // ownerPubkey) feeding the newly-added-mentions diff. Diff logic itself - // lives in threading.ts (diffAddedMentionPubkeys); this is the minimal - // composer-side wiring. Queued to split with the rest of this list. - ["src/features/messages/ui/MessageComposer.tsx", 1114], - // global-agent-config: model-tuning section (BuzzAgentModelTuningFields via - // EditAgentAdvancedFields) + providerValid gate + effectiveProvider derivation - // + globalProvider threading into getPersonaProviderOptions. All load-bearing - // feature logic; queued to split with the rest of this list. - ["src/features/agents/ui/EditAgentDialog.tsx", 1088], - // global-agent-config rebase over #1639: AgentInstanceEditDialog (renamed from - // EditAgentDialog by #1639) gained initialFocus?/EditAgentFocusTarget prop - // threading from the deep-link focus feature, and isEditAgentProviderSaveValid - // extracted as a testable helper with originalRuntimeSupportsProvider to close - // the runtime-switch hole in Will's (b) providerValid gate narrowing. - // E2E-fix round: added globalProvider fallback to useRequiredCredentialState - // call site and buzz-agent auto-expand effect for model-tuning knob visibility. - // F1-fix: added globalEnvVars to useRequiredCredentialState so globally-satisfied - // credential keys are excluded from requiredEnvKeyMissing (display/gate parity). - // Feature logic, not generic debt. Approved override; still queued to split. - // +23 rebase onto #1667: behavioral quad fields (respond_to/parallelism/toolsets) - // plumbed through AgentInstanceEditDialog from PersonaAdvancedFields. - // +2 provider-aware effort: model/provider props threaded to BuzzAgentModelTuningFields. - // +15 provider/model dropdown fixes: useBakedBuildEnvKeysQuery + hideProviderIds - // for Databricks v1 gate; prospectiveRuntimeId default fallback for builtins. - // PR-B moves default/API-key derivation into shared hooks; the explicit - // hidden-key projection keeps the top-level secret out of Advanced rows. - // +6 (1195 -> 1201): rebase onto main — this PR's model-source label wiring - // lands on top of main's dialog growth. Queued to split. - // +28 (1201 -> 1229): inline "Add custom harness…" entry — sentinel option, - // modal state, and the AddCustomHarnessDialog mount. The shared routing and - // deferred-selection logic lives in addCustomHarness.ts to keep this minimal. - ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1229], - // AgentDefinitionDialog grew past 1000 with the following load-bearing fixes: - // isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models); - // runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix); - // hideProviderIds computation for Databricks v1 gate. Queued to split. - // +28 (1020 -> 1048): inline "Add custom harness…" entry — sentinel option, - // modal state, and the AddCustomHarnessDialog mount. The shared routing and - // deferred-selection logic lives in addCustomHarness.ts to keep this minimal. - ["src/features/agents/ui/AgentDefinitionDialog.tsx", 1048], - // #2630 emoji picker search: the shadow-root search-input autofocus effect - // (rAF retry loop) took this file 999 -> 1026 and landed without this entry, - // so main's Desktop Core went red. Queued to split with the rest of this list. - ["src/features/agents/ui/AgentCreationPreview.tsx", 1026], -]); - await runFileSizeCheck({ projectRoot, rules, - overrides, label: "Desktop", - scriptPath: "desktop/scripts/check-file-sizes.mjs", }); diff --git a/mobile/scripts/check-file-sizes.mjs b/mobile/scripts/check-file-sizes.mjs index a3af9c054b..765cd8edcf 100644 --- a/mobile/scripts/check-file-sizes.mjs +++ b/mobile/scripts/check-file-sizes.mjs @@ -15,16 +15,8 @@ const rules = [ }, ]; -// TEMP — these files exceed the 1000-line limit and are queued to be split. -// Do not add to this list; split the file instead. Remove each entry as its -// file is broken up. -const overrides = new Map([ -]); - await runFileSizeCheck({ projectRoot, rules, - overrides, label: "Mobile", - scriptPath: "mobile/scripts/check-file-sizes.mjs", }); diff --git a/scripts/check-file-sizes-core.mjs b/scripts/check-file-sizes-core.mjs index 0da7994756..1365424628 100644 --- a/scripts/check-file-sizes-core.mjs +++ b/scripts/check-file-sizes-core.mjs @@ -1,114 +1,174 @@ +import { execFileSync } from "node:child_process"; import { promises as fs } from "node:fs"; import path from "node:path"; -/** - * Shared file-size check used by the desktop and web workspaces. - * - * Each app supplies its own `rules` (which roots/extensions to scan) and an - * optional `overrides` map of TEMP per-file ceilings. Everything else — the - * walk, the line count, the violation report, the non-zero exit — lives here so - * the two apps can never drift. - */ - -// `rules[].root` and the `overrides` keys are authored with `/`, but -// path.relative yields `\` on Windows — so every comparison against them has -// to happen in posix form or it silently matches nothing. +function git(args, cwd, options = {}) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + ...options, + }); +} + function toPosixPath(relativePath) { return relativePath.split(path.sep).join("/"); } -async function walkFiles(directory) { - const entries = await fs.readdir(directory, { withFileTypes: true }); - const files = await Promise.all( - entries.map(async (entry) => { - const fullPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - return walkFiles(fullPath); - } - - return [fullPath]; - }), - ); +export function countLines(content) { + if (content.length === 0) { + return 0; + } + return content.split(/\r?\n/).length; +} + +export function allowedLineCount(baseLines, maxLines) { + return baseLines == null || baseLines <= maxLines ? maxLines : baseLines; +} - return files.flat(); +export function evaluateFileSize({ baseLines, candidateLines, maxLines }) { + const limit = allowedLineCount(baseLines, maxLines); + return { limit, violates: candidateLines > limit }; } function findRule(rules, relativePath) { - const posixPath = toPosixPath(relativePath); - return rules.find((rule) => posixPath.startsWith(`${rule.root}/`)); + return rules.find((rule) => relativePath.startsWith(`${rule.root}/`)); } -function countLines(content) { - if (content.length === 0) { - return 0; +export function resolveBaseRef(repoRoot, env = process.env) { + if (env.CHECK_FILE_SIZES_BASE) { + return env.CHECK_FILE_SIZES_BASE; } - return content.split(/\r?\n/).length; + if (env.GITHUB_ACTIONS === "true") { + return "HEAD^1"; + } + + try { + const mergeBase = git( + ["merge-base", "origin/main", "HEAD"], + repoRoot, + ).trim(); + const head = git(["rev-parse", "HEAD"], repoRoot).trim(); + return mergeBase === head ? "HEAD" : mergeBase; + } catch (error) { + throw new Error( + "Could not resolve the file-size base from origin/main. Fetch origin/main or set CHECK_FILE_SIZES_BASE to an explicit commit.", + { cause: error }, + ); + } } -/** - * @param {object} options - * @param {string} options.projectRoot Absolute path the rule roots resolve against. - * @param {Array<{root: string, extensions: Set, maxLines: number}>} options.rules - * @param {string} options.label Human label for the failure header (e.g. "Desktop"). - * @param {Map} [options.overrides] TEMP per-file ceilings, keyed by path relative to projectRoot. - * @param {string} options.scriptPath Path mentioned in the failure hint where overrides live. - */ -export async function runFileSizeCheck({ - projectRoot, - rules, - label, - overrides = new Map(), - scriptPath, -}) { - const candidateFiles = ( - await Promise.all( - rules.map((rule) => { - const dir = path.join(projectRoot, rule.root); - return fs - .access(dir) - .then(() => walkFiles(dir)) - .catch(() => []); - }), - ) - ).flat(); +export function parseChangedFiles(output) { + const fields = output.split("\0"); + const changes = []; + + for (let index = 0; index < fields.length - 1; ) { + const status = fields[index++]; + if (status.startsWith("R") || status.startsWith("C")) { + changes.push({ + status: status[0], + oldPath: fields[index++], + path: fields[index++], + }); + } else { + changes.push({ status: status[0], path: fields[index++] }); + } + } + + return changes; +} + +function changedProjectFiles({ repoRoot, projectRelative, baseRef }) { + const output = git( + ["diff", "--name-status", "-z", "-M", baseRef, "--", projectRelative], + repoRoot, + ); + const changes = parseChangedFiles(output); + const trackedPaths = new Set(changes.map((change) => change.path)); + const untracked = git( + ["ls-files", "--others", "--exclude-standard", "-z", "--", projectRelative], + repoRoot, + ) + .split("\0") + .filter(Boolean); + + for (const filePath of untracked) { + if (!trackedPaths.has(filePath)) { + changes.push({ status: "A", path: filePath }); + } + } + return changes; +} + +function readBaseFile(repoRoot, baseRef, filePath) { + return git(["show", `${baseRef}:${filePath}`], repoRoot, { + encoding: null, + }).toString("utf8"); +} + +export async function runFileSizeCheck({ projectRoot, rules, label }) { + // Every governed project is a direct child of the repository root. Derive + // these paths without Git so hook-provided repository environment variables + // cannot collapse the project pathspec to an empty string. + const repoRoot = path.dirname(projectRoot); + const projectRelative = toPosixPath(path.basename(projectRoot)); + const baseRef = resolveBaseRef(repoRoot); + + // Fail clearly instead of silently turning a missing/shallow base into a pass. + git(["cat-file", "-e", `${baseRef}^{commit}`], repoRoot); const violations = []; + for (const change of changedProjectFiles({ + repoRoot, + projectRelative, + baseRef, + })) { + if (change.status === "D") continue; - for (const filePath of candidateFiles) { - const relativePath = path.relative(projectRoot, filePath); + const relativePath = toPosixPath( + path.relative(projectRelative, change.path), + ); const rule = findRule(rules, relativePath); - if (!rule) { - continue; - } + if (!rule || !rule.extensions.has(path.extname(relativePath))) continue; - const extension = path.extname(relativePath); - if (!rule.extensions.has(extension)) { - continue; - } + const candidatePath = path.join(repoRoot, change.path); + const candidateLines = countLines(await fs.readFile(candidatePath, "utf8")); + const basePath = change.oldPath ?? change.path; + const baseContent = + change.status === "A" ? null : readBaseFile(repoRoot, baseRef, basePath); + const baseLines = baseContent == null ? null : countLines(baseContent); + const result = evaluateFileSize({ + baseLines, + candidateLines, + maxLines: rule.maxLines, + }); - const limit = overrides.get(toPosixPath(relativePath)) ?? rule.maxLines; - const content = await fs.readFile(filePath, "utf8"); - const lineCount = countLines(content); - if (lineCount > limit) { + if (result.violates) { violations.push({ - limit, - lineCount, - relativePath: toPosixPath(relativePath), + relativePath, + baseLines, + candidateLines, + limit: result.limit, }); } } - if (violations.length > 0) { - console.error(`${label} file size check failed:`); - for (const violation of violations) { - console.error( - `- ${violation.relativePath}: ${violation.lineCount} lines (limit ${violation.limit})`, - ); - } + if (violations.length === 0) return; + + console.error(`${label} file size ratchet failed (base ${baseRef}):`); + for (const violation of violations) { + const before = violation.baseLines == null ? "new" : violation.baseLines; + const delta = + violation.baseLines == null + ? "" + : ` (${violation.candidateLines - violation.baseLines >= 0 ? "+" : ""}${violation.candidateLines - violation.baseLines})`; console.error( - `Split the file or add a narrowly scoped exception in \`${scriptPath}\`.`, + `- ${violation.relativePath}: ${before} -> ${violation.candidateLines}${delta} lines (allowed ${violation.limit})`, ); - process.exit(1); } + console.error( + "Keep new files at or below the limit; files already over it may not grow.", + ); + process.exitCode = 1; } diff --git a/scripts/check-file-sizes-core.test.mjs b/scripts/check-file-sizes-core.test.mjs new file mode 100644 index 0000000000..14dfe4b8da --- /dev/null +++ b/scripts/check-file-sizes-core.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + allowedLineCount, + countLines, + evaluateFileSize, + parseChangedFiles, + resolveBaseRef, +} from "./check-file-sizes-core.mjs"; + +function git(repo, ...args) { + return execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim(); +} + +test("local base resolution uses the branch merge-base and fails without origin/main", () => { + const repo = mkdtempSync(path.join(tmpdir(), "file-size-base-")); + git(repo, "init", "-b", "main"); + git(repo, "config", "user.name", "Test"); + git(repo, "config", "user.email", "test@example.com"); + git(repo, "commit", "--allow-empty", "-m", "base"); + git(repo, "remote", "add", "origin", repo); + git(repo, "fetch", "origin", "main:refs/remotes/origin/main"); + const base = git(repo, "rev-parse", "HEAD"); + git(repo, "switch", "-c", "feature"); + git(repo, "commit", "--allow-empty", "-m", "first branch commit"); + git(repo, "commit", "--allow-empty", "-m", "second branch commit"); + + assert.equal(resolveBaseRef(repo, {}), base); + git(repo, "update-ref", "-d", "refs/remotes/origin/main"); + assert.throws( + () => resolveBaseRef(repo, {}), + /Fetch origin\/main or set CHECK_FILE_SIZES_BASE/, + ); +}); + +test("counts empty, LF, and CRLF content with the existing semantics", () => { + assert.equal(countLines(""), 0); + assert.equal(countLines("one\n"), 2); + assert.equal(countLines("one\r\ntwo"), 2); +}); + +test("new files use the configured ceiling", () => { + assert.equal(allowedLineCount(null, 1000), 1000); + assert.deepEqual( + evaluateFileSize({ baseLines: null, candidateLines: 1000, maxLines: 1000 }), + { + limit: 1000, + violates: false, + }, + ); + assert.equal( + evaluateFileSize({ baseLines: null, candidateLines: 1001, maxLines: 1000 }) + .violates, + true, + ); +}); + +test("a compliant file may not cross the ceiling", () => { + assert.equal( + evaluateFileSize({ baseLines: 996, candidateLines: 1000, maxLines: 1000 }) + .violates, + false, + ); + assert.equal( + evaluateFileSize({ baseLines: 996, candidateLines: 1003, maxLines: 1000 }) + .violates, + true, + ); +}); + +test("parses modifications, deletions, and renames from Git's NUL format", () => { + assert.deepEqual( + parseChangedFiles( + "M\0desktop/src/a.ts\0D\0desktop/src/b.ts\0R100\0desktop/src/old.ts\0desktop/src/new.ts\0", + ), + [ + { status: "M", path: "desktop/src/a.ts" }, + { status: "D", path: "desktop/src/b.ts" }, + { + status: "R", + oldPath: "desktop/src/old.ts", + path: "desktop/src/new.ts", + }, + ], + ); +}); + +test("an inherited oversized file may hold or shrink but not grow", () => { + assert.equal(allowedLineCount(1026, 1000), 1026); + assert.equal( + evaluateFileSize({ baseLines: 1026, candidateLines: 1026, maxLines: 1000 }) + .violates, + false, + ); + assert.equal( + evaluateFileSize({ baseLines: 1026, candidateLines: 1001, maxLines: 1000 }) + .violates, + false, + ); + assert.equal( + evaluateFileSize({ baseLines: 1026, candidateLines: 1027, maxLines: 1000 }) + .violates, + true, + ); +}); diff --git a/web/scripts/check-file-sizes.mjs b/web/scripts/check-file-sizes.mjs index 093dc3c2d7..810a2b7ae7 100644 --- a/web/scripts/check-file-sizes.mjs +++ b/web/scripts/check-file-sizes.mjs @@ -29,5 +29,4 @@ await runFileSizeCheck({ projectRoot, rules, label: "Web", - scriptPath: "web/scripts/check-file-sizes.mjs", }); From 55a3ed7b9217cee5b23e0a5441947dc929b2a38c Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 28 Jul 2026 16:35:10 -0600 Subject: [PATCH 14/99] fix(desktop): clear stale thread new-message pill (#3411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - reconcile anchored-scroll state when passive layout changes put a thread at its physical floor - route thread composer-padding growth and shrink through the same hook-owned settlement path - preserve pinned thread targets while clearing stale new-message state ## Root cause Thread bottom state was updated primarily by native `scroll` events. Deferred replies, viewport changes, and composer-overlay padding can finish changing geometry after the user's last scroll—or after the initial open pin—without another scroll event. The thread could visibly reach the floor while `isAtBottom` and `newMessageCount` remained stale, leaving the “N new messages” pill visible. ## Verification - `pnpm check` - `pnpm typecheck` - `pnpm test` — 3,768 passed - push hook: branch-skew, Desktop check, and Desktop full unit suite passed Signed-off-by: Wes --- .../messages/ui/MessageThreadPanel.tsx | 43 +++++++------ .../ui/useAnchoredScroll.lifecycle.test.mjs | 62 ++++++++++++++++++- .../features/messages/ui/useAnchoredScroll.ts | 44 +++++++++++-- 3 files changed, 122 insertions(+), 27 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 59c92c7a69..08a57fa4c6 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -251,11 +251,6 @@ export function MessageThreadPanel({ // conditional activity accessory (agent working and/or someone typing). const hasComposerBottomActivity = activityAccessoryVisible || threadTypingPubkeys.length > 0; - useComposerHeightPadding( - threadBodyRef, - threadComposerWrapperRef, - isSinglePanelView, - ); // Live ref so onCaptureSendContext can read reply state at submit time // (before any async mention-flow awaits change navigation state). @@ -490,19 +485,31 @@ export function MessageThreadPanel({ threadHead, ]); - const { isAtBottom, newMessageCount, onScroll, scrollToBottom } = - useAnchoredScroll({ - channelId: threadHeadId, - contentRef: threadContentRef, - isLoading: threadRepliesPending || repliesRenderState === "pending", - messages: threadMessages, - highlightTargetMessage: scrollTargetHighlights, - onTargetReached: onScrollTargetResolved, - onTargetSettled: onScrollTargetSettled, - pinTargetCentered: !scrollTargetHighlights, - scrollContainerRef: threadBodyRef, - targetMessageId: scrollTargetId, - }); + const { + isAtBottom, + newMessageCount, + onScroll, + scrollToBottom, + settleAtBottomAfterLayout, + } = useAnchoredScroll({ + channelId: threadHeadId, + contentRef: threadContentRef, + isLoading: threadRepliesPending || repliesRenderState === "pending", + messages: threadMessages, + highlightTargetMessage: scrollTargetHighlights, + onTargetReached: onScrollTargetResolved, + onTargetSettled: onScrollTargetSettled, + pinTargetCentered: !scrollTargetHighlights, + scrollContainerRef: threadBodyRef, + targetMessageId: scrollTargetId, + }); + useComposerHeightPadding( + threadBodyRef, + threadComposerWrapperRef, + isSinglePanelView, + "padding", + settleAtBottomAfterLayout, + ); const knownAgentPubkeys = useKnownAgentPubkeys(); const initialAgentPubkeys = React.useMemo(() => { diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index 507f654cd0..1fdfb05857 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -218,7 +218,8 @@ function makePinnedCenterNodes() { disconnect() {} observe(target) { - this.target = target; + this.targets ??= []; + this.targets.push(target); } }; @@ -246,6 +247,18 @@ function Harness({ channelId, onTargetSettled, refs }) { return null; } +function BottomStateHarness({ messages, onState, refs }) { + const anchored = useAnchoredScroll({ + channelId: "conversation", + contentRef: refs.content, + isLoading: false, + messages, + scrollContainerRef: refs.container, + }); + onState(anchored); + return null; +} + function VirtualTargetHarness({ refs }) { const didRun = React.useRef(false); const bottomApi = useVirtualizedBottomSettle( @@ -294,7 +307,10 @@ test("channel change attaches pinned-center observers after refs mount", async ( }); assert.equal(nodes.resizeObservers.length, 1); - assert.equal(nodes.resizeObservers[0].target, nodes.content); + assert.deepEqual(nodes.resizeObservers[0].targets, [ + nodes.content, + nodes.container, + ]); assert.equal(nodes.container.listeners.get("wheel")?.length, 1); await act(async () => { @@ -313,7 +329,47 @@ test("channel change attaches pinned-center observers after refs mount", async ( }); }); -test("pinned target settles only after resize correction and a paint frame", async () => { +test("container resize clears a stale new-message state at the physical floor", async () => { + const refs = { + container: { current: null }, + content: { current: null }, + }; + const root = createRoot(document.createElement("div")); + const nodes = makePinnedCenterNodes(); + refs.container.current = nodes.container; + refs.content.current = nodes.content; + let state = null; + const render = (messages) => + root.render( + React.createElement(BottomStateHarness, { + messages, + onState: (nextState) => { + state = nextState; + }, + refs, + }), + ); + + await act(async () => render([{ id: "first" }])); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + await act(async () => render([{ id: "first" }, { id: "second" }])); + assert.equal(state.isAtBottom, false); + assert.equal(state.newMessageCount, 1); + + // A taller viewport reaches the floor without producing a native scroll. + nodes.container.clientHeight = 900; + await act(async () => nodes.resizeObservers[0].callback()); + + assert.equal(state.isAtBottom, true); + assert.equal(state.newMessageCount, 0); + await act(async () => root.unmount()); +}); + +test("pinned target resize reconciles bottom state before retiring", async () => { const refs = { container: { current: null }, content: { current: null }, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index ead5771294..add9439599 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -73,6 +73,9 @@ type UseAnchoredScrollResult = { highlightedMessageId: string | null; /** Imperative: scroll to bottom. */ scrollToBottom: (behavior?: ScrollBehavior) => void; + /** Re-pins after a layout owner changes trailing geometry. Returns true when + * the hook handled the settlement, including a preserved pinned target. */ + settleAtBottomAfterLayout: () => boolean; /** Arm a one-shot scroll-to-bottom that fires on the next appended message * (used by the composer's send flow). */ scrollToBottomOnNextUpdate: () => void; @@ -383,6 +386,35 @@ export function useAnchoredScroll({ forceBottomOnNextAppendRef.current = true; }, []); + const settleAtBottomAfterLayout = React.useCallback(() => { + const container = scrollContainerRef.current; + if (!container) return false; + if (anchorRef.current.kind === "pinned-center") { + repinPinnedCenter(); + const atBottom = isAtBottomNow(container); + setIsAtBottom((previous) => + previous === atBottom ? previous : atBottom, + ); + if (atBottom) setNewMessageCount(0); + schedulePinnedTargetSettle(anchorRef.current.messageId); + return true; + } + if (!isAtBottomNow(container)) return false; + + anchorRef.current = { kind: "at-bottom" }; + setIsAtBottom(true); + setNewMessageCount(0); + if (!virtualizerOwnsPrependAnchoring) { + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + } + return true; + }, [ + repinPinnedCenter, + schedulePinnedTargetSettle, + scrollContainerRef, + virtualizerOwnsPrependAnchoring, + ]); + const highlightMessage = React.useCallback((messageId: string) => { if (highlightTimeoutRef.current !== null) { window.clearTimeout(highlightTimeoutRef.current); @@ -743,10 +775,8 @@ export function useAnchoredScroll({ const observer = new ResizeObserver(() => { const container = scrollContainerRef.current; if (!container) return; - if (anchorRef.current.kind === "pinned-center") { - repinPinnedCenter(); - schedulePinnedTargetSettle(anchorRef.current.messageId); - } else if ( + if (settleAtBottomAfterLayout()) return; + if ( anchorRef.current.kind === "at-bottom" && !virtualizerOwnsPrependAnchoring ) { @@ -754,6 +784,8 @@ export function useAnchoredScroll({ } }); observer.observe(content); + const container = scrollContainerRef.current; + if (container && container !== content) observer.observe(container); return () => { observer.disconnect(); if (targetSettleRafRef.current !== null) { @@ -764,9 +796,8 @@ export function useAnchoredScroll({ }, [ channelId, contentRef, - repinPinnedCenter, - schedulePinnedTargetSettle, scrollContainerRef, + settleAtBottomAfterLayout, virtualizerOwnsPrependAnchoring, ]); @@ -919,6 +950,7 @@ export function useAnchoredScroll({ newMessageCount, highlightedMessageId, scrollToBottom: scrollToBottomImperative, + settleAtBottomAfterLayout, scrollToBottomOnNextUpdate, scrollToMessage: scrollToMessageImperative, onVirtualizerAtBottomStateChange, From 90e058ebf68137e048a409aec6616519379ff726 Mon Sep 17 00:00:00 2001 From: Apurva324 Date: Wed, 29 Jul 2026 04:26:20 +0530 Subject: [PATCH 15/99] feat: add explicit entry for claude-opus-5 in model config (#2831) Fixes #2787 - Added `claude-opus-5` to `config.rs` model classification and adaptive effort helpers. - Updated fixture test configurations to cover `claude-opus-5`. - Verified with `cargo test` and JS unit tests. Signed-off-by: Apurva Shaw Co-authored-by: Apurva Shaw --- crates/buzz-agent/src/config.rs | 2 ++ desktop/src/features/agents/ui/effortTable.fixture.json | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index f3464fb903..864fa7d237 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -184,6 +184,7 @@ pub fn anthropic_thinking_config( fn anthropic_model_supports_xhigh(model: &str) -> bool { model.starts_with("claude-opus-4-7") || model.starts_with("claude-opus-4-8") + || model.starts_with("claude-opus-5") || model.starts_with("claude-sonnet-5") || model.starts_with("claude-fable-5") || model.starts_with("claude-mythos-5") @@ -606,6 +607,7 @@ fn is_adaptive_thinking_model(model: &str) -> bool { model.starts_with("claude-opus-4-6") || model.starts_with("claude-opus-4-7") || model.starts_with("claude-opus-4-8") + || model.starts_with("claude-opus-5") // Sonnet 5.x (any patch/date suffix after "claude-sonnet-5"). || model.starts_with("claude-sonnet-5") // Sonnet 4.6 exactly (not Sonnet 4.5 or earlier — not in the adaptive table). diff --git a/desktop/src/features/agents/ui/effortTable.fixture.json b/desktop/src/features/agents/ui/effortTable.fixture.json index ed44c7581b..defb1f86de 100644 --- a/desktop/src/features/agents/ui/effortTable.fixture.json +++ b/desktop/src/features/agents/ui/effortTable.fixture.json @@ -41,6 +41,13 @@ "validValues": ["low", "medium", "high", "xhigh", "max"], "defaultValue": "high" }, + { + "note": "Anthropic adaptive xhigh-capable: claude-opus-5", + "provider": "anthropic", + "model": "claude-opus-5", + "validValues": ["low", "medium", "high", "xhigh", "max"], + "defaultValue": "high" + }, { "note": "Anthropic adaptive xhigh-capable: claude-mythos-5", "provider": "anthropic", From 22be8bb35177e27efc2dca2534df9a8dd871eae0 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:13:28 -0400 Subject: [PATCH 16/99] fix(relay): avoid subscription lock inversion (#3413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - drop the `subs` DashMap guard before mutating subscription indexes - snapshot fan-out candidate vectors so index guards are dropped before looking up `subs` - add concurrent fan-out/replacement regression coverage ## Why `fan_out_scoped` previously held an index guard while `push_match` acquired `subs`, while CLOSE and same-ID replacement held `subs` while removing from an index. The reverse ordering made an AB/BA deadlock reachable and could synchronously park all Tokio workers. ## Validation - `rustup run 1.95.0 cargo test -p buzz-relay` — 769 library tests passed, 33 ignored; 11 binary tests passed; doc tests passed - push hooks with pinned Rust 1.95 — branch-skew, repository Rust suites, and desktop Tauri suite passed - `git diff --check` ## Residual risk Fan-out now clones bounded candidate vectors before matching. This adds allocation/copy cost proportional to the indexed candidate set, in exchange for eliminating nested DashMap guards. This fixes the concrete lock cycle but does not prove every observed production wedge had this cause. --------- Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-relay/src/subscription.rs | 263 +++++++++++++++++++++++--- 1 file changed, 236 insertions(+), 27 deletions(-) diff --git a/crates/buzz-relay/src/subscription.rs b/crates/buzz-relay/src/subscription.rs index 68f0fea3c4..7a62188d3a 100644 --- a/crates/buzz-relay/src/subscription.rs +++ b/crates/buzz-relay/src/subscription.rs @@ -164,17 +164,30 @@ impl SubscriptionRegistry { conn_id: ConnId, sub_id: &str, ) -> Option { - if let Some(mut conn_subs) = self.subs.get_mut(&conn_id) { - if let Some((filters, community_id, channel_id)) = conn_subs.remove(sub_id) { - self.remove_from_index(conn_id, sub_id, &filters, community_id, channel_id); - metrics::gauge!("buzz_subscriptions_active").decrement(1.0); - return Some(RemovedSubscription { - community_id, - channel_id, - }); - } - } - None + self.remove_subscription_inner(conn_id, sub_id, || {}) + } + + fn remove_subscription_inner( + &self, + conn_id: ConnId, + sub_id: &str, + after_remove: F, + ) -> Option + where + F: FnOnce(), + { + let mut conn_subs = self.subs.get_mut(&conn_id)?; + let (filters, community_id, channel_id) = conn_subs.remove(sub_id)?; + + after_remove(); + self.remove_from_index(conn_id, sub_id, &filters, community_id, channel_id); + drop(conn_subs); + + metrics::gauge!("buzz_subscriptions_active").decrement(1.0); + Some(RemovedSubscription { + community_id, + channel_id, + }) } /// Remove all subscriptions for a connection and clean up index entries. @@ -275,15 +288,37 @@ impl SubscriptionRegistry { channel_id, kind: event.event.kind, }; - if let Some(candidates) = self.channel_kind_index.get(&(community_id, key)) { - for (conn_id, sub_id) in candidates.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + if let Some(candidates) = self + .channel_kind_index + .get(&(community_id, key)) + .map(|entry| entry.value().clone()) + { + for (conn_id, sub_id) in candidates { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } // Also check wildcard (channel-only, kindless) index. - if let Some(wildcards) = self.channel_wildcard_index.get(&(community_id, channel_id)) { - for (conn_id, sub_id) in wildcards.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + if let Some(wildcards) = self + .channel_wildcard_index + .get(&(community_id, channel_id)) + .map(|entry| entry.value().clone()) + { + for (conn_id, sub_id) in wildcards { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } } else { @@ -296,24 +331,54 @@ impl SubscriptionRegistry { kind: event.event.kind, p, }; - if let Some(candidates) = self.global_p_kind_index.get(&key) { - for (conn_id, sub_id) in candidates.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + if let Some(candidates) = self + .global_p_kind_index + .get(&key) + .map(|entry| entry.value().clone()) + { + for (conn_id, sub_id) in candidates { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } } if let Some(candidates) = self .global_kind_index .get(&(community_id, event.event.kind)) + .map(|entry| entry.value().clone()) { - for (conn_id, sub_id) in candidates.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + for (conn_id, sub_id) in candidates { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } // Also check global wildcard (kindless global subs). - if let Some(wildcards) = self.global_wildcard_index.get(&community_id) { - for (conn_id, sub_id) in wildcards.iter() { - self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + if let Some(wildcards) = self + .global_wildcard_index + .get(&community_id) + .map(|entry| entry.value().clone()) + { + for (conn_id, sub_id) in wildcards { + self.push_match( + conn_id, + &sub_id, + community_id, + event, + &mut results, + &mut seen, + ); } } } @@ -370,13 +435,20 @@ impl SubscriptionRegistry { &self, conn_id: ConnId, sub_id: &str, + community_id: CommunityId, event: &StoredEvent, results: &mut Vec<(ConnId, SubId)>, seen: &mut HashSet<(ConnId, SubId)>, ) { if let Some(conn_subs) = self.subs.get(&conn_id) { - if let Some((filters, _, _)) = conn_subs.get(sub_id) { - if filters_match(filters, event) { + if let Some((filters, sub_community_id, sub_channel_id)) = conn_subs.get(sub_id) { + // Candidate snapshots can become stale while a same-ID replacement + // moves the subscription. Re-check its authoritative scope before + // matching so an old index entry cannot deliver across scopes. + if *sub_community_id == community_id + && *sub_channel_id == event.channel_id + && filters_match(filters, event) + { let entry = (conn_id, sub_id.to_string()); if seen.insert(entry.clone()) { results.push(entry); @@ -576,6 +648,8 @@ mod tests { use buzz_core::StoredEvent; use chrono::Utc; use nostr::{EventBuilder, Keys, Kind, Tag}; + use std::sync::Arc; + use std::time::{Duration, Instant}; fn make_stored_event(kind: Kind, channel_id: Option) -> StoredEvent { let keys = Keys::generate(); @@ -629,6 +703,141 @@ mod tests { assert!(matches.is_empty()); } + #[test] + fn test_subscription_removal_cannot_delete_replacement_index() { + let registry = Arc::new(SubscriptionRegistry::new()); + let conn_id = Uuid::new_v4(); + let channel_id = Uuid::new_v4(); + let sub_id = "same-id".to_string(); + let filters = vec![Filter::new().kind(Kind::TextNote)]; + registry.register(conn_id, sub_id.clone(), filters.clone(), Some(channel_id)); + + let (removed_tx, removed_rx) = std::sync::mpsc::sync_channel(0); + let (resume_tx, resume_rx) = std::sync::mpsc::sync_channel(0); + let remove_registry = Arc::clone(®istry); + let remove_sub_id = sub_id.clone(); + let remove = std::thread::spawn(move || { + remove_registry.remove_subscription_inner(conn_id, &remove_sub_id, || { + removed_tx.send(()).expect("signal authoritative removal"); + resume_rx.recv().expect("resume index cleanup"); + }) + }); + + removed_rx.recv().expect("old subscription removed"); + + let (registered_tx, registered_rx) = std::sync::mpsc::sync_channel(0); + let register_registry = Arc::clone(®istry); + let register_sub_id = sub_id.clone(); + let register = std::thread::spawn(move || { + register_registry.register(conn_id, register_sub_id, filters, Some(channel_id)); + registered_tx + .send(()) + .expect("signal replacement registration"); + }); + + let replacement_finished_early = registered_rx + .recv_timeout(Duration::from_millis(100)) + .is_ok(); + resume_tx.send(()).expect("resume old cleanup"); + remove.join().expect("removal thread completes"); + if !replacement_finished_early { + registered_rx + .recv_timeout(Duration::from_secs(1)) + .expect("replacement registration completes"); + } + register.join().expect("registration thread completes"); + assert!( + !replacement_finished_early, + "replacement must wait until old index cleanup is complete" + ); + + let event = make_stored_event(Kind::TextNote, Some(channel_id)); + assert_eq!( + registry.fan_out(&event), + vec![(conn_id, sub_id)], + "replacement must remain reachable through its index" + ); + } + + #[test] + fn test_stale_candidate_snapshot_does_not_cross_subscription_scope() { + let registry = SubscriptionRegistry::new(); + let conn_id = Uuid::new_v4(); + let channel_a = Uuid::new_v4(); + let channel_b = Uuid::new_v4(); + let sub_id = "same-id".to_string(); + let filters = vec![Filter::new().kind(Kind::TextNote)]; + registry.register(conn_id, sub_id.clone(), filters.clone(), Some(channel_a)); + + // Reproduce fan-out's unlocked candidate snapshot, then move the same + // subscription ID before the authoritative subscription lookup. + let key = IndexKey { + channel_id: channel_a, + kind: Kind::TextNote, + }; + let candidates = registry + .channel_kind_index + .get(&(test_community(), key)) + .expect("channel A candidate exists") + .value() + .clone(); + registry.register(conn_id, sub_id, filters, Some(channel_b)); + + let event = make_stored_event(Kind::TextNote, Some(channel_a)); + let mut results = Vec::new(); + let mut seen = HashSet::new(); + for (candidate_conn_id, candidate_sub_id) in candidates { + registry.push_match( + candidate_conn_id, + &candidate_sub_id, + test_community(), + &event, + &mut results, + &mut seen, + ); + } + + assert!( + results.is_empty(), + "replacement on channel B received channel A event through stale snapshot" + ); + } + + #[test] + fn test_fan_out_concurrent_with_subscription_replacement_completes() { + let registry = Arc::new(SubscriptionRegistry::new()); + let conn_id = Uuid::new_v4(); + let channel_id = Uuid::new_v4(); + let sub_id = "sub1".to_string(); + let filters = vec![Filter::new().kind(Kind::TextNote)]; + registry.register(conn_id, sub_id.clone(), filters.clone(), Some(channel_id)); + let event = Arc::new(make_stored_event(Kind::TextNote, Some(channel_id))); + let deadline = Instant::now() + Duration::from_secs(2); + + let fan_out_registry = Arc::clone(®istry); + let fan_out_event = Arc::clone(&event); + let fan_out = std::thread::spawn(move || { + while Instant::now() < deadline { + let _ = fan_out_registry.fan_out(&fan_out_event); + } + }); + + let replace_registry = Arc::clone(®istry); + let replace = std::thread::spawn(move || { + while Instant::now() < deadline { + replace_registry.register( + conn_id, + sub_id.clone(), + filters.clone(), + Some(channel_id), + ); + } + }); + + fan_out.join().expect("fan-out thread completes"); + replace.join().expect("replacement thread completes"); + } + #[test] fn test_subscription_registry_remove_connection() { let registry = SubscriptionRegistry::new(); From 6300a6b1d03e32c473c7b6568df663c8927565cf Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:51:19 -0400 Subject: [PATCH 17/99] =?UTF-8?q?fix(acp):=20per-runtime=20env=20defaults?= =?UTF-8?q?=20at=20spawn=20=E2=80=94=20isolate=20Hermes=20from=20configure?= =?UTF-8?q?d=20MCP=20startup=20(#3420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add a generic per-runtime env-defaults table, `config::default_agent_env()`, mirroring the existing `default_agent_args()` / `codex_network_env()` precedent, and merge it once in `AcpClient::spawn` with the established precedence: **runtime defaults < persona `extra_env` < inherited parent env** - first (and only) row: Buzz-owned Hermes processes get `HERMES_ACP_SKIP_CONFIGURED_MCP=1`, so Hermes does not preload unrelated profile-configured MCP servers before answering ACP `initialize` (fixes the 10s model-discovery timeout in #3355 — Buzz supplies session MCP servers explicitly through `session/new`, per Hermes's documented host-integration contract for this variable) - normalize Windows `.cmd`/`.bat` shims alongside `.exe` in `normalize_agent_command_identity` (npm installs resolve to those wrappers) - switch the `extra_env` parent-presence check from `var()` to `var_os()` so non-UTF-8 parent values are honored Replaces the runtime-specific approach in #3386: same behavior, but the mechanism is generic runtime spawn metadata in `config.rs` rather than a Hermes/ACP special case in `acp.rs`, and the seam covers every launch path (Desktop spawn, `buzz-acp models`, CLI) because they all funnel through `AcpClient::spawn`. ~15 lines of production code. Fixes #3355 ## Testing - `cargo test -p buzz-acp` — **639 passed, 0 failed** (full package, includes the new `default_agent_env_recognizes_hermes_identities` unit test and `spawn_applies_runtime_env_defaults_with_extra_env_precedence` integration test covering default injection, extra_env override, and non-Hermes exclusion) - `cargo fmt --all -- --check`, `cargo clippy -p buzz-acp --all-targets -- -D warnings` — clean - live-local with real Hermes v0.19.0 (`hermes-acp`): `buzz-acp models` returned **13 models / currentModelId in 2.6–3.0s** (was a 10.0s timeout on the first cold run without isolation); a wrapper probe confirmed the child received `HERMES_ACP_SKIP_CONFIGURED_MCP=1` by default and `0` when the parent env set it explicitly (operator wins) - lefthook pre-push suite green: rust-tests, desktop-check, desktop-test, desktop-tauri-test, mobile-test, branch-skew No UI changes; subprocess environment behavior only. Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: Tyler Longwell Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell Co-authored-by: mr-r0b0t.eth --- crates/buzz-acp/src/acp.rs | 84 ++++++++++++++++++++++++++++++++++- crates/buzz-acp/src/config.rs | 59 +++++++++++++++++++++++- 2 files changed, 141 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index d629e6a037..9eb668cbc2 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -494,12 +494,22 @@ impl AcpClient { // entry falls through to the standard operator-wins treatment below. let codex_merge_active = codex_config_value.is_some(); + // Per-runtime environment defaults (e.g. Hermes MCP-startup isolation). + // Applied first so both persona `extra_env` (below, via `Command::env` + // key replacement) and inherited parent env (via the parent-presence + // check) override them. + for &(key, value) in crate::config::default_agent_env(command) { + if std::env::var_os(key).is_none() { + cmd.env(key, value); + } + } + for (key, value) in extra_env { if key == "CODEX_CONFIG" && codex_merge_active { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; } - if std::env::var(key).is_err() { + if std::env::var_os(key).is_none() { cmd.env(key, value); } } @@ -2891,6 +2901,78 @@ mod tests { ); } + /// Spawn a probe script whose file name carries a runtime identity (e.g. + /// `hermes-acp`) and return the value of `var` as the child observed it. + /// `` means the child did not receive the var. + #[cfg(unix)] + async fn spawn_named_and_read_child_env( + file_name: &str, + var: &str, + extra_env: &[(String, String)], + ) -> String { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("buzz-acp-env-probe-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("create env probe dir"); + let path = dir.join(file_name); + std::fs::write( + &path, + format!("#!/bin/sh\nprintf '%s\\n' \"${{{var}:-}}\"\n"), + ) + .expect("write env probe script"); + let mut permissions = std::fs::metadata(&path).expect("stat probe").permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&path, permissions).expect("chmod probe"); + + let mut client = AcpClient::spawn( + path.to_str().expect("probe path is UTF-8"), + &[], + extra_env, + false, + ) + .await + .expect("spawn env probe script"); + let observed = client + .reader + .next() + .await + .unwrap_or_else(|| panic!("child produced no output for {var}")) + .expect("child stdout was not readable"); + client.shutdown().await; + std::fs::remove_dir_all(&dir).expect("remove env probe dir"); + observed + } + + /// Buzz-owned Hermes processes get the configured-MCP isolation default, + /// and an explicit persona entry still overrides it (defaults are applied + /// before `extra_env`, so the later `Command::env` write wins). + #[cfg(unix)] + #[tokio::test] + async fn spawn_applies_runtime_env_defaults_with_extra_env_precedence() { + const VAR: &str = "HERMES_ACP_SKIP_CONFIGURED_MCP"; + if std::env::var_os(VAR).is_some() { + // Inherited parent values win over both layers; the default and + // override behavior below is unobservable in such an environment. + return; + } + + assert_eq!( + spawn_named_and_read_child_env("hermes-acp", VAR, &[]).await, + "1", + "Hermes spawns must default {VAR}=1" + ); + assert_eq!( + spawn_named_and_read_child_env("hermes-acp", VAR, &[(VAR.into(), "0".into())]).await, + "0", + "an explicit extra_env entry must override the runtime default" + ); + assert_eq!( + spawn_named_and_read_child_env("other-agent", VAR, &[]).await, + "", + "non-Hermes spawns must not receive Hermes defaults" + ); + } + /// Persona config must not be able to re-enable the scheduler: this is a /// correctness invariant, not an operator-tunable default, so the /// injection is set after (and therefore wins over) the `extra_env` loop. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 9a1b74c276..19304bf186 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -676,7 +676,12 @@ pub(crate) fn normalize_agent_command_identity(command: &str) -> String { .next() .expect("rsplit always yields at least one element"); let lower = basename.to_ascii_lowercase(); - let stem = lower.strip_suffix(".exe").unwrap_or(&lower); + // Windows resolves commands through `.exe` binaries and npm's `.cmd`/`.bat` + // shims; all three name the same runtime identity. + let stem = [".exe", ".cmd", ".bat"] + .iter() + .find_map(|extension| lower.strip_suffix(extension)) + .unwrap_or(&lower); stem.chars() .map(|character| match character { ' ' | '_' => '-', @@ -694,6 +699,25 @@ fn default_agent_args(command: &str) -> Option> { } } +/// Per-runtime environment defaults applied when Buzz owns the agent process. +/// +/// Mirrors [`default_agent_args`]: keyed on the normalized command identity, +/// with the merge (in `AcpClient::spawn`) giving explicit persona env and +/// inherited parent env precedence over these defaults. +/// +/// Hermes: ACP hosts supply session MCP servers explicitly through +/// `session/new`, but Hermes otherwise starts every profile-configured MCP +/// server before it responds to `initialize` — which can exhaust the host's +/// startup budget (see block/buzz#3355). Skip that unrelated global startup +/// by default; an operator or persona can still opt back in by setting the +/// variable explicitly. +pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'static str)] { + match normalize_agent_command_identity(command).as_str() { + "hermes" | "hermes-agent" | "hermes-acp" => &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")], + _ => &[], + } +} + /// Build the `CODEX_CONFIG` environment variable that enables full outbound /// network access in Codex's macOS Seatbelt sandbox. /// @@ -1589,6 +1613,15 @@ mod tests { "claude-code" ); assert_eq!(normalize_agent_command_identity("Goose.EXE"), "goose"); + // Windows npm shims resolve to `.cmd`/`.bat` wrappers. + assert_eq!( + normalize_agent_command_identity(r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd"), + "hermes-acp" + ); + assert_eq!( + normalize_agent_command_identity(r"C:\Tools\Hermes\HERMES-AGENT.BAT"), + "hermes-agent" + ); // Non-ASCII must not panic. assert_eq!(normalize_agent_command_identity("my-agënt"), "my-agënt"); // Edge cases: empty, whitespace-only, bare separators. @@ -1598,6 +1631,30 @@ mod tests { assert_eq!(normalize_agent_command_identity("///"), ""); } + #[test] + fn default_agent_env_recognizes_hermes_identities() { + for command in [ + "hermes", + "hermes-agent", + "hermes-acp", + "/opt/hermes/bin/hermes-acp", + r"C:\Users\test\bin\HERMES_ACP.EXE", + r"C:\Users\test\AppData\Roaming\npm\hermes-acp.cmd", + ] { + assert_eq!( + default_agent_env(command), + &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")], + "unexpected env defaults for {command}" + ); + } + for command in ["goose", "codex-acp", "claude-agent-acp", "buzz-agent", ""] { + assert!( + default_agent_env(command).is_empty(), + "non-Hermes command must have no env defaults: {command}" + ); + } + } + #[test] fn strips_legacy_acp_arg_case_insensitively() { assert_eq!( From 485d03a358b6d695aaf97879f3fbaf2f308d0755 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 29 Jul 2026 07:09:08 +0100 Subject: [PATCH 18/99] Fix mobile attachment and gallery polish (#3370) ## Summary - align mobile message metadata and enlarge attachment-menu content - smooth keyboard-to-camera/photo transitions and initialize the iOS photo grid at the intended scale - fix horizontal gallery loading, edge overflow, and end spacing ## Why The attachment surfaces were reacting to keyboard and compact-menu geometry during presentation, while gallery clipping and image lifecycle behavior caused misalignment and occasional blank previews. ## Testing - `just mobile-check` - `flutter test` (881 passed, 1 skipped) - native `RunnerTests` (17 passed) - verified standalone Release build on a physical iPhone --------- Signed-off-by: kenny lopez --- mobile/ios/Runner/InlinePhotoPicker.swift | 13 + .../ios/Runner/NativeAttachmentPopover.swift | 210 +++++++++++---- .../NativeAttachmentPopoverCoordinator.swift | 151 ++++++++++- mobile/ios/RunnerTests/RunnerTests.swift | 182 +++++++++++++ .../channel_detail_page/message_bubble.dart | 5 +- mobile/lib/features/channels/compose_bar.dart | 6 +- .../channels/compose_bar/attachments.dart | 161 ++++++++--- .../channels/compose_bar/camera_preview.dart | 15 +- .../message_content/media_carousel.dart | 15 +- .../features/channels/thread_detail_page.dart | 5 +- .../shared/widgets/message_author_meta.dart | 2 +- .../channels/channel_detail_page_test.dart | 57 ++++ .../features/channels/compose_bar_test.dart | 253 ++++++++++++++++++ .../channels/message_content_test.dart | 45 ++++ .../widgets/message_author_meta_test.dart | 35 +++ 15 files changed, 1054 insertions(+), 101 deletions(-) diff --git a/mobile/ios/Runner/InlinePhotoPicker.swift b/mobile/ios/Runner/InlinePhotoPicker.swift index 4b8d1365df..05a576323f 100644 --- a/mobile/ios/Runner/InlinePhotoPicker.swift +++ b/mobile/ios/Runner/InlinePhotoPicker.swift @@ -3,6 +3,14 @@ import PhotosUI import UIKit import UniformTypeIdentifiers +enum EmbeddedPhotoPickerLayout { + static func applyPreferredScale(_ zoomIn: () -> Void) { + UIView.performWithoutAnimation { + zoomIn() + } + } +} + final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory { private let messenger: FlutterBinaryMessenger private weak var parentViewController: UIViewController? @@ -68,6 +76,7 @@ final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView { } containerView.backgroundColor = .clear + containerView.clipsToBounds = true if #available(iOS 17.0, *) { installPicker() } @@ -120,6 +129,10 @@ final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView { picker.didMove(toParent: parentViewController) } pickerViewController = picker + containerView.layoutIfNeeded() + EmbeddedPhotoPickerLayout.applyPreferredScale { + picker.zoomIn() + } } private func exportPickerResult(_ result: PHPickerResult) async throws -> String { diff --git a/mobile/ios/Runner/NativeAttachmentPopover.swift b/mobile/ios/Runner/NativeAttachmentPopover.swift index bb49ca6e46..f2f6c01df8 100644 --- a/mobile/ios/Runner/NativeAttachmentPopover.swift +++ b/mobile/ios/Runner/NativeAttachmentPopover.swift @@ -17,12 +17,12 @@ final class NativeAttachmentPopoverViewController: case camera } + private typealias ContentPreparation = (@escaping () -> Void) -> Void + private let channel: FlutterMethodChannel private let expandedWidth: CGFloat - private let menuSize = CGSize(width: 176, height: 208) - private let expandedHeight: CGFloat = 372 - private let expandedHorizontalOffset: CGFloat = 10 - private let expandedVerticalOffset: CGFloat = 40 + private let maximumMenuHeight: CGFloat + private let expandedHeight = NativeAttachmentMenuLayout.maximumHeight private let contentHost = UIView() private let cameraSession = AVCaptureSession() private let cameraOutput = AVCapturePhotoOutput() @@ -50,14 +50,31 @@ final class NativeAttachmentPopoverViewController: private var activeCameraCaptureID: Int64? private var isFinishing = false private var didNotifyDismissal = false + private var keyboardDismissalOffset: CGFloat = 0 + private var menuStackHeightConstraint: NSLayoutConstraint? var onDismiss: (() -> Void)? - init(channel: FlutterMethodChannel, expandedWidth: CGFloat) { + private var menuSize: CGSize { + NativeAttachmentMenuLayout.size( + compatibleWith: traitCollection, + maximumHeight: maximumMenuHeight + ) + } + + init( + channel: FlutterMethodChannel, + expandedWidth: CGFloat, + maximumMenuHeight: CGFloat = NativeAttachmentMenuLayout.maximumHeight + ) { self.channel = channel self.expandedWidth = expandedWidth + self.maximumMenuHeight = maximumMenuHeight super.init(nibName: nil, bundle: nil) - preferredContentSize = menuSize + preferredContentSize = NativeAttachmentMenuLayout.size( + compatibleWith: .current, + maximumHeight: maximumMenuHeight + ) } @available(*, unavailable) @@ -106,6 +123,20 @@ final class NativeAttachmentPopoverViewController: stopCamera() } + override func traitCollectionDidChange( + _ previousTraitCollection: UITraitCollection? + ) { + super.traitCollectionDidChange(previousTraitCollection) + guard + previousTraitCollection?.preferredContentSizeCategory + != traitCollection.preferredContentSizeCategory + else { + return + } + + updateMenuLayout() + } + func adaptivePresentationStyle( for controller: UIPresentationController ) -> UIModalPresentationStyle { @@ -122,16 +153,45 @@ final class NativeAttachmentPopoverViewController: let container = UIView() container.translatesAutoresizingMaskIntoConstraints = false + let scrollView = UIScrollView() + scrollView.alwaysBounceVertical = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(scrollView) + let stack = UIStackView() stack.axis = .vertical stack.distribution = .fillEqually + stack.spacing = NativeAttachmentMenuLayout.itemSpacing stack.translatesAutoresizingMaskIntoConstraints = false - container.addSubview(stack) + scrollView.addSubview(stack) + let stackHeightConstraint = stack.heightAnchor.constraint( + equalToConstant: NativeAttachmentMenuLayout.itemsHeight( + compatibleWith: traitCollection + ) + ) + menuStackHeightConstraint = stackHeightConstraint NSLayoutConstraint.activate([ - stack.leadingAnchor.constraint(equalTo: container.leadingAnchor), - stack.trailingAnchor.constraint(equalTo: container.trailingAnchor), - stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 8), - stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), + scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: container.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + stack.leadingAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.leadingAnchor, + constant: NativeAttachmentMenuLayout.contentPadding + ), + stack.trailingAnchor.constraint( + equalTo: scrollView.frameLayoutGuide.trailingAnchor, + constant: -NativeAttachmentMenuLayout.contentPadding + ), + stack.topAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.topAnchor, + constant: NativeAttachmentMenuLayout.contentPadding + ), + stack.bottomAnchor.constraint( + equalTo: scrollView.contentLayoutGuide.bottomAnchor, + constant: -NativeAttachmentMenuLayout.contentPadding + ), + stackHeightConstraint, ]) stack.addArrangedSubview( @@ -169,8 +229,19 @@ final class NativeAttachmentPopoverViewController: return container } + private func updateMenuLayout() { + menuStackHeightConstraint?.constant = + NativeAttachmentMenuLayout.itemsHeight( + compatibleWith: traitCollection + ) + if surface == .menu { + preferredContentSize = menuSize + } + } + private func showPhotos() { guard surface != .photos else { return } + prepareForExpandedSurface() stopCamera() var configuration = PHPickerConfiguration(photoLibrary: .shared()) @@ -192,16 +263,14 @@ final class NativeAttachmentPopoverViewController: let container = UIView() container.backgroundColor = .clear + container.clipsToBounds = true addChild(picker) picker.view.translatesAutoresizingMaskIntoConstraints = false container.addSubview(picker.view) NSLayoutConstraint.activate([ picker.view.leadingAnchor.constraint(equalTo: container.leadingAnchor), picker.view.trailingAnchor.constraint(equalTo: container.trailingAnchor), - picker.view.topAnchor.constraint( - equalTo: container.topAnchor, - constant: -8 - ), + picker.view.topAnchor.constraint(equalTo: container.topAnchor), picker.view.bottomAnchor.constraint(equalTo: container.bottomAnchor), ]) picker.didMove(toParent: self) @@ -227,11 +296,32 @@ final class NativeAttachmentPopoverViewController: trailing: actionButton ) - transition(to: .photos, content: container) + transition( + to: .photos, + content: container, + preparation: { [weak picker] reveal in + guard let picker else { + reveal() + return + } + // PHPicker ignores scale changes while its remote grid is still + // adapting to the compact menu bounds. Give it one main-loop turn at + // the final popover size, apply the scale offscreen, then reveal it. + DispatchQueue.main.async { + picker.view.layoutIfNeeded() + EmbeddedPhotoPickerLayout.applyPreferredScale { + picker.zoomIn() + picker.view.layoutIfNeeded() + } + DispatchQueue.main.async(execute: reveal) + } + } + ) } private func showCamera() { guard surface != .camera else { return } + prepareForExpandedSurface() removePhotoPicker() let container = UIView() @@ -288,9 +378,26 @@ final class NativeAttachmentPopoverViewController: stopCamera() let menu = makeMenuView() - transition(to: .menu, content: menu) { [weak self] in - self?.removePhotoPicker() + transition( + to: .menu, + content: menu, + completion: { [weak self] in + self?.removePhotoPicker() + } + ) + } + + private func prepareForExpandedSurface() { + if let sourceHost = popoverPresentationController?.sourceView?.superview { + keyboardDismissalOffset = max( + keyboardDismissalOffset, + NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap( + containerBounds: sourceHost.bounds, + keyboardLayoutFrame: sourceHost.keyboardLayoutGuide.layoutFrame + ) + ) } + NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: view.window) } private func installContent(_ content: UIView) { @@ -308,6 +415,7 @@ final class NativeAttachmentPopoverViewController: private func transition( to nextSurface: Surface, content nextView: UIView, + preparation: ContentPreparation? = nil, completion: (() -> Void)? = nil ) { let previousView = visibleContentView @@ -327,50 +435,61 @@ final class NativeAttachmentPopoverViewController: ]) contentHost.layoutIfNeeded() - let direction: CGFloat = isExpanding ? 34 : -34 - nextView.alpha = UIAccessibility.isReduceMotionEnabled ? 0 : 0.01 - nextView.transform = - UIAccessibility.isReduceMotionEnabled - ? .identity - : CGAffineTransform(translationX: direction, y: 0).scaledBy( - x: 0.97, - y: 0.97 - ) + let shouldAnimate = !UIAccessibility.isReduceMotionEnabled + // Do not expose embedded surfaces while the popover changes size. In + // particular, PHPicker visibly reflows its grid from the compact menu + // width to the expanded width if it is allowed to paint during this step. + nextView.alpha = 0 visibleContentView = nextView surface = nextSurface - let duration = UIAccessibility.isReduceMotionEnabled ? 0.16 : 0.36 + let duration = shouldAnimate ? (isExpanding ? 0.24 : 0.2) : 0 UIView.animate( withDuration: duration, delay: 0, - usingSpringWithDamping: 0.86, - initialSpringVelocity: 0.18, - options: [.beginFromCurrentState, .allowUserInteraction] + options: [ + .beginFromCurrentState, + .allowUserInteraction, + .curveEaseInOut, + ] ) { self.preferredContentSize = targetSize if let popover = self.popoverPresentationController, let sourceView = popover.sourceView { - popover.sourceRect = sourceView.bounds.offsetBy( - dx: isExpanding ? self.expandedHorizontalOffset : 0, - dy: isExpanding ? self.expandedVerticalOffset : 0 + popover.sourceRect = NativeAttachmentPopoverAnchorLayout.sourceRect( + anchorBounds: sourceView.bounds, + keyboardDismissalOffset: self.keyboardDismissalOffset, + isExpanded: isExpanding ) } previousView?.alpha = 0 - previousView?.transform = - UIAccessibility.isReduceMotionEnabled - ? .identity - : CGAffineTransform(translationX: -direction, y: 0).scaledBy( - x: 0.97, - y: 0.97 - ) - nextView.alpha = 1 - nextView.transform = .identity self.view.layoutIfNeeded() } completion: { _ in previousView?.removeFromSuperview() - previousView?.transform = .identity - completion?() + self.view.layoutIfNeeded() + + let reveal = { + UIView.animate( + withDuration: shouldAnimate ? 0.14 : 0, + delay: 0, + options: [ + .beginFromCurrentState, + .allowUserInteraction, + .curveEaseOut, + ] + ) { + nextView.alpha = 1 + } completion: { _ in + completion?() + } + } + + if let preparation { + preparation(reveal) + } else { + reveal() + } } } @@ -917,6 +1036,7 @@ final class NativeAttachmentPopoverViewController: Self.removeTemporaryFiles(temporaryPaths) return } + NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: view.window) isFinishing = true view.isUserInteractionEnabled = false selectionGeneration += 1 diff --git a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift index 4b9c32ffcd..73559d7c2b 100644 --- a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift +++ b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift @@ -1,6 +1,71 @@ import Flutter import UIKit +enum NativeAttachmentExpandedSurfaceBehavior { + @MainActor + static func dismissKeyboard(in window: UIWindow?) { + window?.endEditing(true) + } + + static func keyboardOverlap( + containerBounds: CGRect, + keyboardLayoutFrame: CGRect + ) -> CGFloat { + guard + !keyboardLayoutFrame.isNull, + !keyboardLayoutFrame.isInfinite, + keyboardLayoutFrame.minY < containerBounds.maxY + else { + return 0 + } + return max(0, containerBounds.maxY - keyboardLayoutFrame.minY) + } +} + +enum NativeAttachmentPopoverAnchorLayout { + static let expandedVerticalOffset: CGFloat = 40 + + static func sourceRect( + anchorBounds: CGRect, + keyboardDismissalOffset: CGFloat, + isExpanded: Bool + ) -> CGRect { + anchorBounds.offsetBy( + dx: 0, + dy: keyboardDismissalOffset + + (isExpanded ? expandedVerticalOffset : 0) + ) + } +} + +enum NativeAttachmentPopoverPresentationLayout { + static func keyboardDismissalOffset( + sourceRect: CGRect, + containerBounds: CGRect, + safeAreaInsets: UIEdgeInsets, + keyboardLayoutFrame: CGRect, + menuHeight: CGFloat + ) -> CGFloat { + let keyboardOverlap = + NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap( + containerBounds: containerBounds, + keyboardLayoutFrame: keyboardLayoutFrame + ) + guard keyboardOverlap > 0 else { return 0 } + + let availableHeight = + sourceRect.minY - (containerBounds.minY + safeAreaInsets.top) + return availableHeight >= menuHeight ? 0 : keyboardOverlap + } + + static func sourceRect( + _ sourceRect: CGRect, + keyboardDismissalOffset: CGFloat + ) -> CGRect { + sourceRect.offsetBy(dx: 0, dy: keyboardDismissalOffset) + } +} + final class NativeAttachmentPopoverCoordinator: NSObject { private let channel: FlutterMethodChannel private weak var parentViewController: UIViewController? @@ -90,13 +155,36 @@ final class NativeAttachmentPopoverCoordinator: NSObject { } let sourceView = presenter.view - let convertedRect: CGRect + var convertedRect: CGRect if let window = sourceView?.window { convertedRect = sourceView?.convert(sourceRect, from: window) ?? sourceRect } else { convertedRect = sourceRect } + if let sourceView { + let keyboardDismissalOffset = + NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset( + sourceRect: convertedRect, + containerBounds: sourceView.bounds, + safeAreaInsets: sourceView.safeAreaInsets, + keyboardLayoutFrame: sourceView.keyboardLayoutGuide.layoutFrame, + menuHeight: NativeAttachmentMenuLayout.size( + compatibleWith: sourceView.traitCollection + ).height + ) + if keyboardDismissalOffset > 0 { + NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard( + in: sourceView.window + ) + convertedRect = + NativeAttachmentPopoverPresentationLayout.sourceRect( + convertedRect, + keyboardDismissalOffset: keyboardDismissalOffset + ) + } + } + let anchorView = makeSourceAnchor(frame: convertedRect) sourceView?.addSubview(anchorView) sourceAnchorView = anchorView @@ -175,6 +263,56 @@ final class NativeAttachmentPopoverCoordinator: NSObject { } } +enum NativeAttachmentMenuLayout { + static let itemCount: CGFloat = 4 + static let contentPadding: CGFloat = 16 + static let minimumItemHeight: CGFloat = 52 + static let itemSpacing: CGFloat = 8 + static let itemVerticalPadding: CGFloat = 8 + static let maximumHeight: CGFloat = 372 + static let width: CGFloat = 216 + static let labelTextStyle: UIFont.TextStyle = .title3 + + static func itemHeight( + compatibleWith traitCollection: UITraitCollection + ) -> CGFloat { + let labelHeight = UIFont.preferredFont( + forTextStyle: labelTextStyle, + compatibleWith: traitCollection + ).lineHeight + return max( + minimumItemHeight, + ceil(labelHeight + (itemVerticalPadding * 2)) + ) + } + + static func itemsHeight( + compatibleWith traitCollection: UITraitCollection + ) -> CGFloat { + (itemHeight(compatibleWith: traitCollection) * itemCount) + + (itemSpacing * (itemCount - 1)) + } + + static func contentHeight( + compatibleWith traitCollection: UITraitCollection + ) -> CGFloat { + (contentPadding * 2) + itemsHeight(compatibleWith: traitCollection) + } + + static func size( + compatibleWith traitCollection: UITraitCollection, + maximumHeight: CGFloat = NativeAttachmentMenuLayout.maximumHeight + ) -> CGSize { + CGSize( + width: width, + height: min( + contentHeight(compatibleWith: traitCollection), + maximumHeight + ) + ) + } +} + func makeNativeAttachmentMenuButton( title: String, symbol: String, @@ -200,7 +338,9 @@ func makeNativeAttachmentMenuButton( let titleLabel = UILabel() titleLabel.text = title titleLabel.textColor = .label - titleLabel.font = .preferredFont(forTextStyle: .body) + titleLabel.font = .preferredFont( + forTextStyle: NativeAttachmentMenuLayout.labelTextStyle + ) titleLabel.adjustsFontForContentSizeCategory = true titleLabel.textAlignment = .left titleLabel.translatesAutoresizingMaskIntoConstraints = false @@ -208,7 +348,10 @@ func makeNativeAttachmentMenuButton( button.addSubview(iconView) button.addSubview(titleLabel) NSLayoutConstraint.activate([ - iconView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14), + iconView.leadingAnchor.constraint( + equalTo: button.leadingAnchor, + constant: 8 + ), iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor), iconView.widthAnchor.constraint(equalToConstant: 26), titleLabel.leadingAnchor.constraint( @@ -217,7 +360,7 @@ func makeNativeAttachmentMenuButton( ), titleLabel.trailingAnchor.constraint( equalTo: button.trailingAnchor, - constant: -14 + constant: -8 ), titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor), ]) diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index a174c78684..c5333cfdf2 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -6,6 +6,179 @@ import XCTest class RunnerTests: XCTestCase { + @MainActor + func testExpandedAttachmentSurfaceDismissesKeyboard() { + let window = KeyboardDismissalSpyWindow() + + NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: window) + + XCTAssertTrue(window.didForceEndEditing) + } + + func testExpandedAttachmentSurfaceMeasuresKeyboardOverlap() { + XCTAssertEqual( + NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap( + containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844), + keyboardLayoutFrame: CGRect( + x: 0, + y: 544, + width: 390, + height: 300 + ) + ), + 300 + ) + XCTAssertEqual( + NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap( + containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844), + keyboardLayoutFrame: CGRect(x: 0, y: 844, width: 390, height: 0) + ), + 0 + ) + } + + func testAttachmentMenuReturnsToKeyboardDismissedAnchor() { + let anchorBounds = CGRect(x: 0, y: 0, width: 44, height: 44) + + XCTAssertEqual( + NativeAttachmentPopoverAnchorLayout.sourceRect( + anchorBounds: anchorBounds, + keyboardDismissalOffset: 300, + isExpanded: true + ), + anchorBounds.offsetBy(dx: 0, dy: 340) + ) + XCTAssertEqual( + NativeAttachmentPopoverAnchorLayout.sourceRect( + anchorBounds: anchorBounds, + keyboardDismissalOffset: 300, + isExpanded: false + ), + anchorBounds.offsetBy(dx: 0, dy: 300) + ) + } + + func testAttachmentMenuKeepsKeyboardWhenMenuFitsAboveTrigger() { + XCTAssertEqual( + NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset( + sourceRect: CGRect(x: 320, y: 480, width: 44, height: 44), + containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844), + safeAreaInsets: UIEdgeInsets(top: 59, left: 0, bottom: 34, right: 0), + keyboardLayoutFrame: CGRect( + x: 0, + y: 544, + width: 390, + height: 300 + ), + menuHeight: NativeAttachmentMenuLayout.size( + compatibleWith: UITraitCollection( + preferredContentSizeCategory: .large + ) + ).height + ), + 0 + ) + } + + func testAttachmentMenuDismissesKeyboardAndRepositionsInCompactHeight() { + let sourceRect = CGRect(x: 760, y: 168, width: 44, height: 44) + let keyboardDismissalOffset = + NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset( + sourceRect: sourceRect, + containerBounds: CGRect(x: 0, y: 0, width: 844, height: 390), + safeAreaInsets: UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59), + keyboardLayoutFrame: CGRect( + x: 0, + y: 228, + width: 844, + height: 162 + ), + menuHeight: NativeAttachmentMenuLayout.size( + compatibleWith: UITraitCollection( + preferredContentSizeCategory: .large + ) + ).height + ) + + XCTAssertEqual(keyboardDismissalOffset, 162) + XCTAssertEqual( + NativeAttachmentPopoverPresentationLayout.sourceRect( + sourceRect, + keyboardDismissalOffset: keyboardDismissalOffset + ), + sourceRect.offsetBy(dx: 0, dy: 162) + ) + } + + func testAttachmentMenuDoesNotMoveWithoutSoftwareKeyboard() { + XCTAssertEqual( + NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset( + sourceRect: CGRect(x: 760, y: 168, width: 44, height: 44), + containerBounds: CGRect(x: 0, y: 0, width: 844, height: 390), + safeAreaInsets: UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59), + keyboardLayoutFrame: CGRect(x: 0, y: 390, width: 844, height: 0), + menuHeight: NativeAttachmentMenuLayout.size( + compatibleWith: UITraitCollection( + preferredContentSizeCategory: .large + ) + ).height + ), + 0 + ) + } + + func testEmbeddedPhotoPickerAppliesOneZoomInStepWithoutAnimation() { + var zoomInCalls = 0 + var animationsWereEnabled = true + + EmbeddedPhotoPickerLayout.applyPreferredScale { + zoomInCalls += 1 + animationsWereEnabled = UIView.areAnimationsEnabled + } + + XCTAssertEqual(zoomInCalls, 1) + XCTAssertFalse(animationsWereEnabled) + } + + func testNativeAttachmentMenuUsesRoomyRowsAndInsets() { + let traits = UITraitCollection(preferredContentSizeCategory: .large) + let size = NativeAttachmentMenuLayout.size(compatibleWith: traits) + + XCTAssertEqual(size.width, 216) + XCTAssertEqual(size.height, 264) + XCTAssertEqual(NativeAttachmentMenuLayout.contentPadding, 16) + XCTAssertEqual( + NativeAttachmentMenuLayout.itemHeight(compatibleWith: traits), + 52 + ) + XCTAssertEqual(NativeAttachmentMenuLayout.itemSpacing, 8) + XCTAssertEqual(NativeAttachmentMenuLayout.labelTextStyle, .title3) + } + + func testNativeAttachmentMenuGrowsAndScrollsForAccessibilityText() { + let traits = UITraitCollection( + preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge + ) + let itemHeight = NativeAttachmentMenuLayout.itemHeight( + compatibleWith: traits + ) + let contentHeight = NativeAttachmentMenuLayout.contentHeight( + compatibleWith: traits + ) + let size = NativeAttachmentMenuLayout.size(compatibleWith: traits) + + XCTAssertGreaterThan(itemHeight, 52) + XCTAssertGreaterThan(contentHeight, 264) + XCTAssertEqual( + size.height, + min(contentHeight, NativeAttachmentMenuLayout.maximumHeight) + ) + XCTAssertLessThanOrEqual( + size.height, + NativeAttachmentMenuLayout.maximumHeight + ) + } + func testDynamicIslandQrScannerRecognizesTallSafeAreas() { for safeAreaTopInset in [51, 59, 62] { XCTAssertTrue( @@ -228,6 +401,15 @@ class RunnerTests: XCTestCase { } } +private final class KeyboardDismissalSpyWindow: UIWindow { + private(set) var didForceEndEditing = false + + override func endEditing(_ force: Bool) -> Bool { + didForceEndEditing = force + return true + } +} + private enum RelayImagePolicyError: Error { case invalidPng case invalidJpeg diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 8e7ac95644..4a49c9b210 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -55,7 +55,10 @@ class _MessageBubble extends ConsumerWidget { return Material( color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - clipBehavior: Clip.antiAlias, + // The media carousel intentionally continues through the list's trailing + // gutter. InkWell still clips its ink to [borderRadius], while leaving + // overflowing message content visible. + clipBehavior: Clip.none, child: InkWell( key: ValueKey('message-row-${message.id}'), borderRadius: BorderRadius.circular(Radii.md), diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index a2421c9339..6c44285a4e 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -736,7 +736,6 @@ class ComposeBar extends HookConsumerWidget { return; } - focusNode.unfocus(); unawaited( iosAttachmentPopover .present( @@ -767,7 +766,10 @@ class ComposeBar extends HookConsumerWidget { }), ) .then((didPresent) { - if (!didPresent && context.mounted) toggleAttachments(); + if (!didPresent && context.mounted) { + focusNode.unfocus(); + toggleAttachments(); + } }), ); } diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 9d9f278edc..7c53ae1098 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -2,10 +2,53 @@ part of '../compose_bar.dart'; enum _AttachmentSurface { closed, menu, camera, photos } -const _attachmentMenuWidth = 176.0; -const _attachmentMenuHeight = 208.0; +const _attachmentMenuWidth = 216.0; +const _attachmentMenuPadding = Grid.xs; +const _attachmentMenuItemHeight = 52.0; +const _attachmentMenuItemSpacing = Grid.xxs; +const _attachmentMenuIconSize = 24.0; +const _attachmentMenuIconSlotWidth = 28.0; const _attachmentExpandedHeight = 372.0; +@immutable +class _AttachmentMenuLayout { + final double itemHeight; + final double contentHeight; + final double height; + + const _AttachmentMenuLayout({ + required this.itemHeight, + required this.contentHeight, + required this.height, + }); + + factory _AttachmentMenuLayout.from(BuildContext context) { + final textPainter = TextPainter( + text: TextSpan(text: 'Camera', style: context.textTheme.titleMedium), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + final itemHeight = math.max( + _attachmentMenuItemHeight, + textPainter.height + (Grid.xxs * 2), + ); + textPainter.dispose(); + final contentHeight = + (_attachmentMenuPadding * 2) + + (itemHeight * 4) + + (_attachmentMenuItemSpacing * 3); + + return _AttachmentMenuLayout( + itemHeight: itemHeight, + contentHeight: contentHeight, + height: math.min(contentHeight, _attachmentExpandedHeight), + ); + } + + bool get isScrollable => contentHeight > height; +} + class _AttachmentSurfacePanel extends HookWidget { final _AttachmentSurface surface; final Widget suggestionPanel; @@ -36,6 +79,7 @@ class _AttachmentSurfacePanel extends HookWidget { Widget build(BuildContext context) { if (surface == _AttachmentSurface.closed) return suggestionPanel; + final menuLayout = _AttachmentMenuLayout.from(context); final reducedMotion = MediaQuery.disableAnimationsOf(context); final isExpanded = surface == _AttachmentSurface.camera || @@ -81,10 +125,24 @@ class _AttachmentSurfacePanel extends HookWidget { final visibleExpandedSurface = renderedExpandedSurface.value ?? (isExpanded ? surface : null); + final cameraInitializationReady = + reducedMotion || + (surface == _AttachmentSurface.camera && rawProgress >= 1); final expandedContent = switch (visibleExpandedSurface) { _AttachmentSurface.camera => KeyedSubtree( key: const ValueKey('camera-preview'), - child: _InlineCameraPreview(onClose: onBack, onCapture: onCapture), + child: KeyedSubtree( + key: ValueKey( + cameraInitializationReady + ? 'camera-initialization-ready' + : 'camera-initialization-deferred', + ), + child: _InlineCameraPreview( + initializeCamera: cameraInitializationReady, + onClose: onBack, + onCapture: onCapture, + ), + ), ), _AttachmentSurface.photos => KeyedSubtree( key: const ValueKey('photo-gallery'), @@ -117,8 +175,8 @@ class _AttachmentSurfacePanel extends HookWidget { _attachmentMenuWidth + ((expandedWidth - _attachmentMenuWidth) * sizeProgress); final height = - _attachmentMenuHeight + - ((expandedHeight - _attachmentMenuHeight) * sizeProgress); + menuLayout.height + + ((expandedHeight - menuLayout.height) * sizeProgress); final baseColor = context.colors.surfaceContainerHighest; final expandedColor = visibleExpandedSurface == _AttachmentSurface.camera @@ -151,12 +209,13 @@ class _AttachmentSurfacePanel extends HookWidget { left: 0, top: 0, width: _attachmentMenuWidth, - height: _attachmentMenuHeight, + height: menuLayout.height, child: IgnorePointer( ignoring: surface != _AttachmentSurface.menu, child: Opacity( opacity: menuOpacity, child: _AttachmentMenu( + layout: menuLayout, onCamera: onCamera, onPhotos: onPhotos, onVideo: onVideo, @@ -289,12 +348,14 @@ class _AttachmentTrigger extends StatelessWidget { } class _AttachmentMenu extends StatelessWidget { + final _AttachmentMenuLayout layout; final VoidCallback onCamera; final VoidCallback onPhotos; final VoidCallback onVideo; final VoidCallback onFiles; const _AttachmentMenu({ + required this.layout, required this.onCamera, required this.onPhotos, required this.onVideo, @@ -304,46 +365,45 @@ class _AttachmentMenu extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( + key: const ValueKey('attachment-menu'), width: _attachmentMenuWidth, - height: _attachmentMenuHeight, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _AttachmentMenuItem( - icon: LucideIcons.camera, - label: 'Camera', - onTap: onCamera, - ), - _AttachmentMenuItem( - icon: LucideIcons.images, - label: 'Photos', - onTap: onPhotos, - ), - _AttachmentMenuItem( - icon: LucideIcons.video, - label: 'Video', - onTap: onVideo, - ), - _AttachmentMenuItem( - icon: LucideIcons.file, - label: 'Files', - onTap: onFiles, - ), - ], - ), + height: layout.height, + child: ListView.separated( + key: const ValueKey('attachment-menu-scroll'), + padding: const EdgeInsets.all(_attachmentMenuPadding), + physics: layout.isScrollable + ? null + : const NeverScrollableScrollPhysics(), + itemCount: 4, + separatorBuilder: (_, _) => + const SizedBox(height: _attachmentMenuItemSpacing), + itemBuilder: (context, index) { + final (icon, label, onTap) = switch (index) { + 0 => (LucideIcons.camera, 'Camera', onCamera), + 1 => (LucideIcons.images, 'Photos', onPhotos), + 2 => (LucideIcons.video, 'Video', onVideo), + _ => (LucideIcons.file, 'Files', onFiles), + }; + return _AttachmentMenuItem( + height: layout.itemHeight, + icon: icon, + label: label, + onTap: onTap, + ); + }, ), ); } } class _AttachmentMenuItem extends StatelessWidget { + final double height; final IconData icon; final String label; final VoidCallback onTap; const _AttachmentMenuItem({ + required this.height, required this.icon, required this.label, required this.onTap, @@ -352,21 +412,40 @@ class _AttachmentMenuItem extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( - height: 48, + key: ValueKey('attachment-menu-item-${label.toLowerCase()}'), + height: height, child: Tooltip( message: label, child: InkWell( onTap: onTap, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Row( children: [ - Icon(icon, size: 20, color: context.colors.onSurfaceVariant), + SizedBox( + key: ValueKey('attachment-menu-icon-${label.toLowerCase()}'), + width: _attachmentMenuIconSlotWidth, + child: Center( + child: Icon( + icon, + size: _attachmentMenuIconSize, + color: context.colors.onSurfaceVariant, + ), + ), + ), const SizedBox(width: Grid.xxs), - Text( - label, - style: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurface, + Expanded( + child: Text( + label, + key: ValueKey( + 'attachment-menu-label-${label.toLowerCase()}', + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.titleMedium?.copyWith( + color: context.colors.onSurface, + fontWeight: FontWeight.w400, + ), ), ), ], diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index 6179bcfb2f..e51c2d16aa 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -1,10 +1,15 @@ part of '../compose_bar.dart'; class _InlineCameraPreview extends HookConsumerWidget { + final bool initializeCamera; final Future Function(XFile image) onCapture; final VoidCallback onClose; - const _InlineCameraPreview({required this.onCapture, required this.onClose}); + const _InlineCameraPreview({ + required this.initializeCamera, + required this.onCapture, + required this.onClose, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -15,6 +20,8 @@ class _InlineCameraPreview extends HookConsumerWidget { final error = useState(null); useEffect(() { + if (!initializeCamera) return null; + var disposed = false; var generation = 0; @@ -76,7 +83,9 @@ class _InlineCameraPreview extends HookConsumerWidget { onInactive: () => unawaited(disposeCurrent()), onResume: () => unawaited(initialize()), ); - unawaited(initialize()); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!disposed) unawaited(initialize()); + }); return () { disposed = true; @@ -86,7 +95,7 @@ class _InlineCameraPreview extends HookConsumerWidget { controllerRef.value = null; unawaited(current?.dispose() ?? Future.value()); }; - }, const []); + }, [initializeCamera]); Future capture() async { final activeController = controller.value; diff --git a/mobile/lib/features/channels/message_content/media_carousel.dart b/mobile/lib/features/channels/message_content/media_carousel.dart index 88bfed5b1d..545adba36c 100644 --- a/mobile/lib/features/channels/message_content/media_carousel.dart +++ b/mobile/lib/features/channels/message_content/media_carousel.dart @@ -141,7 +141,7 @@ class _MessageImageCarousel extends HookConsumerWidget { fontWeight: FontWeight.w400, ), ), - const SizedBox(height: Grid.half), + const SizedBox(height: Grid.half + Grid.quarter), LayoutBuilder( builder: (context, constraints) { final contentWidth = constraints.hasBoundedWidth @@ -152,12 +152,16 @@ class _MessageImageCarousel extends HookConsumerWidget { final leadingExtent = leadingOverflow; final isLeftToRight = Directionality.of(context) == TextDirection.ltr; + final itemTrailingPaddings = [ + for (var index = 0; index < items.length; index++) + index == items.length - 1 ? Grid.gutter : Grid.half, + ]; final previewDecodeWidths = [ for (var index = 0; index < items.length; index++) math.max( 1.0, carouselWidth * controller.viewportFraction - - (index == items.length - 1 ? 0 : Grid.half), + itemTrailingPaddings[index], ), ]; final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); @@ -190,6 +194,10 @@ class _MessageImageCarousel extends HookConsumerWidget { height: _messageMediaCarouselHeight, child: PageView.builder( controller: controller, + allowImplicitScrolling: true, + // Keep the first image aligned with the message body, but + // allow later pages to paint through the avatar gutter as + // the row scrolls. clipBehavior: Clip.none, padEnds: false, itemCount: items.length, @@ -197,8 +205,9 @@ class _MessageImageCarousel extends HookConsumerWidget { itemBuilder: (context, index) { final item = items[index]; return Padding( + key: ValueKey('message-media-carousel-page:${item.url}'), padding: EdgeInsetsDirectional.only( - end: index == items.length - 1 ? 0 : Grid.half, + end: itemTrailingPaddings[index], ), child: Semantics( button: true, diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index a6d75a9f05..0babba2394 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -504,7 +504,10 @@ class _ThreadMessage extends ConsumerWidget { child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - clipBehavior: Clip.antiAlias, + // The media carousel intentionally continues through the list's + // trailing gutter. InkWell still clips its ink to [borderRadius], + // while leaving overflowing message content visible. + clipBehavior: Clip.none, child: InkWell( key: ValueKey('thread-message-row-${message.id}'), borderRadius: BorderRadius.circular(Radii.md), diff --git a/mobile/lib/shared/widgets/message_author_meta.dart b/mobile/lib/shared/widgets/message_author_meta.dart index bc69506cc9..c7aff7df82 100644 --- a/mobile/lib/shared/widgets/message_author_meta.dart +++ b/mobile/lib/shared/widgets/message_author_meta.dart @@ -83,7 +83,7 @@ class MessageAuthorMeta extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded(child: authorName), + Flexible(child: authorName), if (showUsername) ...[ const SizedBox(width: Grid.half), ConstrainedBox( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 2dc01a9a0b..48ad861d3a 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -749,6 +749,63 @@ void main() { ); }); + testWidgets( + 'keeps image galleries body-aligned and flush with the trailing edge', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + const firstImage = 'https://example.com/media/first.png'; + const secondImage = 'https://example.com/media/second.png'; + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'gallery', + pubkey: 'alice', + content: + 'Gallery\n' + '![First]($firstImage)\n' + '![Second]($secondImage)', + extraTags: const [ + ['imeta', 'url $firstImage', 'm image/png'], + ['imeta', 'url $secondImage', 'm image/png'], + ], + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + final carousel = find.byKey(const ValueKey('message-media-carousel')); + final imageCount = find.byKey( + const ValueKey('message-media-carousel-count'), + ); + final carouselRect = tester.getRect(carousel); + final imageCountRect = tester.getRect(imageCount); + + expect(carouselRect.left, imageCountRect.left); + expect(carouselRect.right, tester.view.physicalSize.width); + expect(carouselRect.top - imageCountRect.bottom, Grid.half + 2); + + final messageMaterial = find + .ancestor( + of: find.byKey(const ValueKey('message-row-gallery')), + matching: find.byType(Material), + ) + .first; + expect( + tester.widget(messageMaterial).clipBehavior, + Clip.none, + ); + }, + ); + testWidgets('uses larger participant avatars in reply summaries', ( tester, ) async { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index bf236e8151..7b747adfac 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -544,6 +544,104 @@ void main() { } }); + testWidgets('opening the native attachment popover keeps composer focus', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + var presentCalls = 0; + _setMockNativeAttachmentPopoverHandler((call) async { + switch (call.method) { + case 'isSupported': + return true; + case 'present': + presentCalls += 1; + return true; + case 'dismiss': + return null; + } + return null; + }); + + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), 'Hello'); + await tester.pumpAndSettle(); + + final textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode?.hasFocus, isTrue); + + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + + expect(presentCalls, 1); + expect(textField.focusNode?.hasFocus, isTrue); + } finally { + await _sendNativeAttachmentPopoverCall(tester, 'dismissed'); + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + + testWidgets( + 'unsupported iOS attachment popover unfocuses before fallback menu', + (tester) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + _setMockNativeAttachmentPopoverHandler((call) async { + return switch (call.method) { + 'isSupported' => false, + 'dismiss' => null, + _ => null, + }; + }); + + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), 'Hello'); + await tester.pumpAndSettle(); + + final textField = tester.widget(find.byType(TextField)); + expect(textField.focusNode?.hasFocus, isTrue); + + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + + expect(textField.focusNode?.hasFocus, isFalse); + expect(find.byKey(const ValueKey('attachment-menu')), findsOneWidget); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + _setMockNativeAttachmentPopoverHandler(null); + debugDefaultTargetPlatformOverride = previousPlatform; + } + }, + ); + testWidgets('disposing a non-owner keeps native popover callbacks active', ( tester, ) async { @@ -1150,6 +1248,161 @@ void main() { expect(find.text('Photos'), findsOneWidget); }); + testWidgets('attachment menu uses roomy rows and surrounding padding', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _openAttachmentMenu(tester); + + final menu = find.byKey(const ValueKey('attachment-menu')); + final rows = [ + for (final label in ['camera', 'photos', 'video', 'files']) + find.byKey(ValueKey('attachment-menu-item-$label')), + ]; + final menuRect = tester.getRect(menu); + + expect(menuRect.size, const Size(216, 264)); + for (final row in rows) { + expect(tester.getSize(row).height, 52); + expect(tester.getRect(row).left - menuRect.left, Grid.xs); + expect(menuRect.right - tester.getRect(row).right, Grid.xs); + } + for (final label in ['Camera', 'Photos', 'Video', 'Files']) { + final text = tester.widget(find.text(label)); + expect(text.style?.fontSize, 20); + } + final icons = [ + for (final label in ['camera', 'photos', 'video', 'files']) + find.byKey(ValueKey('attachment-menu-icon-$label')), + ]; + final labels = [ + for (final label in ['camera', 'photos', 'video', 'files']) + find.byKey(ValueKey('attachment-menu-label-$label')), + ]; + for (final icon in icons) { + expect(tester.getSize(icon).width, 28); + expect( + tester + .widget( + find.descendant(of: icon, matching: find.byType(Icon)), + ) + .size, + 24, + ); + } + final labelLeft = tester.getRect(labels.first).left; + for (var index = 0; index < labels.length; index += 1) { + expect(tester.getRect(labels[index]).left, labelLeft); + expect( + tester.getRect(labels[index]).center.dy, + tester.getRect(rows[index]).center.dy, + ); + } + expect(tester.getRect(rows.first).top - menuRect.top, Grid.xs); + expect(menuRect.bottom - tester.getRect(rows.last).bottom, Grid.xs); + for (var index = 1; index < rows.length; index += 1) { + expect( + tester.getRect(rows[index]).top - + tester.getRect(rows[index - 1]).bottom, + Grid.xxs, + ); + } + }); + + testWidgets( + 'attachment menu grows rows and scrolls for accessibility text', + (tester) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + textScaler: const TextScaler.linear(4), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _openAttachmentMenu(tester); + + final menu = find.byKey(const ValueKey('attachment-menu')); + final rows = [ + for (final label in ['camera', 'photos', 'video', 'files']) + find.byKey(ValueKey('attachment-menu-item-$label')), + ]; + final scrollView = tester.widget( + find.byKey(const ValueKey('attachment-menu-scroll')), + ); + + expect(tester.getSize(menu), const Size(216, 372)); + expect(tester.getSize(rows.first).height, greaterThan(52)); + expect(scrollView.physics, isA()); + await tester.drag( + find.byKey(const ValueKey('attachment-menu-scroll')), + const Offset(0, -300), + ); + await tester.pump(); + expect(tester.getSize(rows.last).height, greaterThan(52)); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('defers camera startup until the surface morph finishes', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Camera')); + await tester.pump(); + + expect( + find.byKey(const ValueKey('camera-initialization-deferred')), + findsOneWidget, + ); + + await tester.pump(const Duration(milliseconds: 300)); + expect( + find.byKey(const ValueKey('camera-initialization-deferred')), + findsOneWidget, + ); + + await tester.pump(const Duration(milliseconds: 20)); + expect( + find.byKey(const ValueKey('camera-initialization-ready')), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('photo picker errors keep the action visible at large text', ( tester, ) async { diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 91e31eee22..77fab8ef09 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -539,6 +539,51 @@ Photos }, ); + testWidgets( + 'keeps adjacent carousel images active and ends with a gutter', + (tester) async { + const first = 'https://example.com/media/gutter-one.png'; + const second = 'https://example.com/media/gutter-two.png'; + await tester.pumpWidget( + _testable( + const MessageContent( + content: + ''' +![image]($first) +![image]($second) +''', + tags: [ + ['imeta', 'url $first', 'm image/png'], + ['imeta', 'url $second', 'm image/png'], + ], + ), + ), + ); + await tester.pumpAndSettle(); + + final carousel = find.byKey(const ValueKey('message-media-carousel')); + final pageViewFinder = find.descendant( + of: carousel, + matching: find.byType(PageView), + ); + final pageView = tester.widget(pageViewFinder); + + expect(pageView.allowImplicitScrolling, isTrue); + expect(pageView.clipBehavior, Clip.none); + + pageView.controller!.jumpToPage(1); + await tester.pumpAndSettle(); + + final lastCard = find.byKey( + const ValueKey('message-media-carousel-item:$second'), + ); + expect( + tester.getRect(carousel).right - tester.getRect(lastCard).right, + Grid.gutter, + ); + }, + ); + testWidgets( 'jumps to a selected gallery thumbnail when motion is disabled', (tester) async { diff --git a/mobile/test/shared/widgets/message_author_meta_test.dart b/mobile/test/shared/widgets/message_author_meta_test.dart index a97af86130..3b55946d47 100644 --- a/mobile/test/shared/widgets/message_author_meta_test.dart +++ b/mobile/test/shared/widgets/message_author_meta_test.dart @@ -4,6 +4,41 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('keeps the separator and timestamp next to a short name', ( + tester, + ) async { + const displayNameKey = Key('author-display-name'); + const timestampKey = Key('author-timestamp'); + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: const Scaffold( + body: SizedBox( + width: 300, + child: MessageAuthorMeta( + displayName: 'Alice', + timestamp: '2m', + displayNameKey: displayNameKey, + timestampKey: timestampKey, + nameColor: Colors.black, + metadataColor: Colors.grey, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final displayNameRect = tester.getRect(find.byKey(displayNameKey)); + final separatorRect = tester.getRect(find.text('·')); + final timestampRect = tester.getRect(find.byKey(timestampKey)); + + expect(separatorRect.left - displayNameRect.right, Grid.half); + expect(timestampRect.left - separatorRect.right, Grid.half); + expect(tester.takeException(), isNull); + }); + testWidgets('reallocates unused metadata width to the display name', ( tester, ) async { From c405ad1d4b1da061c11b3d26761252d41dcc62d3 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Wed, 29 Jul 2026 08:36:09 -0500 Subject: [PATCH 19/99] feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) (#3463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > On 8 tasks matched by name across the two runs, cost fell $8.36 → $1.77 (4.71×) and wall-clock 12,423 s → 1,085 s (11.45×). ## Summary Two independent, self-contained fixes to `buzz-agent`/`buzz-acp`, split out of the benchmark branch so they can land while the harness work continues: 1. **Request and surface Anthropic prompt caching.** buzz never sent a `cache_control` breakpoint, so on the Databricks Anthropic route `cache_read_input_tokens` was **structurally always 0** and the ~10× cache-read discount was never claimed. This teaches `anthropic_body()` to mark the cacheable prefix, and plumbs the cache split end-to-end so accounting can price it. 2. **Pass proxy + TLS-trust env into MCP tool subprocesses**, so agent tools on a proxy-only host stop reporting a live network as offline. ## Why the caching gap matters The Anthropic Messages API does **not** cache unless the request carries a `cache_control` breakpoint, and the Databricks AI Gateway — a third-party proxy in front of the model, in the same category as Bedrock/Vertex — does **not** auto-cache (only the first-party Anthropic API and Claude-on-AWS do zero-config caching). So every request was billed cold. Measured live against the Databricks gateway (`databricks-claude-opus-5`, 2026-07-28), the same call with and without a single `cache_control` marker: | Run | `input_tokens` | `cache_creation` | `cache_read` | latency | |---|---|---|---|---| | No `cache_control`, two byte-identical calls | 121,625 | 0 | **0** | ~9.3 s | | With one marker — cold (write) | 4 | 121,625 | 0 | 9.3 s | | With one marker — warm (read) | 4 | 0 | **121,625** | **4.5 s** | One marker moved 121,625 tokens from full-price input to a 0.1× cache read and roughly halved latency (a clean, isolated ~2.07× prefill speedup on this single-threaded microbenchmark). The gateway honours `cache_control`; buzz simply never sent it. At fleet scale this was a real budget item. Across matched Terminal-Bench solo sweeps (89 tasks, `-n 20`, before the fix), the two OpenAI-route models independently landed at ~86–87% cache reads — the expected shape for an agentic loop, where system + tools + append-only history repeat every turn — while the Anthropic route returned a hard 0% on every receipt: | Condition | Route | Input tokens | Cache reads | Cost | Cost if uncached | Discount | |---|---|---|---|---|---|---| | luna (`gpt-5-6`) | OpenAI | 20,320,818 | **17.7M (87.0%)** | $6.96 | $22.87 | **3.28×** | | sol (`gpt-5-6`) | OpenAI | 22,312,290 | **19.2M (85.9%)** | $37.07 | $123.35 | **3.33×** | | opus (`claude-opus-5`) | Anthropic | 12,459,822 | **0 (0.0%)** | $81.31 | $81.31 | **1.00×** | Applying luna's measured 87% read rate to the opus token counts at list prices (`input $5/M`, `cached_input $0.5/M`, `output $25/M`) puts the opus run at **~$32.53 vs the $81.31 actually paid — a ~60% overspend on those 49 trials (~$89 on a full sweep)**. That is an upper bound (it prices every cached token at the 0.1× read rate and ignores the 1.25× write premium), and the opus discount is structurally smaller than luna/sol's because opus emits ~3.5× more uncacheable output per trial, which sets a floor on what caching can recover. There is also a plausible **second-order effect**: Databricks appears to meter its per-minute rate limit on *uncached* input tokens, so the missing cache also cost rate-limit headroom — the opus endpoint lost 63% of its trials to fatal 429s while running alone at one-third of a GPT endpoint's raw throughput. This is a hypothesis, not a proven mechanism (the only zero-cache condition is also the only Anthropic endpoint), but it is the reading that explains the throttling with one rule instead of two. ## Post-fix results (provisional — first trials of an in-flight re-run) On 8 tasks matched by name across the two runs, cost fell **$8.36 → $1.77 (4.71×)** and wall-clock **12,423 s → 1,085 s (11.45×)**. | Metric | before (`4a955a858`) | after (`3bef1f6a`) | |---|---|---| | Cache reads as % of input | **0.0%** | **78.7%** (still climbing toward the ~86% steady state) | | `cost_usd_no_cache_discount / cost_usd` | **1.00×** | **2.18×** (tracking the projected ~2.5×) | | Trials with a fatal 429 (same `-n 20`) | **63%** | **15–19%** | To be clear about attribution: **~2× of that is the clean prefill saving from caching itself**; the rest is second-order — cached requests burn far less rate-limit budget, so they stall less and redo less destroyed work. The 11.45× is a system-level result specific to this throttled workspace, not a caching benchmark. Quality held (7/8 solved in each run). A controlled low-`-n` A/B (neither arm hitting a 429), which the `BUZZ_AGENT_PROMPT_CACHING` opt-out exists to enable, is still owed before this becomes a published claim. ## What changed ### 1. Request caching (`llm.rs`, `config.rs`) `anthropic_body()` emits ephemeral `cache_control` breakpoints, gated by `BUZZ_AGENT_PROMPT_CACHING` (**default on**, `=0` to opt out): - **Static prefix** — marker on the `system` block. Prefix order is `tools → system → messages`, so this single marker caches **tools + system** together. Byte-identical on every turn of a run, and survives a context handoff (system/tools come from cfg/mcp, not `self.history`). - **Rolling tail + leapfrog** — marker on the last block of the last **two** messages. The append-only history re-reads the prior turn's prefix from cache; marking two messages (not one) keeps consecutive breakpoints inside Anthropic's **20-block lookback window** even as tool parallelism rises, avoiding a silent full-price miss. An empty system prompt stays a bare string (Anthropic rejects empty text blocks), and below-threshold prefixes are silently not cached, so the flag is safe on by default. ### 2. Surface the cache split end-to-end — the plumbing (`types.rs`, `llm.rs`, `agent.rs`, `lib.rs`, `usage.rs`, `acp.rs`) This is the part that makes gaps like the one above **visible** instead of silent. A consumer that prices all of `input_tokens` at the full rate can't tell a route that's caching from one that isn't — the total looks right either way. So: - `LlmResponse` gains `cached_input_tokens` (a **subset** of `input_tokens`, never an addition); `parse_anthropic` / `parse_openai` / `parse_responses` each populate it. - A `usage_first()` helper reads the cache count wherever a provider hides it — flat `cache_read_input_tokens` (Anthropic), `prompt_tokens_details.cached_tokens` (OpenAI chat), `input_tokens_details.cached_tokens` (Responses) — taking the **first present value, never a sum**. Reading only flat keys is exactly why the OpenAI route's nested `cached_tokens` had *also* been going unclaimed: `prompt_tokens` is already inclusive, so the total looked correct while the discount silently went unreported. - The per-turn/per-session accumulators and the goose `usage_update` payload now carry `accumulatedCachedInputTokens`; `buzz-acp` deserializes it (`serde` default `0` for goose, which doesn't send it) and logs `cached=`. ### 3. Fix a Databricks MLflow-route double-count (`llm.rs`) The Databricks MLflow route reports the flat Anthropic-spelled `cache_read_input_tokens` *alongside* an already-inclusive `prompt_tokens`, so the old code summed them and nearly doubled the count — inflating both the context-budget gate and cost. `openai_chat_input_tokens()` now reads `prompt_tokens` alone. Verified on a live `databricks-glm-5-2` response where `prompt_tokens + completion == total` proves inclusivity. (Anthropic's native route genuinely *excludes* the cache fields and is still summed — the two never collide, because `claude*` models route to the Anthropic path.) ### 4. Proxy + TLS-trust passthrough into MCP tools (`mcp.rs`) — independent fix `buzz-agent` `env_clear()`s each MCP child, and the allowlist carried no proxy/TLS vars. On a proxy-only host that doesn't degrade the tools, it **blinds** them: apt, curl, pip, git connect directly, the egress firewall resets the socket, and the agent reports "Connection reset by peer" — indistinguishable from a genuinely offline task. Adds both spellings of `HTTP(S)_PROXY`/`NO_PROXY`/`ALL_PROXY` (curl/git read lowercase; Go/Python read uppercase; libcurl ignores uppercase `HTTP_PROXY`) plus `SSL_CERT_FILE`/`SSL_CERT_DIR` for TLS-terminating proxies that present their own CA. ## Testing - `cargo fmt --all -- --check`, `cargo clippy -p buzz-agent -p buzz-acp --all-targets -- -D warnings` — clean. - `cargo test -p buzz-agent -p buzz-acp` — **all green** (632 + 299 lib tests plus integration suites, 0 failures). New tests cover: the three breakpoints and the disabled/empty-system/single-message edge cases; nested-vs-flat cache parsing for all three routes; the Databricks inclusive-`prompt_tokens` fix; wire deserialization of `accumulatedCachedInputTokens`; and the proxy/TLS passthrough allowlist. - Pre-push lefthook suite green (branch-skew, rust-tests, test, desktop-check/test/tauri). ## Relationship to the benchmark branch These are the non-`benchmarks/` changes from `benchmark/harness-accounting-and-solo`, lifted onto a clean base off `main` so they can merge independently. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Atish Patel Co-authored-by: Claude Opus 4.8 (1M context) --- crates/buzz-acp/src/acp.rs | 5 + crates/buzz-acp/src/usage.rs | 40 ++++ crates/buzz-agent/src/agent.rs | 17 ++ crates/buzz-agent/src/config.rs | 9 + crates/buzz-agent/src/lib.rs | 24 +- crates/buzz-agent/src/llm.rs | 376 ++++++++++++++++++++++++++++++-- crates/buzz-agent/src/mcp.rs | 60 +++++ crates/buzz-agent/src/types.rs | 11 + 8 files changed, 519 insertions(+), 23 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 9eb668cbc2..3514580519 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -1848,6 +1848,11 @@ impl AcpClient { session_id = %notif.session_id, input = payload.accumulated_input_tokens, output = payload.accumulated_output_tokens, + // A subset of `input`, logged so downstream accounting can + // price it at the provider's cached rate. Always emitted, + // including as 0, so a parser can tell "no cache hits" + // apart from "this build predates the field". + cached = payload.accumulated_cached_input_tokens, "goose usage update" ); self.goose_usage.record(¬if.session_id, payload); diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index a4f7abd3b3..8cca9c96f8 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -85,6 +85,12 @@ pub(crate) struct UsageUpdatePayload { pub context_limit: u64, pub accumulated_input_tokens: u64, pub accumulated_output_tokens: u64, + /// The cache-served subset of `accumulated_input_tokens`. Optional — goose + /// does not send it, and buzz-agent only reports a non-zero value when the + /// provider returned a cache split, so `0` legitimately means either "no + /// cache hits" or "provider reported none". + #[serde(default)] + pub accumulated_cached_input_tokens: u64, pub accumulated_cost: Option, /// Effective model id for this turn. Optional — goose payloads that /// predate this field deserialize cleanly as `None`. @@ -323,12 +329,44 @@ impl UsageTracker { mod tests { use super::*; + /// The camelCase key buzz-agent actually puts on the wire must land on the + /// field. A rename mismatch here would deserialize to the serde default of + /// 0, and every trial would price as if nothing had ever been cached — the + /// exact silent failure this field was added to remove. + #[test] + fn cached_input_tokens_deserialize_from_the_wire_key() { + let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ + "used": 15_247, + "contextLimit": 0, + "accumulatedInputTokens": 15_091, + "accumulatedOutputTokens": 156, + "accumulatedCachedInputTokens": 5_033, + })) + .expect("payload must deserialize"); + assert_eq!(p.accumulated_cached_input_tokens, 5_033); + assert!(p.accumulated_cached_input_tokens <= p.accumulated_input_tokens); + } + + /// goose does not send the field; its payloads must still deserialize. + #[test] + fn a_payload_without_the_cache_field_defaults_to_zero() { + let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ + "used": 500, + "contextLimit": 200_000, + "accumulatedInputTokens": 400, + "accumulatedOutputTokens": 100, + })) + .expect("payload must deserialize without the cache field"); + assert_eq!(p.accumulated_cached_input_tokens, 0); + } + fn payload(input: u64, output: u64, cost: Option) -> UsageUpdatePayload { UsageUpdatePayload { used: input + output, context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, + accumulated_cached_input_tokens: 0, accumulated_cost: cost, model: None, } @@ -340,6 +378,7 @@ mod tests { context_limit: 0, accumulated_input_tokens: input, accumulated_output_tokens: output, + accumulated_cached_input_tokens: 0, accumulated_cost: cost, model: None, } @@ -836,6 +875,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, + accumulated_cached_input_tokens: 0, accumulated_cost: cost, model: model.map(str::to_string), } diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 730e87b2e8..cbf27357f7 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -60,6 +60,11 @@ pub struct RunCtx<'a> { /// Accumulated output tokens across all LLM rounds in this turn, for /// NIP-AM metric publishing. Reset to `None` at turn start in `run()`. pub turn_output_tokens: &'a mut Option, + /// The cache-served subset of `turn_input_tokens`, accumulated across all + /// LLM rounds in this turn. Reset to `None` at turn start in `run()`. + /// Consumers price this slice at the provider's cached rate; without it + /// every round of a growing conversation is billed at full price. + pub turn_cached_input_tokens: &'a mut Option, } impl RunCtx<'_> { @@ -78,6 +83,7 @@ impl RunCtx<'_> { // Reset per-turn token accumulators for this prompt. *self.turn_input_tokens = None; *self.turn_output_tokens = None; + *self.turn_cached_input_tokens = None; let mut round = 0u32; // Per-prompt `_Stop` objection count. Bounded per prompt (not per @@ -175,6 +181,17 @@ impl RunCtx<'_> { *self.turn_output_tokens = Some(self.turn_output_tokens.unwrap_or(0).saturating_add(out)); } + // Accumulate the cache-served subset of this turn's input. Tracked + // separately from `turn_input_tokens` rather than subtracted from + // it: the input total must stay inclusive for the handoff gate, + // which cares how much context was sent, not what it cost. + if let Some(cached) = response.cached_input_tokens { + *self.turn_cached_input_tokens = Some( + self.turn_cached_input_tokens + .unwrap_or(0) + .saturating_add(cached), + ); + } if !response.reasoning.is_empty() { wire::send( diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 864fa7d237..037b67b3cb 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -738,6 +738,13 @@ pub struct Config { /// Thinking/reasoning effort level. `None` = use provider default (no /// thinking config sent). Set via `BUZZ_AGENT_THINKING_EFFORT`. pub thinking_effort: Option, + /// Emit Anthropic `cache_control` breakpoints on the stable prefix + /// (tools + system prompt) and the rolling conversation tail. Default on; + /// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Only consulted on Anthropic + /// Messages routes (first-party Anthropic and the DatabricksV2 Claude + /// route) — the Databricks gateway does not auto-cache, so without this the + /// surfaced `cache_read_input_tokens` is structurally always 0. + pub prompt_caching: bool, } impl Config { @@ -833,6 +840,7 @@ impl Config { hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, + prompt_caching: parse_env("BUZZ_AGENT_PROMPT_CACHING", 1u8)? != 0, }; cfg.validate()?; Ok(cfg) @@ -874,6 +882,7 @@ impl Config { hook_servers: HookServers::None, hints_enabled: false, thinking_effort: None, + prompt_caching: false, } } diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index e141b9860f..6745dd0f92 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -100,6 +100,11 @@ struct Session { accumulated_input_tokens: u64, /// Session-cumulative output tokens across all turns. accumulated_output_tokens: u64, + /// Session-cumulative cache-served input tokens across all turns — a subset + /// of `accumulated_input_tokens`, not an addition to it. Emitted alongside + /// it so a consumer can price the cached slice at the provider's discounted + /// rate instead of assuming every input token cost full price. + accumulated_cached_input_tokens: u64, } fn die(msg: String) -> ! { @@ -426,6 +431,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen effective_model: None, accumulated_input_tokens: 0, accumulated_output_tokens: 0, + accumulated_cached_input_tokens: 0, }, ); drop(sessions); @@ -672,6 +678,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender .unwrap_or(&app.cfg.model); let mut turn_input_tokens: Option = None; let mut turn_output_tokens: Option = None; + let mut turn_cached_input_tokens: Option = None; let mut ctx = RunCtx { cfg: &app.cfg, effective_model: effective_model_str, @@ -690,6 +697,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender last_request_history_bytes: &mut last_request_history_bytes, turn_input_tokens: &mut turn_input_tokens, turn_output_tokens: &mut turn_output_tokens, + turn_cached_input_tokens: &mut turn_cached_input_tokens, }; let result = ctx.run(p.prompt).await; if let Some(s) = app.sessions.lock().await.get_mut(&sid) { @@ -722,14 +730,21 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender s.accumulated_output_tokens = s .accumulated_output_tokens .saturating_add(turn_output_tokens.unwrap_or(0)); - Some((s.accumulated_input_tokens, s.accumulated_output_tokens)) + s.accumulated_cached_input_tokens = s + .accumulated_cached_input_tokens + .saturating_add(turn_cached_input_tokens.unwrap_or(0)); + Some(( + s.accumulated_input_tokens, + s.accumulated_output_tokens, + s.accumulated_cached_input_tokens, + )) } else { // Session is gone — the accumulated baseline no longer exists, so // there is nothing correct to emit. Skip the usage notification. None } }; - if let Some((accumulated_in, accumulated_out)) = accumulated { + if let Some((accumulated_in, accumulated_out, accumulated_cached)) = accumulated { wire::send( &wire_tx, goose_session_update( @@ -742,6 +757,11 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender "contextLimit": 0u64, "accumulatedInputTokens": accumulated_in, "accumulatedOutputTokens": accumulated_out, + // A subset of accumulatedInputTokens, not an addition to + // it. Extends goose's usage_update shape; a consumer that + // does not know the field ignores it and prices exactly as + // it did before. + "accumulatedCachedInputTokens": accumulated_cached, "model": effective_model_str, }), ), diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 23cef24e72..f2359f9ba3 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -720,6 +720,12 @@ fn anthropic_body( } } flush(&mut messages, &mut pending); + // Rolling cache breakpoint: mark the tail of the (append-only) conversation + // so the next turn re-reads this whole prefix from cache instead of paying + // full input price for it. See `stamp_rolling_cache_breakpoint`. + if cfg.prompt_caching { + stamp_rolling_cache_breakpoint(&mut messages); + } let tools_json: Vec = tools .iter() .map(|t| { @@ -727,8 +733,19 @@ fn anthropic_body( "name": t.name, "description": t.description, "input_schema": t.input_schema }) }) .collect(); + // Static prefix breakpoint: caching the `system` block caches the whole + // prefix up to and including it — and the prefix order is + // `tools -> system -> messages`, so this single marker caches tools + + // system together. Requires the structured (array) form of `system`; skip + // it for an empty prompt since Anthropic rejects empty text blocks. + let system_value = if cfg.prompt_caching && !system_prompt.is_empty() { + json!([{ "type": "text", "text": system_prompt, + "cache_control": { "type": "ephemeral" } }]) + } else { + json!(system_prompt) + }; let mut body = json!({ "model": effective_model, "max_tokens": cfg.max_output_tokens, - "system": system_prompt, "messages": messages }); + "system": system_value, "messages": messages }); if let Some(e) = effort { let (thinking, output_config) = crate::config::anthropic_thinking_config(effective_model, e, cfg.max_output_tokens); @@ -745,6 +762,41 @@ fn anthropic_body( body } +/// Attach ephemeral `cache_control` markers to the tail of the conversation so +/// the next turn re-reads the whole prior prefix from cache (~0.1x input price) +/// rather than re-billing it as fresh input. Anthropic caches the prefix up to +/// and including each marked block. +/// +/// We mark the last content block of the last *two* messages, not just the +/// final one. Each Anthropic breakpoint walks back at most 20 content blocks to +/// find a prior cache entry, and one agentic turn can append ~17 blocks at the +/// default `max_parallel_tools` (1 assistant text + N `tool_use` + +/// N `tool_result`). With only a tail marker, consecutive breakpoints sit a +/// full turn apart, which slips past the 20-block window as soon as parallelism +/// rises or a turn carries extra blocks — and the miss is silent. Marking the +/// last two messages halves the gap (to ~N+1 blocks), keeping a live cache +/// entry comfortably within reach. Uses 2 of the 4 allowed breakpoints; the +/// static `system` marker is the third. +/// +/// A no-op for messages whose content is empty or whose tail block is not a +/// JSON object. +fn stamp_rolling_cache_breakpoint(messages: &mut [Value]) { + let n = messages.len(); + // The two most-recently-appended messages (the current turn's tool results + // and the assistant turn before them). `checked_sub` + `flatten` skips the + // second index when there is only one message. + for idx in [n.checked_sub(1), n.checked_sub(2)].into_iter().flatten() { + if let Some(block) = messages[idx] + .get_mut("content") + .and_then(Value::as_array_mut) + .and_then(|c| c.last_mut()) + .and_then(Value::as_object_mut) + { + block.insert("cache_control".into(), json!({ "type": "ephemeral" })); + } + } +} + fn anthropic_tool_result_content(content: &[ToolResultContent]) -> Vec { content .iter() @@ -1079,11 +1131,18 @@ fn parse_responses(v: Value) -> Result { }; let input_tokens = sum_usage(&v, &["input_tokens"]); let output_tokens = sum_usage(&v, &["output_tokens"]); + // The Responses API nests the cache split under `input_tokens_details`. + let cached_input_tokens = usage_first( + &v, + &["cache_read_input_tokens"], + &[("input_tokens_details", "cached_tokens")], + ); Ok(LlmResponse { text, tool_calls, stop, input_tokens, + cached_input_tokens, output_tokens, reasoning, }) @@ -1131,19 +1190,70 @@ fn anthropic_input_tokens(v: &Value) -> Option { ) } -/// Input-token total for OpenAI Chat Completions and Databricks responses. -/// OpenAI's `prompt_tokens` is already inclusive. Databricks uses the same -/// `prompt_tokens` wire field but ALSO reports Anthropic-style cache fields -/// alongside it, so we sum them; the cache fields are simply absent (and -/// contribute 0) for vanilla OpenAI. +/// Input-token total for OpenAI Chat Completions and Databricks MLflow-route +/// responses. `prompt_tokens` is already the inclusive input total on both, so +/// it is read alone and never summed with the cache fields. +/// +/// Vanilla OpenAI nests the cache split under `prompt_tokens_details` and +/// `prompt_tokens` includes it. The Databricks MLflow route reports the split +/// with the flat Anthropic spelling (`cache_read_input_tokens`) *alongside* an +/// already-inclusive `prompt_tokens` — so summing double-counts. Verified on +/// `databricks-glm-5-2` (2026-07-28): `prompt_tokens 13320`, +/// `cache_read_input_tokens 13312`, `completion_tokens 30`, `total_tokens +/// 13350`; since `prompt_tokens + completion_tokens == total_tokens`, the 13312 +/// cached tokens are contained in the 13320, not additional to it. Summing gave +/// 26632 — nearly double — inflating both the context-budget gate and cost. +/// +/// This differs from Anthropic's native route (see [`anthropic_input_tokens`]), +/// where `input_tokens` genuinely EXCLUDES the cache fields and must be summed. +/// The two never collide here: the router sends `claude*` models to the +/// Anthropic route, so `parse_openai` only ever sees inclusive `prompt_tokens`. fn openai_chat_input_tokens(v: &Value) -> Option { - sum_usage( + sum_usage(v, &["prompt_tokens"]) +} + +/// First present value among `usage.` and `usage..` pairs. +/// +/// Cache counts are the one usage figure providers do not agree on the shape of. +/// Anthropic puts `cache_read_input_tokens` flat on `usage`; OpenAI nests the +/// same quantity one level down, under `prompt_tokens_details` on +/// `/chat/completions` and `input_tokens_details` on `/responses`. [`sum_usage`] +/// only reads flat keys, which is why the OpenAI split was invisible for so +/// long: `prompt_tokens` is already inclusive, so the *total* was right and +/// nothing looked broken while the discount silently went unclaimed. +/// +/// Returns the first candidate that resolves, not a sum — these are alternative +/// spellings of one number, so adding them would double-count on Databricks, +/// which reports both shapes. +fn usage_first(v: &Value, flat: &[&str], nested: &[(&str, &str)]) -> Option { + let usage = v.get("usage")?; + for f in flat { + if let Some(n) = usage.get(*f).and_then(Value::as_u64) { + return Some(n); + } + } + for (outer, leaf) in nested { + if let Some(n) = usage + .get(*outer) + .and_then(|o| o.get(*leaf)) + .and_then(Value::as_u64) + { + return Some(n); + } + } + None +} + +/// Cache-read tokens for an OpenAI Chat Completions response. +/// +/// `prompt_tokens_details.cached_tokens` is where vanilla OpenAI reports it. +/// The flat Anthropic spelling is checked first for Databricks, which routes +/// Anthropic models through an OpenAI-shaped envelope. +fn openai_chat_cached_tokens(v: &Value) -> Option { + usage_first( v, - &[ - "prompt_tokens", - "cache_read_input_tokens", - "cache_creation_input_tokens", - ], + &["cache_read_input_tokens"], + &[("prompt_tokens_details", "cached_tokens")], ) } @@ -1184,11 +1294,15 @@ fn parse_anthropic(v: Value) -> Result { } let input_tokens = anthropic_input_tokens(&v); let output_tokens = sum_usage(&v, &["output_tokens"]); + // Anthropic reports the cache split flat on `usage`. Note this is already + // part of `input_tokens` above, which sums it in deliberately. + let cached_input_tokens = usage_first(&v, &["cache_read_input_tokens"], &[]); Ok(LlmResponse { text, tool_calls, stop, input_tokens, + cached_input_tokens, output_tokens, reasoning, }) @@ -1235,11 +1349,13 @@ fn parse_openai(v: Value) -> Result { } let input_tokens = openai_chat_input_tokens(&v); let output_tokens = sum_usage(&v, &["completion_tokens"]); + let cached_input_tokens = openai_chat_cached_tokens(&v); Ok(LlmResponse { text, tool_calls, stop, input_tokens, + cached_input_tokens, output_tokens, reasoning, }) @@ -1606,6 +1722,7 @@ mod tests { prefer_mesh_for_auto: false, hints_enabled: true, thinking_effort: None, + prompt_caching: true, } } @@ -2604,6 +2721,100 @@ mod tests { assert_eq!(imgs[1]["image_url"]["url"], "data:image/png;base64,bbb"); } + // ---- prompt caching (cache_control) body-shape tests ---- + + #[test] + fn anthropic_body_stamps_cache_control_when_enabled() { + let body = anthropic_body( + &cfg(Provider::DatabricksV2), + "sys", + &[ + HistoryItem::User("hello".into()), + HistoryItem::Assistant { + text: "hi".into(), + tool_calls: vec![], + }, + HistoryItem::User("more".into()), + ], + &[], + "databricks-claude-opus-5", + None, + ); + // Static prefix: system promoted to a structured block carrying the marker. + assert_eq!(body["system"][0]["type"], "text"); + assert_eq!(body["system"][0]["text"], "sys"); + assert_eq!(body["system"][0]["cache_control"]["type"], "ephemeral"); + // Leapfrog: the last block of the last TWO messages is marked; earlier + // ones are not. Three distinct user/assistant/user turns → messages[1] + // and messages[2] marked, messages[0] clean. + let msgs = body["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 3); + let tail_block = |m: &Value| m["content"].as_array().unwrap().last().unwrap().clone(); + assert_eq!(tail_block(&msgs[2])["cache_control"]["type"], "ephemeral"); + assert_eq!(tail_block(&msgs[1])["cache_control"]["type"], "ephemeral"); + assert!( + tail_block(&msgs[0]).get("cache_control").is_none(), + "only the last two messages carry a breakpoint" + ); + } + + #[test] + fn anthropic_body_single_message_stamps_only_one_breakpoint() { + // With a single message there is no second turn to leapfrog to; the + // checked_sub(2) index is skipped rather than panicking. + let body = anthropic_body( + &cfg(Provider::DatabricksV2), + "sys", + &[HistoryItem::User("hello".into())], + &[], + "databricks-claude-opus-5", + None, + ); + let msgs = body["messages"].as_array().unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!( + msgs[0]["content"].as_array().unwrap().last().unwrap()["cache_control"]["type"], + "ephemeral" + ); + } + + #[test] + fn anthropic_body_no_cache_control_when_disabled() { + let mut c = cfg(Provider::DatabricksV2); + c.prompt_caching = false; + let body = anthropic_body( + &c, + "sys", + &[HistoryItem::User("hello".into())], + &[], + "databricks-claude-opus-5", + None, + ); + // system stays a bare string; no marker anywhere. + assert_eq!(body["system"], "sys"); + let last_block = &body["messages"][0]["content"][0]; + assert!(last_block.get("cache_control").is_none()); + } + + #[test] + fn anthropic_body_empty_system_stays_string_even_when_caching() { + // An empty system prompt must not become an empty text block — + // Anthropic rejects those. Caching is still applied to the tail. + let body = anthropic_body( + &cfg(Provider::DatabricksV2), + "", + &[HistoryItem::User("hello".into())], + &[], + "databricks-claude-opus-5", + None, + ); + assert_eq!(body["system"], ""); + assert_eq!( + body["messages"][0]["content"][0]["cache_control"]["type"], + "ephemeral" + ); + } + // ---- ThinkingEffort body-shape tests ---- #[test] @@ -3493,20 +3704,34 @@ mod tests { } #[test] - fn parse_openai_databricks_sums_cache_fields() { - // Databricks uses the OpenAI chat wire format (prompt_tokens) but also - // reports Anthropic-style cache fields; the inclusive total sums them. + fn parse_openai_databricks_prompt_tokens_already_inclusive() { + // Databricks' MLflow route uses the OpenAI chat wire format + // (prompt_tokens) but ALSO reports the flat Anthropic-style + // cache_read_input_tokens. prompt_tokens is already inclusive of that + // slice, so the total is prompt_tokens alone — summing double-counts. + // Values are the live databricks-glm-5-2 response (2026-07-28), where + // prompt_tokens + completion_tokens == total_tokens proves inclusivity. let v = serde_json::json!({ "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], "usage": { - "prompt_tokens": 200, - "completion_tokens": 4, - "total_tokens": 204, - "cache_read_input_tokens": 800, - "cache_creation_input_tokens": 0 + "prompt_tokens": 13320, + "completion_tokens": 30, + "total_tokens": 13350, + "cache_read_input_tokens": 13312, + "prompt_tokens_details": {"cached_tokens": 13312} } }); - assert_eq!(parse_openai(v).unwrap().input_tokens, Some(1000)); + let r = parse_openai(v).unwrap(); + assert_eq!( + r.input_tokens, + Some(13320), + "prompt_tokens is the inclusive total" + ); + assert_eq!(r.cached_input_tokens, Some(13312)); + assert!( + r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap(), + "the cached slice is a subset of the input total" + ); } #[test] @@ -3517,6 +3742,115 @@ mod tests { assert_eq!(parse_openai(v).unwrap().input_tokens, None); } + #[test] + fn parse_openai_reads_nested_cached_tokens() { + // The shape vanilla OpenAI actually returns, captured from a live + // /chat/completions probe on gpt-5.6-luna: `prompt_tokens` is already + // inclusive and the cache split is nested one level down. Reading only + // flat keys left the discount unclaimed while the total looked correct, + // which is why this went unnoticed. + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "OK"}}], + "usage": { + "prompt_tokens": 5229, + "completion_tokens": 4, + "total_tokens": 5233, + "prompt_tokens_details": {"audio_tokens": 0, "cached_tokens": 5226, + "cache_write_tokens": 0} + } + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.input_tokens, Some(5229), "total must stay inclusive"); + assert_eq!(r.cached_input_tokens, Some(5226)); + } + + #[test] + fn parse_openai_cache_write_round_reports_zero_cached() { + // First request of a cold prefix: the provider writes the cache and + // serves nothing from it. `Some(0)` not `None` — the split was reported, + // it was simply zero, and a consumer must be able to tell the two apart. + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "OK"}}], + "usage": { + "prompt_tokens": 5229, + "completion_tokens": 4, + "prompt_tokens_details": {"cached_tokens": 0, "cache_write_tokens": 5226} + } + }); + assert_eq!(parse_openai(v).unwrap().cached_input_tokens, Some(0)); + } + + #[test] + fn parse_openai_no_cache_detail_is_none() { + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], + "usage": {"prompt_tokens": 123, "completion_tokens": 4} + }); + assert_eq!(parse_openai(v).unwrap().cached_input_tokens, None); + } + + #[test] + fn parse_openai_prefers_flat_anthropic_spelling_over_nested() { + // Databricks reports both shapes for the same quantity. Take one, never + // the sum, or the cached slice double-counts. cache_read (800) is a + // subset of the inclusive prompt_tokens (1000), as it must be. + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "hi"}}], + "usage": { + "prompt_tokens": 1000, + "completion_tokens": 4, + "cache_read_input_tokens": 800, + "prompt_tokens_details": {"cached_tokens": 800} + } + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.input_tokens, Some(1000)); + assert_eq!(r.cached_input_tokens, Some(800)); + assert!(r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap()); + } + + #[test] + fn parse_anthropic_reports_cache_read_as_cached() { + // Anthropic's `input_tokens` EXCLUDES cached, so the inclusive total is + // a sum -- but the cached slice must still be a subset of that total. + let v = serde_json::json!({ + "stop_reason": "end_turn", + "content": [{"type": "text", "text": "hi"}], + "usage": { + "input_tokens": 100, + "output_tokens": 7, + "cache_read_input_tokens": 900, + "cache_creation_input_tokens": 50 + } + }); + let r = parse_anthropic(v).unwrap(); + assert_eq!(r.input_tokens, Some(1050)); + assert_eq!(r.cached_input_tokens, Some(900)); + assert!(r.cached_input_tokens.unwrap() <= r.input_tokens.unwrap()); + } + + #[test] + fn parse_responses_reads_nested_cached_tokens() { + // The Responses API nests the same figure under a different key than + // /chat/completions does. + let v = serde_json::json!({ + "status": "completed", + "output": [{ + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi"}] + }], + "usage": { + "input_tokens": 4000, + "output_tokens": 9, + "input_tokens_details": {"cached_tokens": 3584} + } + }); + let r = parse_responses(v).unwrap(); + assert_eq!(r.input_tokens, Some(4000)); + assert_eq!(r.cached_input_tokens, Some(3584)); + } + #[test] fn parse_responses_uses_input_tokens() { let v = serde_json::json!({ diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index 744b10dc7a..9ae125a0b7 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -52,6 +52,31 @@ const PASSTHROUGH_ENV: &[&str] = &[ "GIT_ASKPASS", "GIT_SSH_COMMAND", "GIT_CONFIG_GLOBAL", + // Proxy — on a host whose only route out is a CONNECT proxy, dropping + // these does not degrade the tools, it blinds them: apt, curl, pip and git + // all connect directly instead, and the egress firewall resets the socket. + // The agent then reports "Connection reset by peer" and concludes the + // environment has no network, which is indistinguishable in the transcript + // from a task that is genuinely offline. + // + // Both cases are needed. curl and git read the lowercase spellings, most + // Go and Python tooling reads the uppercase ones, and libcurl deliberately + // ignores uppercase HTTP_PROXY (CGI ambiguity), so keeping only one form + // silently breaks half the toolchain. + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + // TLS trust — a proxy that terminates TLS presents its own CA, and an + // image whose trust store does not carry it fails every https fetch with a + // verification error. Same class of failure as the proxy vars: the parent + // was configured correctly and the child could not see it. + "SSL_CERT_FILE", + "SSL_CERT_DIR", // Buzz identity — dev-mcp writes NOSTR_PRIVATE_KEY to a keyfile then // removes it from its own env (children never see it). BUZZ_PRIVATE_KEY // and BUZZ_RELAY_URL are kept for the buzz CLI. BUZZ_AUTH_TAG is a @@ -1015,6 +1040,41 @@ mod content_tests { fn passthrough_includes_buzz_owner_attestation() { assert!(PASSTHROUGH_ENV.contains(&"BUZZ_AUTH_TAG")); } + + #[test] + fn passthrough_carries_proxy_configuration_to_tools() { + // On a proxy-only host this is the difference between an agent that can + // install a package and one that reports the network is down. Both + // spellings: libcurl ignores uppercase HTTP_PROXY, and Go/Python + // tooling largely ignores the lowercase set. + for var in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "no_proxy", + "all_proxy", + ] { + assert!( + PASSTHROUGH_ENV.contains(&var), + "{var} must survive env_clear() or every MCP tool loses the proxy" + ); + } + } + + #[test] + fn passthrough_carries_tls_trust_to_tools() { + // A TLS-terminating proxy presents its own CA; without these the child + // rejects every https fetch even though the proxy itself is reachable. + for var in ["SSL_CERT_FILE", "SSL_CERT_DIR"] { + assert!( + PASSTHROUGH_ENV.contains(&var), + "{var} must survive env_clear() or https fails inside tools" + ); + } + } use rmcp::model::Content; #[cfg(windows)] diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index d29e975e03..1b5f30b1ce 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -139,6 +139,17 @@ pub struct LlmResponse { /// tokens, so reading it alone would undercount). Used to gate handoff on /// the real token budget rather than a byte estimate. pub input_tokens: Option, + /// The portion of `input_tokens` the provider served from its prompt cache, + /// or `None` when the response reported no cache split. Providers bill this + /// slice at a large discount (roughly 10x for both OpenAI and Anthropic), + /// so a consumer that prices all of `input_tokens` at the full rate + /// *overstates* cost — by a lot on an append-only agent loop, where most of + /// each request is a prefix the provider already has. + /// + /// This is a subset of `input_tokens`, never an addition to it: every + /// provider we speak to reports an inclusive input total, so adding this + /// would double-count. + pub cached_input_tokens: Option, /// Output tokens the provider reported for this request, or `None` if the /// response carried no usage. Used to accumulate per-turn output counts /// for NIP-AM metric publishing. From ce01e930edff97bdc12a205fb8f938fcacdba8c1 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 29 Jul 2026 15:35:41 +0100 Subject: [PATCH 20/99] Polish mobile typing indicator (#3528) ## Summary - Present channel and thread typing status in a composer-matched container. - Animate the strip so the message list moves smoothly as typing begins and ends. - Increase typing-label contrast and avatar/padding for readability. ## Pixel 10 snapshot ![Typing indicator above the composer](https://raw.githubusercontent.com/block/buzz/31de9f86a76fe61498bc7f2931d9e574827a9aa2/pr-3528--typing-indicator.png) ## Validation - `flutter test test/features/channels/channel_detail_page_test.dart` - `flutter analyze` Signed-off-by: kenny lopez --- .../channels/channel_detail_page.dart | 14 ++- .../channels/channel_detail_page/app_bar.dart | 64 ------------- .../channels/channel_typing_indicator.dart | 89 +++++++++++++++++++ .../features/channels/thread_detail_page.dart | 77 +++------------- .../channels/channel_detail_page_test.dart | 19 ++++ 5 files changed, 131 insertions(+), 132 deletions(-) create mode 100644 mobile/lib/features/channels/channel_typing_indicator.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 6bbb60c0d1..4304705f90 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -27,6 +27,7 @@ import 'agent_activity/working_bots_provider.dart'; import 'channel_management_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; +import 'channel_typing_indicator.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; import 'date_formatters.dart'; @@ -368,8 +369,17 @@ class ChannelDetailPage extends HookConsumerWidget { ), ), ), - if (!resolvedChannel.isForum && typingEntries.isNotEmpty) - _TypingIndicator(entries: typingEntries), + if (!resolvedChannel.isForum) + AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: typingEntries.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: typingEntries), + ), if (!resolvedChannel.isForum && resolvedChannel.isMember && !resolvedChannel.isArchived) diff --git a/mobile/lib/features/channels/channel_detail_page/app_bar.dart b/mobile/lib/features/channels/channel_detail_page/app_bar.dart index fbf5abfb3b..406d68b4f5 100644 --- a/mobile/lib/features/channels/channel_detail_page/app_bar.dart +++ b/mobile/lib/features/channels/channel_detail_page/app_bar.dart @@ -19,70 +19,6 @@ double _dmAppBarTitleContentHeight(BuildContext context) { return textHeight > 30 ? textHeight : 30; } -class _TypingIndicator extends ConsumerWidget { - final List entries; - - const _TypingIndicator({required this.entries}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); - final names = entries.map((e) { - final profile = - userCache[e.pubkey.toLowerCase()] ?? - ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase()); - return profile?.label ?? shortPubkey(e.pubkey); - }).toList(); - final text = switch (names.length) { - 1 => '${names[0]} is typing…', - 2 => '${names[0]} and ${names[1]} are typing…', - _ => '${names[0]} and ${names.length - 1} others are typing…', - }; - - final visibleEntries = entries.take(3).toList(); - final avatarCount = visibleEntries.length; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.quarter + 2, - ), - child: Row( - children: [ - SizedBox( - width: 20.0 + (avatarCount - 1) * 12.0, - height: 20, - child: Stack( - children: [ - for (var i = 0; i < avatarCount; i++) - Positioned( - left: i * 12.0, - child: SmallAvatar( - pubkey: visibleEntries[i].pubkey, - userCache: userCache, - ), - ), - ], - ), - ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text( - text, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, - fontStyle: FontStyle.italic, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - class _MembersButton extends ConsumerWidget { final String channelId; final Channel channel; diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart new file mode 100644 index 0000000000..d021b9e287 --- /dev/null +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/utils/string_utils.dart'; +import '../profile/user_cache_provider.dart'; +import 'channel_typing_provider.dart'; +import 'small_avatar.dart'; + +/// Composer-adjacent status for people currently typing in a channel or thread. +class ChannelTypingIndicator extends ConsumerWidget { + final List entries; + + const ChannelTypingIndicator({super.key, required this.entries}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final userCache = ref.watch(userCacheProvider); + final names = entries.map((entry) { + final profile = + userCache[entry.pubkey.toLowerCase()] ?? + ref.read(userCacheProvider.notifier).get(entry.pubkey.toLowerCase()); + return profile?.label ?? shortPubkey(entry.pubkey); + }).toList(); + final text = switch (names.length) { + 1 => '${names[0]} is typing…', + 2 => '${names[0]} and ${names[1]} are typing…', + _ => '${names[0]} and ${names.length - 1} others are typing…', + }; + final visibleEntries = entries.take(3).toList(); + final avatarCount = visibleEntries.length; + + return Padding( + padding: const EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: Grid.xxs, + ), + child: Container( + key: const ValueKey('channel-typing-indicator'), + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xxs, + vertical: Grid.xxs, + ), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: Row( + children: [ + SizedBox( + width: 24.0 + (avatarCount - 1) * 14.0, + height: 24, + child: Stack( + children: [ + for (var i = 0; i < avatarCount; i++) + Positioned( + left: i * 14.0, + child: SmallAvatar( + pubkey: visibleEntries[i].pubkey, + userCache: userCache, + size: 24, + ), + ), + ], + ), + ), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text( + text, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.primary, + fontStyle: FontStyle.italic, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 0babba2394..167b111f4f 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -12,6 +12,7 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_link_navigation.dart'; import 'channel_typing_provider.dart'; +import 'channel_typing_indicator.dart'; import 'thread_replies_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; @@ -280,8 +281,16 @@ class ThreadDetailPage extends HookConsumerWidget { }, ), ), - if (threadTyping.isNotEmpty) - _ThreadTypingIndicator(entries: threadTyping), + AnimatedSize( + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180), + curve: Curves.easeOutCubic, + alignment: Alignment.bottomCenter, + child: threadTyping.isEmpty + ? const SizedBox.shrink() + : ChannelTypingIndicator(entries: threadTyping), + ), if (isMember && !isArchived) ComposeBar( channelId: channelId, @@ -663,70 +672,6 @@ class _ThreadMessage extends ConsumerWidget { } } -class _ThreadTypingIndicator extends ConsumerWidget { - final List entries; - - const _ThreadTypingIndicator({required this.entries}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final userCache = ref.watch(userCacheProvider); - final names = entries.map((e) { - final profile = - userCache[e.pubkey.toLowerCase()] ?? - ref.read(userCacheProvider.notifier).get(e.pubkey.toLowerCase()); - return profile?.label ?? shortPubkey(e.pubkey); - }).toList(); - final text = switch (names.length) { - 1 => '${names[0]} is typing...', - 2 => '${names[0]} and ${names[1]} are typing...', - _ => '${names[0]} and ${names.length - 1} others are typing...', - }; - - final visibleEntries = entries.take(3).toList(); - final avatarCount = visibleEntries.length; - - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.quarter + 2, - ), - child: Row( - children: [ - SizedBox( - width: 20.0 + (avatarCount - 1) * 12.0, - height: 20, - child: Stack( - children: [ - for (var i = 0; i < avatarCount; i++) - Positioned( - left: i * 12.0, - child: SmallAvatar( - pubkey: visibleEntries[i].pubkey, - userCache: userCache, - ), - ), - ], - ), - ), - const SizedBox(width: Grid.xxs), - Flexible( - child: Text( - text, - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.outline, - fontStyle: FontStyle.italic, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ); - } -} - class _Avatar extends StatelessWidget { final UserProfile? profile; final String pubkey; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 48ad861d3a..127e6851e6 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1808,6 +1808,25 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Alice is typing…'), findsOneWidget); + + final indicator = tester.widget( + find.byKey(const ValueKey('channel-typing-indicator')), + ); + final decoration = indicator.decoration! as BoxDecoration; + expect( + indicator.padding, + const EdgeInsets.symmetric(horizontal: Grid.xxs, vertical: Grid.xxs), + ); + expect( + decoration.color, + AppTheme.light().colorScheme.surfaceContainerHighest, + ); + expect(decoration.border, isA()); + expect( + tester.widget(find.text('Alice is typing…')).style?.color, + AppTheme.light().colorScheme.primary, + ); + expect(tester.widget(find.byType(SmallAvatar)).size, 24); }); testWidgets('shows two typers', (tester) async { From 24d90d1280a9325c6cbcf8eea30ac54db5afd2cb Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 29 Jul 2026 15:42:17 +0100 Subject: [PATCH 21/99] Refine community invite limits (#3529) ## Summary - Simplify the community invite dialog around link sharing. - Add matching expiry and use-limit dropdowns, with sensible preset use caps. - Cover the default unlimited and selected-limit invite payloads. ## Validation - `pnpm -C desktop run build:e2e` - `pnpm -C desktop exec playwright test tests/e2e/invite-link-copy.spec.ts tests/e2e/invites-settings-screenshots.spec.ts --project=smoke` Signed-off-by: kenny lopez --- .../ui/CommunityInviteDialog.tsx | 7 +- .../ui/InviteLinkSection.tsx | 181 +++++++++--------- desktop/tests/e2e/invite-link-copy.spec.ts | 30 +++ .../e2e/invites-settings-screenshots.spec.ts | 12 +- 4 files changed, 134 insertions(+), 96 deletions(-) diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx index bd23e2bbec..9daca590f4 100644 --- a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx +++ b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; @@ -29,12 +30,14 @@ export function CommunityInviteDialog({ return ( - + Invite to community + + Anyone with this link can join this community. + diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index c4e140f723..dc0735c85e 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -8,16 +8,12 @@ import { Button } from "@/shared/ui/button"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Input } from "@/shared/ui/input"; import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; -import { Switch } from "@/shared/ui/switch"; const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "1 day", value: 24 * 60 * 60 }, @@ -26,6 +22,15 @@ const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "30 days", value: 30 * 24 * 60 * 60 }, ]; +const MAX_USE_OPTIONS: { label: string; value: number | null }[] = [ + { label: "No limit", value: null }, + { label: "1 use", value: 1 }, + { label: "3 uses", value: 3 }, + { label: "5 uses", value: 5 }, + { label: "10 uses", value: 10 }, + { label: "25 uses", value: 25 }, +]; + export const DEFAULT_INVITE_TTL_SECS = TTL_OPTIONS[1].value; type CopyStatus = "idle" | "copying" | "copied"; @@ -45,16 +50,12 @@ export function InviteLinkSection({ ttlSecs: number; }) { const [copyStatus, setCopyStatus] = React.useState("idle"); - const [maxUsesEnabled, setMaxUsesEnabled] = React.useState(true); - const [maxUsesInput, setMaxUsesInput] = React.useState("3"); - const parsedMaxUses = Number(maxUsesInput); - const maxUsesValid = - !maxUsesEnabled || - (Number.isInteger(parsedMaxUses) && - parsedMaxUses >= 1 && - parsedMaxUses <= 10000); + const [maxUses, setMaxUses] = React.useState(null); const ttlLabel = TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; + const maxUsesLabel = + MAX_USE_OPTIONS.find((option) => option.value === maxUses)?.label ?? + "No limit"; const copyLabel = copyStatus === "copying" ? "Copying…" @@ -69,13 +70,10 @@ export function InviteLinkSection({ }, [copyStatus]); async function handleCopy() { - if (copyStatus === "copying" || !maxUsesValid) return; + if (copyStatus === "copying") return; setCopyStatus("copying"); try { - const invite = await mintInvite({ - ttlSecs, - maxUses: maxUsesEnabled ? parsedMaxUses : null, - }); + const invite = await mintInvite({ ttlSecs, maxUses }); await writeTextToClipboard(invite.url); setCopyStatus("copied"); toast.success("Invite link copied"); @@ -87,82 +85,79 @@ export function InviteLinkSection({ return (
-
- - -
-

Share with a link

-

- Anyone with the link can join this community. -

+
+
+ Expires after + + + + + + onTtlSecsChange(Number(value))} + value={String(ttlSecs)} + > + {TTL_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+
+ Limit number of uses + + + + + + + setMaxUses(value === "no-limit" ? null : Number(value)) + } + value={String(maxUses ?? "no-limit")} + > + {MAX_USE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
- - - - - - Expires after - - onTtlSecsChange(Number(value))} - value={String(ttlSecs)} - > - {TTL_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - -
-
- - - {maxUsesEnabled ? ( - setMaxUsesInput(event.target.value)} - placeholder="3" - type="number" - value={maxUsesInput} - /> - ) : null} - {maxUsesEnabled && !maxUsesValid ? ( - - Enter a whole number from 1 to 10,000 - - ) : null}
@@ -170,7 +165,7 @@ export function InviteLinkSection({ className="shrink-0 border-border shadow-none" data-copy-status={copyStatus} data-testid="copy-invite-link" - disabled={copyStatus === "copying" || !maxUsesValid} + disabled={copyStatus === "copying"} onClick={() => void handleCopy()} size="sm" type="button" diff --git a/desktop/tests/e2e/invite-link-copy.spec.ts b/desktop/tests/e2e/invite-link-copy.spec.ts index 94bc8e26b0..015abd848f 100644 --- a/desktop/tests/e2e/invite-link-copy.spec.ts +++ b/desktop/tests/e2e/invite-link-copy.spec.ts @@ -3,7 +3,10 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; +let invitePayloads: Record[]; + test.beforeEach(async ({ page }) => { + invitePayloads = []; await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: "http://127.0.0.1:4173", }); @@ -11,6 +14,7 @@ test.beforeEach(async ({ page }) => { relayRequiresMembership: true, }); await page.route("**/api/invites", async (route) => { + invitePayloads.push(route.request().postDataJSON()); await route.fulfill({ contentType: "application/json", json: { @@ -33,8 +37,12 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy await page.getByTestId("community-invite-dialog-trigger").click(); await expect(page.getByTestId("invite-link-url")).toHaveCount(0); await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0); + await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText( + "No limit", + ); await page.getByTestId("copy-invite-link").click(); await expect(page.getByTestId("copy-invite-link")).toContainText("Copied"); + expect(invitePayloads).toEqual([{ ttl_secs: 3 * 24 * 60 * 60 }]); const payload = await page.evaluate(() => { const log = ( @@ -53,3 +61,25 @@ test("copies a freshly minted invite link without showing a URL or QR code", asy text: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test", }); }); + +test("sets a selected invite-use limit", async ({ page }) => { + await page.goto("/"); + await openSettings(page, "community-members"); + await page.getByTestId("community-invite-dialog-trigger").click(); + + const maxUsesTrigger = page.getByTestId("invite-link-max-uses-trigger"); + await maxUsesTrigger.click(); + await expect( + page.getByRole("menuitemradio", { name: "No limit" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitemradio", { name: "25 uses" }), + ).toBeVisible(); + await page.getByTestId("invite-link-max-uses-10").click(); + await expect(maxUsesTrigger).toHaveText("10 uses"); + await page.getByTestId("copy-invite-link").click(); + await expect(page.getByTestId("copy-invite-link")).toContainText("Copied"); + expect(invitePayloads).toEqual([ + { max_uses: 10, ttl_secs: 3 * 24 * 60 * 60 }, + ]); +}); diff --git a/desktop/tests/e2e/invites-settings-screenshots.spec.ts b/desktop/tests/e2e/invites-settings-screenshots.spec.ts index 654aec56fe..c56e33bcd5 100644 --- a/desktop/tests/e2e/invites-settings-screenshots.spec.ts +++ b/desktop/tests/e2e/invites-settings-screenshots.spec.ts @@ -78,15 +78,25 @@ test("capture: share-style community invite dialog", async ({ page }) => { await expect(page.getByTestId("community-invite-email-field")).toHaveCount(0); await expect(page.getByPlaceholder("Type an email address")).toHaveCount(0); await expect( - dialog.getByRole("heading", { name: "Share with a link" }), + dialog.getByText("Anyone with this link can join this community."), ).toBeVisible(); + await expect(dialog.getByText("Expires after")).toBeVisible(); + await expect(dialog.getByText("Limit number of uses")).toBeVisible(); + await expect(page.getByTestId("invite-link-max-uses-trigger")).toHaveText( + "No limit", + ); await expect(page.getByTestId("copy-invite-link")).toHaveText("Copy link"); await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0); await expect(page.getByTestId("invite-link-url")).toHaveCount(0); const expiryTrigger = page.getByTestId("invite-link-ttl-trigger"); await expect(expiryTrigger).toHaveText("3 days"); + await expect(expiryTrigger).toHaveCSS("font-size", "14px"); + await expect( + dialog.getByText("Limit number of uses", { exact: true }), + ).toHaveCSS("font-size", "14px"); await expiryTrigger.click(); + await expect(page.getByRole("menu")).not.toContainText("Expires after"); await expect( page.getByRole("menuitemradio", { name: "1 day" }), ).toBeVisible(); From 6438dedf83a9dbe1853e484326911bf6c7f1618c Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Wed, 29 Jul 2026 10:02:12 -0500 Subject: [PATCH 22/99] feat(agent): route Claude/GPT model families to their native gateway wire (#3538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Databricks v2 chooses the gateway wire format — OpenAI Responses, Anthropic Messages, or MLflow chat — purely from substrings in the endpoint name. There is no family field on the endpoint to key off, so the substring set *is* the routing contract. The matcher only recognised `gpt-5`/`gpt5` and `claude`, which makes correct billing depend on every Claude endpoint happening to be named with the literal string "claude". ## Why this matters Getting a Claude model onto the Anthropic Messages route is exactly what lets buzz attach the `cache_control` breakpoint (the fix in #3463). If a Claude endpoint's catalog name omits "claude" — an alias, a bare `opus-5`, a `goose-opus-5` — it silently falls through to the MLflow (OpenAI-wire) path, where Anthropic prompt caching is **structurally impossible**. The result is the same failure #3463 fixed: 0% cache reads, the full ~10x read discount lost, and no error — a naming convention quietly holding up a billing-correctness invariant. ## What changed `databricks_v2_route_for_model` (`crates/buzz-agent/src/llm.rs`) now matches broader, case-insensitive marker sets: - **Claude → Anthropic Messages:** `claude`, `opus`, `sonnet`, `haiku`, `mythos`, `fable` — the Claude family names and release code names, so a Claude endpoint reaches the cache-capable route regardless of how it's named. - **GPT → OpenAI Responses:** the `gpt` family (now `gpt` on its own, not just `gpt-5`) plus the GPT-5 launch code names `sol`, `luna`, `terra`. OpenAI markers are evaluated first, preserving the prior `gpt-5`-first precedence for any name that could carry both. Names matching neither set still fall through to the MLflow chat route. ## Testing - `cargo fmt`, `cargo clippy -p buzz-agent --all-targets -- -D warnings` — clean. - `cargo test -p buzz-agent` — all green (299 lib + integration suites, 0 failures). The `databricks_v2_routes_by_model_family` test was expanded to cover each new marker, the GPT-5 code names, case-insensitivity, and the unchanged MLflow fallback (including `gemini`). ## Relationship to #3463 #3463 taught the Anthropic path to request caching; this makes sure Claude models actually land on that path. Follow-up still open: surfacing `cache_creation_input_tokens` end-to-end so a persistent `reads == 0 && writes == 0` reveals a disabled cache regardless of which wire a model takes — happy to do that next. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Atish Patel Co-authored-by: Claude Opus 4.8 (1M context) --- crates/buzz-agent/src/llm.rs | 141 +++++++++++++++++++++++++++++++++-- 1 file changed, 133 insertions(+), 8 deletions(-) diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f2359f9ba3..f69a963533 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1019,13 +1019,53 @@ fn is_responses_required_error(body: &str) -> bool { || b.contains("use the responses api") } +/// OpenAI-family code names that appear as their own segment in a Databricks v2 +/// endpoint name (the GPT-5 launch aliases). The `gpt` family itself is matched +/// separately by segment prefix so `gpt`, `gpt5`, and the `gpt` of a split +/// `gpt-5` all qualify. +const DATABRICKS_V2_OPENAI_CODE_NAMES: &[&str] = &["sol", "luna", "terra"]; + +/// Anthropic (Claude) family and release code names that appear as their own +/// segment in a Databricks v2 endpoint name — the `claude` prefix, the family +/// names (`opus`, `sonnet`, `haiku`), and the release code names (`mythos`, +/// `fable`). Getting a Claude model onto the Anthropic Messages route is what +/// lets it carry a `cache_control` breakpoint; an endpoint that matches none of +/// these falls through to the MLflow (OpenAI-wire) path, where Anthropic prompt +/// caching is structurally impossible and the discount is silently lost. +const DATABRICKS_V2_CLAUDE_NAMES: &[&str] = + &["claude", "opus", "sonnet", "haiku", "mythos", "fable"]; + +/// Split a Databricks v2 endpoint name into its lowercase alphanumeric segments, +/// breaking on any non-alphanumeric delimiter (`-`, `_`, `.`, `/`, …). E.g. +/// `Databricks-Claude-Opus-5` -> `["databricks", "claude", "opus", "5"]`. +fn model_name_segments(model: &str) -> Vec { + model + .split(|c: char| !c.is_ascii_alphanumeric()) + .filter(|s| !s.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + fn databricks_v2_route_for_model(model: &str) -> DatabricksV2Route { - // Databricks v2 catalog names currently identify OpenAI-shaped GPT-5 - // models and Anthropic-shaped Claude models by these substrings. - let lower = model.to_ascii_lowercase(); - if lower.contains("gpt-5") || lower.contains("gpt5") { + // The v2 catalog exposes no family field, so the wire format is inferred + // from the endpoint name. Discovery deliberately keeps arbitrary custom + // aliases, so we match whole name *segments* rather than raw substrings: a + // substring test would misroute unrelated names — `consolidated-llama` + // (`sol`), `terraform-coder` (`terra`), `corpus-reranker`/`octopus-model` + // (`opus`) — onto a wire whose request shape their backend can't parse, + // turning a caching optimization into a hard request/parse failure. Segment + // matching still accepts real prefixed names like `goose-opus-5`. + let segments = model_name_segments(model); + let has_named_segment = + |names: &[&str]| segments.iter().any(|seg| names.contains(&seg.as_str())); + // `gpt` family: any segment beginning with `gpt` — covers `gpt`, `gpt5`, and + // the `gpt` segment of a split `gpt-5`, without matching mid-word. + let is_gpt_family = segments.iter().any(|seg| seg.starts_with("gpt")); + // OpenAI is checked before Claude so a name carrying both markers resolves + // to the OpenAI wire (preserving the prior `gpt-5`-first precedence). + if is_gpt_family || has_named_segment(DATABRICKS_V2_OPENAI_CODE_NAMES) { DatabricksV2Route::OpenAiResponses - } else if lower.contains("claude") { + } else if has_named_segment(DATABRICKS_V2_CLAUDE_NAMES) { DatabricksV2Route::AnthropicMessages } else { DatabricksV2Route::MlflowChatCompletions @@ -2579,20 +2619,105 @@ mod tests { #[test] fn databricks_v2_routes_by_model_family() { + use DatabricksV2Route::{AnthropicMessages, MlflowChatCompletions, OpenAiResponses}; for (model, route, path) in [ + // OpenAI-shaped: the gpt family plus the GPT-5 code names. ( "databricks-gpt-5-5", - DatabricksV2Route::OpenAiResponses, + OpenAiResponses, + "/ai-gateway/openai/v1/responses", + ), + ("gpt-4o", OpenAiResponses, "/ai-gateway/openai/v1/responses"), + // The intentional dashless `gpt5` spelling still routes to OpenAI. + ("gpt5", OpenAiResponses, "/ai-gateway/openai/v1/responses"), + ( + "databricks-gpt-5-6-luna", + OpenAiResponses, + "/ai-gateway/openai/v1/responses", + ), + ( + "databricks-gpt-5-6-sol", + OpenAiResponses, + "/ai-gateway/openai/v1/responses", + ), + ( + "databricks-terra", + OpenAiResponses, "/ai-gateway/openai/v1/responses", ), + // Anthropic-shaped: the claude prefix, the family names, and the + // release code names — each must reach the cache-capable route even + // when the endpoint name omits the literal "claude". ( "databricks-claude-opus-4-7", - DatabricksV2Route::AnthropicMessages, + AnthropicMessages, "/ai-gateway/anthropic/v1/messages", ), + ( + "goose-opus-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + ( + "databricks-sonnet-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + ( + "databricks-haiku-4-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + ( + "databricks-mythos-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + ( + "databricks-fable-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + // Case-insensitive. + ( + "Databricks-Claude-Opus-5", + AnthropicMessages, + "/ai-gateway/anthropic/v1/messages", + ), + // Unrecognised names still fall through to the MLflow chat route. ( "custom-tool-model", - DatabricksV2Route::MlflowChatCompletions, + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + ( + "databricks-gemini-3-pro", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + // Collision guard: short code names must match only as whole + // segments, never as substrings of an unrelated custom alias. + // Each of these embeds a marker (`sol`, `terra`, `opus`) mid-word + // and must stay on the MLflow fallback, not adopt a wire its + // backend can't parse. + ( + "consolidated-llama", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + ( + "terraform-coder", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + ( + "corpus-reranker", + MlflowChatCompletions, + "/ai-gateway/mlflow/v1/chat/completions", + ), + ( + "octopus-model", + MlflowChatCompletions, "/ai-gateway/mlflow/v1/chat/completions", ), ] { From 4555899ab2bfc7aa47b22dd872253f3704091782 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 29 Jul 2026 16:10:51 +0100 Subject: [PATCH 23/99] Polish mobile navigation and menus (#3486) ## Summary - Add a shared footer fade behind the floating tabs on Home, Activity, and Search. - Use a shared anchored popover for Activity filters and section actions, with working section move controls. - Polish message grouping/press states and remove the initial Search back button. Screenshot 2026-07-29 at 08 49 37 ### Testing - `flutter analyze` - `flutter test` - Release build installed and checked on a connected iPhone ### Screenshots A real-device Activity baseline showing the original solid footer is attached in a PR comment. The updated review build was checked on the connected iPhone. --------- Signed-off-by: kenny lopez --- .../lib/features/activity/activity_page.dart | 5 +- .../activity_page/header_actions.dart | 8 +- .../channel_detail_page/message_bubble.dart | 286 ++++++++-------- .../lib/features/channels/channels_page.dart | 2 + .../features/channels/channels_page/body.dart | 5 +- .../channels/channels_page/sections.dart | 110 ++++--- .../features/channels/thread_detail_page.dart | 306 +++++++++--------- mobile/lib/features/home/home_page.dart | 15 +- mobile/lib/features/search/search_page.dart | 12 +- .../widgets/anchored_popover_menu.dart} | 60 ++-- .../lib/shared/widgets/frosted_scaffold.dart | 6 + .../widgets/mobile_tab_footer_backdrop.dart | 61 ++++ .../features/activity/activity_page_test.dart | 12 + .../features/channels/channels_page_test.dart | 68 ++++ .../features/search/search_page_test.dart | 8 +- .../mobile_tab_footer_backdrop_test.dart | 23 ++ 16 files changed, 614 insertions(+), 373 deletions(-) rename mobile/lib/{features/activity/activity_page/popover_menu.dart => shared/widgets/anchored_popover_menu.dart} (79%) create mode 100644 mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart create mode 100644 mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index d5ea8d1c53..db39850481 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -1,6 +1,4 @@ import 'dart:async'; -import 'dart:math' as math; -import 'dart:ui' show SemanticsRole; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -11,6 +9,7 @@ import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; @@ -34,7 +33,6 @@ import 'reminders_provider.dart'; part 'activity_page/header_actions.dart'; part 'activity_page/inbox_row.dart'; part 'activity_page/lists.dart'; -part 'activity_page/popover_menu.dart'; part 'activity_page/status_views.dart'; /// Conversation-oriented Activity inbox. @@ -284,6 +282,7 @@ class ActivityPage extends HookConsumerWidget { } return FrostedScaffold( + backgroundColor: Colors.transparent, appBar: FrostedAppBar( gradient: context.appColors.topSectionGradient, automaticallyImplyLeading: false, diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 1aab78e622..56592e2e69 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -33,10 +33,10 @@ class _FilterMenuButton extends StatelessWidget { key: const ValueKey('activity-filter-menu'), borderRadius: BorderRadius.circular(Radii.md), onTap: () async { - final selected = await _showActivityPopover( + final selected = await showAnchoredPopover( context: buttonContext, width: 240, - alignment: _ActivityPopoverAlignment.start, + alignment: AnchoredPopoverAlignment.start, offset: const Offset(0, Grid.half), menuPadding: const EdgeInsets.symmetric(vertical: Grid.half), color: context.colors.surface.withValues(alpha: 0.98), @@ -179,10 +179,10 @@ class _InboxOptionsButton extends StatelessWidget { tooltip: 'Activity options', icon: const Icon(LucideIcons.ellipsis, size: 20), onPressed: () async { - final selected = await _showActivityPopover( + final selected = await showAnchoredPopover( context: buttonContext, width: 216, - alignment: _ActivityPopoverAlignment.end, + alignment: AnchoredPopoverAlignment.end, color: context.colors.surface, elevation: 4, shadowColor: context.colors.shadow.withValues(alpha: 0.18), diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 4a49c9b210..5c428fc67b 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -52,156 +52,166 @@ class _MessageBubble extends ConsumerWidget { } } - return Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's trailing - // gutter. InkWell still clips its ink to [borderRadius], while leaving - // overflowing message content visible. - clipBehavior: Clip.none, - child: InkWell( - key: ValueKey('message-row-${message.id}'), + return Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + child: Material( + color: Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - onLongPress: () => showMessageActions( - context: context, - ref: ref, - message: message, - channelId: currentChannelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.xs : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, + // The media carousel intentionally continues through the list's trailing + // gutter. InkWell still clips its ink to [borderRadius], while leaving + // overflowing message content visible. + clipBehavior: Clip.none, + child: InkWell( + key: ValueKey('message-row-${message.id}'), + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + onLongPress: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: currentChannelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _UserAvatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Transform.translate( - offset: Offset(0, showAuthor ? -Grid.quarter : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only(bottom: Grid.quarter), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'message-author-${message.id}', - ), - usernameKey: ValueKey( - 'message-username-${message.id}', - ), - timestampKey: ValueKey( - 'message-timestamp-${message.id}', + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? 0 : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => showUserProfileSheet(context, message.pubkey), + child: _UserAvatar( + profile: profile, + pubkey: message.pubkey, + ), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel(profile), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'message-author-${message.id}', + ), + usernameKey: ValueKey( + 'message-username-${message.id}', + ), + timestampKey: ValueKey( + 'message-timestamp-${message.id}', + ), ), ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), ), - ), + ], ], - ], + ), ), - ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: currentChannelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: currentChannelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: currentChannelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), + onChannelTap: (channelId) { + openChannelLink( + context: context, ref: ref, - message: message, - channelId: currentChannelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, - ), - onChannelTap: (channelId) { - openChannelLink( - context: context, - ref: ref, - channelId: channelId, - currentChannelId: currentChannelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), + channelId: channelId, + currentChannelId: currentChannelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 8edd056e76..ba5d4ebf9d 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -14,6 +14,7 @@ import '../../shared/community/community_icon_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; @@ -226,6 +227,7 @@ class ChannelsPage extends HookConsumerWidget { }, [isReconnectingWithContent]); return FrostedScaffold( + backgroundColor: Colors.transparent, appBar: FrostedAppBar( horizontalInset: _kTopSectionInset, // Under a Buzz theme the community + account avatar strip carries the diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 2e0a570f41..9f0b1dd4a7 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -189,7 +189,10 @@ class _SliverChannelsList extends HookConsumerWidget { } return SliverPadding( - padding: const EdgeInsets.only(top: Grid.xxs, bottom: 80), + padding: EdgeInsets.only( + top: Grid.xxs, + bottom: MediaQuery.paddingOf(context).bottom, + ), sliver: SliverList.list( children: [ if (visibleChannels.isEmpty) diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index 63bd5db9d9..f9fe5453d7 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -152,56 +152,72 @@ class _CustomSectionHeader extends ConsumerWidget { ), ), const SizedBox(width: _kChannelLabelGap), - Text( - section.name, - style: contentListTitleTextStyle.copyWith( - color: sectionColor, - fontWeight: FontWeight.w600, + Expanded( + child: Text( + section.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: contentListTitleTextStyle.copyWith( + color: sectionColor, + fontWeight: FontWeight.w600, + ), ), ), - const Spacer(), - GestureDetector( - onTapUp: (details) async { - final overlay = - Overlay.of(context).context.findRenderObject()! - as RenderBox; - final position = RelativeRect.fromRect( - details.globalPosition & Size.zero, - Offset.zero & overlay.size, - ); - final value = await showMenu( - context: context, - position: position, - items: [ - const PopupMenuItem(value: 'rename', child: Text('Rename')), - PopupMenuItem( - value: 'move_up', - enabled: !isFirst, - child: const Text('Move Up'), - ), - PopupMenuItem( - value: 'move_down', - enabled: !isLast, - child: const Text('Move Down'), + Builder( + builder: (buttonContext) => IconButton( + key: ValueKey('section-menu-${section.id}'), + tooltip: '${section.name} options', + visualDensity: VisualDensity.compact, + icon: Icon( + LucideIcons.ellipsisVertical, + size: _kChannelIconSize, + color: sectionColor, + ), + onPressed: () async { + final value = await showAnchoredPopover( + context: buttonContext, + width: 216, + alignment: AnchoredPopoverAlignment.end, + color: context.colors.surface, + elevation: 4, + shadowColor: context.colors.shadow.withValues(alpha: 0.18), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.md), + side: BorderSide(color: context.colors.outline), ), - const PopupMenuItem(value: 'delete', child: Text('Delete')), - ], - ); - switch (value) { - case 'rename': - onRename(); - case 'move_up': - onMoveUp(); - case 'move_down': - onMoveDown(); - case 'delete': - onDelete(); - } - }, - child: Icon( - LucideIcons.ellipsisVertical, - size: _kChannelIconSize, - color: sectionColor, + surfaceKey: ValueKey('section-popover-${section.id}'), + items: [ + const PopupMenuItem( + value: 'rename', + child: Text('Rename'), + ), + PopupMenuItem( + value: 'move_up', + enabled: !isFirst, + child: const Text('Move Up'), + ), + PopupMenuItem( + value: 'move_down', + enabled: !isLast, + child: const Text('Move Down'), + ), + const PopupMenuItem( + value: 'delete', + child: Text('Delete'), + ), + ], + ); + switch (value) { + case 'rename': + onRename(); + case 'move_up': + onMoveUp(); + case 'move_down': + onMoveDown(); + case 'delete': + onDelete(); + } + }, ), ), const SizedBox(width: Grid.quarter), diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 167b111f4f..e8081f42fe 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -502,168 +502,172 @@ class _ThreadMessage extends ConsumerWidget { } } - return DecoratedBox( - key: ValueKey('thread-message-${message.id}'), - decoration: BoxDecoration( - color: isHighlighted - ? context.colors.primary.withValues(alpha: 0.12) - : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - // The media carousel intentionally continues through the list's - // trailing gutter. InkWell still clips its ink to [borderRadius], - // while leaving overflowing message content visible. - clipBehavior: Clip.none, - child: InkWell( - key: ValueKey('thread-message-row-${message.id}'), + return Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), + child: DecoratedBox( + key: ValueKey('thread-message-${message.id}'), + decoration: BoxDecoration( + color: isHighlighted + ? context.colors.primary.withValues(alpha: 0.12) + : Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), - highlightColor: context.colors.primary.withValues(alpha: 0.1), - onLongPress: () => showMessageActions( - context: context, - ref: ref, - message: message, - channelId: channelId, - canManageMessage: canManageMessage, - allMessages: allMessages, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - child: Padding( - padding: EdgeInsets.only( - top: showAuthor ? Grid.xs : Grid.xxs, - bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(Radii.md), + // The media carousel intentionally continues through the list's + // trailing gutter. InkWell still clips its ink to [borderRadius], + // while leaving overflowing message content visible. + clipBehavior: Clip.none, + child: InkWell( + key: ValueKey('thread-message-row-${message.id}'), + borderRadius: BorderRadius.circular(Radii.md), + highlightColor: context.colors.primary.withValues(alpha: 0.1), + onLongPress: () => showMessageActions( + context: context, + ref: ref, + message: message, + channelId: channelId, + canManageMessage: canManageMessage, + allMessages: allMessages, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - GestureDetector( - onTap: () => showUserProfileSheet(context, message.pubkey), - child: _Avatar(profile: profile, pubkey: message.pubkey), - ) - else - const SizedBox(width: messageAvatarSize), - const SizedBox(width: messageAvatarContentGap), - Expanded( - child: Transform.translate( - offset: Offset(0, showAuthor ? -Grid.quarter : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showAuthor) - Padding( - padding: const EdgeInsets.only( - bottom: Grid.quarter, - ), - child: Row( - children: [ - Expanded( - child: MessageAuthorMeta( - displayName: displayName, - username: messageUsernameLabel(profile), - timestamp: formatMessageTime( - message.createdAt, - ), - nameColor: context.colors.onSurface, - metadataColor: - context.colors.onSurfaceVariant, - onAuthorTap: () => showUserProfileSheet( - context, - message.pubkey, - ), - displayNameKey: ValueKey( - 'thread-message-author-${message.id}', - ), - usernameKey: ValueKey( - 'thread-message-username-${message.id}', - ), - timestampKey: ValueKey( - 'thread-message-timestamp-${message.id}', + child: Padding( + padding: EdgeInsets.only( + top: showAuthor ? 0 : Grid.xxs, + bottom: showAuthor ? 0 : Grid.xxs, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + GestureDetector( + onTap: () => + showUserProfileSheet(context, message.pubkey), + child: _Avatar(profile: profile, pubkey: message.pubkey), + ) + else + const SizedBox(width: messageAvatarSize), + const SizedBox(width: messageAvatarContentGap), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: showAuthor ? Grid.half : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showAuthor) + Padding( + padding: const EdgeInsets.only( + bottom: Grid.quarter, + ), + child: Row( + children: [ + Expanded( + child: MessageAuthorMeta( + displayName: displayName, + username: messageUsernameLabel(profile), + timestamp: formatMessageTime( + message.createdAt, + ), + nameColor: context.colors.onSurface, + metadataColor: + context.colors.onSurfaceVariant, + onAuthorTap: () => showUserProfileSheet( + context, + message.pubkey, + ), + displayNameKey: ValueKey( + 'thread-message-author-${message.id}', + ), + usernameKey: ValueKey( + 'thread-message-username-${message.id}', + ), + timestampKey: ValueKey( + 'thread-message-timestamp-${message.id}', + ), ), ), - ), - if (message.edited) ...[ - const SizedBox(width: Grid.half), - Text( - '(edited)', - style: context.textTheme.labelSmall - ?.copyWith( - color: - context.colors.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), + if (message.edited) ...[ + const SizedBox(width: Grid.half), + Text( + '(edited)', + style: context.textTheme.labelSmall + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + ), + ], ], - ], + ), ), - ), - MessageContent( - content: message.content, - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - channelNames: channelNames, - tags: message.tags, - baseStyle: messageBodyTextStyle.copyWith( - color: context.colors.onSurface, - ), - mediaCarouselTrailingOverflow: Grid.gutter, - onMediaReply: allMessages == null - ? null - : () { - if (!context.mounted) return; - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ThreadDetailPage( - threadHead: message, - allMessages: allMessages!, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, + MessageContent( + content: message.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, + channelNames: channelNames, + tags: message.tags, + baseStyle: messageBodyTextStyle.copyWith( + color: context.colors.onSurface, + ), + mediaCarouselTrailingOverflow: Grid.gutter, + onMediaReply: allMessages == null + ? null + : () { + if (!context.mounted) return; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: message, + allMessages: allMessages!, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), ), - ), - ); - }, - onMediaMore: (viewerContext, imageUrl) => - showImageActions( - context: viewerContext, + ); + }, + onMediaMore: (viewerContext, imageUrl) => + showImageActions( + context: viewerContext, + ref: ref, + message: message, + channelId: channelId, + imageUrl: imageUrl, + canManageMessage: canManageMessage, + onDeleted: () { + if (viewerContext.mounted) { + Navigator.of(viewerContext).maybePop(); + } + }, + ), + onChannelTap: (targetChannelId) { + openChannelLink( + context: context, ref: ref, - message: message, - channelId: channelId, - imageUrl: imageUrl, - canManageMessage: canManageMessage, - onDeleted: () { - if (viewerContext.mounted) { - Navigator.of(viewerContext).maybePop(); - } - }, - ), - onChannelTap: (targetChannelId) { - openChannelLink( - context: context, - ref: ref, - channelId: targetChannelId, - currentChannelId: channelId, - ); - }, - onMentionTap: (pubkey) => - showUserProfileSheet(context, pubkey), - ), - if (message.reactions.isNotEmpty) - ReactionRow( - reactions: message.reactions, - onToggle: (emoji) => - toggleReaction(ref, message, emoji), + channelId: targetChannelId, + currentChannelId: channelId, + ); + }, + onMentionTap: (pubkey) => + showUserProfileSheet(context, pubkey), ), - ], + if (message.reactions.isNotEmpty) + ReactionRow( + reactions: message.reactions, + onToggle: (emoji) => + toggleReaction(ref, message, emoji), + ), + ], + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 7c1e5792f9..a40e317469 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../activity/activity_page.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; @@ -17,12 +18,12 @@ class HomePage extends HookConsumerWidget { final WidgetBuilder settingsPageBuilder; - static const double _tabBarHeight = 56; + static const double _tabBarHeight = mobileTabBarHeight; static const double _tabBarRadius = _tabBarHeight / 2; static const double _tabBarInnerInset = Grid.half; static const double _selectedTabRadius = (_tabBarHeight - (_tabBarInnerInset * 2)) / 2; - static const double _tabBarBottomGap = Grid.twelve; + static const double _tabBarBottomGap = mobileTabBarBottomGap; static const double _tabBarHorizontalMargin = Grid.gutter; static const double _tabDestinationHorizontalPadding = Grid.sm; static const double _tabIconSize = 22; @@ -63,6 +64,7 @@ class HomePage extends HookConsumerWidget { ]; return Scaffold( + backgroundColor: Colors.transparent, // Keep the floating navigation and Home quick actions anchored while the // keyboard is visible on any tab. resizeToAvoidBottomInset: false, @@ -71,6 +73,7 @@ class HomePage extends HookConsumerWidget { child: Stack( fit: StackFit.expand, children: [ + Positioned.fill(child: ColoredBox(color: context.colors.surface)), Positioned.fill( child: MediaQuery( data: _mediaQueryWithFloatingTabBarClearance( @@ -80,6 +83,14 @@ class HomePage extends HookConsumerWidget { child: IndexedStack(index: tabIndex.value, children: pages), ), ), + Align( + alignment: Alignment.bottomCenter, + child: IgnorePointer( + child: MobileTabFooterBackdrop( + height: mobileTabFooterBackdropHeight(context), + ), + ), + ), Positioned.fill( child: ChannelQuickActionsLauncher( visible: tabIndex.value == 0, diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 7f4dc24b45..b608b65aef 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -93,10 +93,12 @@ class SearchPage extends HookConsumerWidget { } return FrostedScaffold( + backgroundColor: Colors.transparent, // Keep the empty state centered in the page rather than the portion left // above the keyboard. resizeToAvoidBottomInset: false, appBar: FrostedAppBar( + automaticallyImplyLeading: false, gradient: context.appColors.topSectionGradient, title: const Text('Search'), titleStyle: headerTitleStyle, @@ -344,7 +346,10 @@ class _SearchBody extends ConsumerWidget { return ListView( key: const Key('search-results-list'), padding: EdgeInsets.only( - bottom: Grid.xl + MediaQuery.viewInsetsOf(context).bottom, + bottom: + Grid.xl + + MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom, ), children: [ if (showChannels && state.channelResults.isNotEmpty) @@ -394,7 +399,10 @@ class _RecentSearches extends StatelessWidget { return ListView( key: const Key('recent-searches-list'), padding: EdgeInsets.only( - bottom: Grid.xl + MediaQuery.viewInsetsOf(context).bottom, + bottom: + Grid.xl + + MediaQuery.paddingOf(context).bottom + + MediaQuery.viewInsetsOf(context).bottom, ), children: [ Padding( diff --git a/mobile/lib/features/activity/activity_page/popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart similarity index 79% rename from mobile/lib/features/activity/activity_page/popover_menu.dart rename to mobile/lib/shared/widgets/anchored_popover_menu.dart index 56d0a612ec..46b50b6b00 100644 --- a/mobile/lib/features/activity/activity_page/popover_menu.dart +++ b/mobile/lib/shared/widgets/anchored_popover_menu.dart @@ -1,16 +1,30 @@ -part of '../activity_page.dart'; +import 'dart:math' as math; +import 'dart:ui' show SemanticsRole; -const _activityPopoverEnterDuration = Duration(milliseconds: 150); -const _activityPopoverExitDuration = Duration(milliseconds: 110); -const _activityPopoverStartScale = 0.96; +import 'package:flutter/material.dart'; -enum _ActivityPopoverAlignment { start, end } +import '../theme/theme.dart'; -Future _showActivityPopover({ +const _popoverEnterDuration = Duration(milliseconds: 150); +const _popoverExitDuration = Duration(milliseconds: 110); +const _popoverStartScale = 0.96; + +/// The horizontal edge a popover aligns to on its triggering control. +enum AnchoredPopoverAlignment { + /// Aligns the popover's leading edge with the trigger's leading edge. + start, + + /// Aligns the popover's trailing edge with the trigger's trailing edge. + end, +} + +/// Shows an anchored, cross-platform popup menu with the Activity controls' +/// sizing, motion, and safe-area placement. +Future showAnchoredPopover({ required BuildContext context, required List> items, required double width, - required _ActivityPopoverAlignment alignment, + required AnchoredPopoverAlignment alignment, required Color color, required ShapeBorder shape, required double elevation, @@ -36,7 +50,7 @@ Future _showActivityPopover({ final mediaQuery = MediaQuery.of(context); return navigator.push( - _ActivityPopoverRoute( + _AnchoredPopoverRoute( position: RelativeRect.fromRect(triggerRect, overlayRect), items: items, width: width, @@ -61,11 +75,11 @@ Future _showActivityPopover({ ); } -class _ActivityPopoverRoute extends PopupRoute { +class _AnchoredPopoverRoute extends PopupRoute { final RelativeRect position; final List> items; final double width; - final _ActivityPopoverAlignment alignment; + final AnchoredPopoverAlignment alignment; final Offset offset; final Color color; final ShapeBorder shape; @@ -78,7 +92,7 @@ class _ActivityPopoverRoute extends PopupRoute { final bool reducedMotion; final String _barrierLabel; - _ActivityPopoverRoute({ + _AnchoredPopoverRoute({ required this.position, required this.items, required this.width, @@ -107,11 +121,11 @@ class _ActivityPopoverRoute extends PopupRoute { @override Duration get transitionDuration => - reducedMotion ? Duration.zero : _activityPopoverEnterDuration; + reducedMotion ? Duration.zero : _popoverEnterDuration; @override Duration get reverseTransitionDuration => - reducedMotion ? Duration.zero : _activityPopoverExitDuration; + reducedMotion ? Duration.zero : _popoverExitDuration; @override Widget buildPage( @@ -123,16 +137,16 @@ class _ActivityPopoverRoute extends PopupRoute { CurveTween(curve: Curves.easeOutCubic), ); final scaleAnimation = Tween( - begin: _activityPopoverStartScale, + begin: _popoverStartScale, end: 1, ).animate(curvedAnimation); final transformOrigin = switch (alignment) { - _ActivityPopoverAlignment.start => Alignment.topLeft, - _ActivityPopoverAlignment.end => Alignment.topRight, + AnchoredPopoverAlignment.start => Alignment.topLeft, + AnchoredPopoverAlignment.end => Alignment.topRight, }; return CustomSingleChildLayout( - delegate: _ActivityPopoverLayoutDelegate( + delegate: _AnchoredPopoverLayoutDelegate( position: position, alignment: alignment, offset: offset, @@ -174,13 +188,13 @@ class _ActivityPopoverRoute extends PopupRoute { } } -class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate { +class _AnchoredPopoverLayoutDelegate extends SingleChildLayoutDelegate { final RelativeRect position; - final _ActivityPopoverAlignment alignment; + final AnchoredPopoverAlignment alignment; final Offset offset; final EdgeInsets screenPadding; - const _ActivityPopoverLayoutDelegate({ + const _AnchoredPopoverLayoutDelegate({ required this.position, required this.alignment, required this.offset, @@ -201,8 +215,8 @@ class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate { Offset getPositionForChild(Size size, Size childSize) { final anchorBottom = size.height - position.bottom; final desiredX = switch (alignment) { - _ActivityPopoverAlignment.start => position.left + offset.dx, - _ActivityPopoverAlignment.end => + AnchoredPopoverAlignment.start => position.left + offset.dx, + AnchoredPopoverAlignment.end => size.width - position.right - childSize.width + offset.dx, }; final minX = screenPadding.left; @@ -222,7 +236,7 @@ class _ActivityPopoverLayoutDelegate extends SingleChildLayoutDelegate { } @override - bool shouldRelayout(_ActivityPopoverLayoutDelegate oldDelegate) { + bool shouldRelayout(_AnchoredPopoverLayoutDelegate oldDelegate) { return position != oldDelegate.position || alignment != oldDelegate.alignment || offset != oldDelegate.offset || diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 6b7e39fd75..fc0e2fe506 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -21,17 +21,23 @@ class FrostedScaffold extends StatelessWidget { /// Whether the body should resize when the on-screen keyboard appears. final bool? resizeToAvoidBottomInset; + /// Optional scaffold background, useful when a parent supplies a shared + /// surface behind this page. + final Color? backgroundColor; + const FrostedScaffold({ super.key, required this.appBar, required this.body, this.floatingActionButton, this.resizeToAvoidBottomInset, + this.backgroundColor, }); @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: backgroundColor, resizeToAvoidBottomInset: resizeToAvoidBottomInset, floatingActionButton: floatingActionButton, body: Stack(children: [body, appBar]), diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart new file mode 100644 index 0000000000..687880acb1 --- /dev/null +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +/// Height of the floating mobile tab bar, excluding its bottom clearance. +const mobileTabBarHeight = 56.0; + +/// Gap between the floating mobile tab bar and the bottom safe area. +const mobileTabBarBottomGap = Grid.twelve; + +/// Returns the shared footer backdrop height, including the logical safe area. +double mobileTabFooterBackdropHeight(BuildContext context) => + mobileTabBarHeight + + mobileTabBarBottomGap + + MediaQuery.paddingOf(context).bottom + + Grid.xl + + Grid.gutter; + +/// Shared fade behind the floating mobile tab bar. +class MobileTabFooterBackdrop extends StatelessWidget { + /// Vertical extent of the backdrop in logical pixels. + final double height; + + /// Gradient stop positions, from the transparent top to the opaque bottom. + final List stops; + + /// Surface-color alpha values paired with [stops]. + final List opacities; + + /// Creates a footer backdrop with the required [height]. + /// + /// Override [stops] and [opacities] together to customize the gradient. + const MobileTabFooterBackdrop({ + super.key, + required this.height, + this.stops = const [0, 0.5, 1], + this.opacities = const [0, 0.75, 1], + }) : assert(stops.length == opacities.length); + + @override + Widget build(BuildContext context) { + final surface = context.colors.surface; + return SizedBox( + height: height, + width: double.infinity, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: stops, + colors: [ + for (final opacity in opacities) + surface.withValues(alpha: opacity), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 19b170b52a..a0293455f2 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -182,6 +182,18 @@ void main() { expect(find.byTooltip('Back'), findsNothing); }); + testWidgets('keeps bottom clearance for the floating tab bar', ( + tester, + ) async { + await tester.pumpWidget(await buildTestable()); + await tester.pumpAndSettle(); + + final safeAreas = tester.widgetList(find.byType(SafeArea)); + expect(safeAreas, hasLength(1)); + expect(safeAreas.single.top, isFalse); + expect(safeAreas.single.bottom, isTrue); + }); + testWidgets('shows error view with retry button', (tester) async { await tester.pumpWidget( await buildTestable(activityNotifier: _ErrorActivityNotifier.new), diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 2a624cbde7..991db3b5cd 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -8,6 +8,8 @@ import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_provider.dart'; +import 'package:buzz/features/channels/channel_sections/channel_sections_storage.dart'; import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; @@ -28,6 +30,7 @@ void main() { bool previewDirectory = false, double keyboardInset = 0, bool disableAnimations = false, + double bottomPadding = 0, Map communityIcons = const {}, ValueChanged? onCommunityIconLoad, TextScaler textScaler = TextScaler.noScaling, @@ -52,6 +55,7 @@ void main() { data: MediaQuery.of(context).copyWith( disableAnimations: disableAnimations, textScaler: textScaler, + padding: EdgeInsets.only(bottom: bottomPadding), viewInsets: EdgeInsets.only(bottom: keyboardInset), ), child: child!, @@ -142,6 +146,60 @@ void main() { expect(sectionTitle.style?.fontWeight, FontWeight.w600); }); + testWidgets('keeps the last channel above the floating tab bar', ( + tester, + ) async { + const footerClearance = 102.0; + await tester.pumpWidget( + buildTestable( + bottomPadding: footerClearance, + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + final padding = tester.widget( + find.descendant( + of: find.byType(CustomScrollView), + matching: find.byType(SliverPadding), + ), + ); + expect((padding.padding as EdgeInsets).bottom, footerClearance); + }); + + testWidgets('truncates long custom section names beside the menu', ( + tester, + ) async { + tester.view.physicalSize = const Size(320, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + const sectionName = 'A deliberately long custom section name for testing'; + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelSectionsProvider.overrideWith( + () => _FakeChannelSectionsNotifier( + const ChannelSectionStore( + sections: [ + ChannelSection(id: 'section-1', name: sectionName, order: 0), + ], + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + final label = tester.widget(find.text(sectionName)); + expect(label.maxLines, 1); + expect(label.overflow, TextOverflow.ellipsis); + expect(tester.takeException(), isNull); + }); + testWidgets('aligns the top, section, row, and skeleton label columns', ( tester, ) async { @@ -1384,6 +1442,16 @@ class _FakeNotifier extends ChannelsNotifier { get observedUnreadEventsByChannel => _observedEventsByChannel; } +class _FakeChannelSectionsNotifier extends ChannelSectionsNotifier { + _FakeChannelSectionsNotifier(this._store); + + final ChannelSectionStore _store; + + @override + ChannelSectionsState build() => + ChannelSectionsState(isReady: true, store: _store, version: 1); +} + class _FakeCommunityListNotifier extends CommunityListNotifier { _FakeCommunityListNotifier(this._communities); diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index 52c2fb23ae..e4a576b91d 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -248,6 +248,7 @@ void main() { tester, ) async { const keyboardInset = 300.0; + const footerClearance = 102.0; await tester.pumpWidget( WidgetHelpers.testable( @@ -266,6 +267,7 @@ void main() { child: Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.only(bottom: footerClearance), viewInsets: const EdgeInsets.only(bottom: keyboardInset), ), child: const SearchPage(), @@ -282,13 +284,14 @@ void main() { ); final padding = recentSearches.padding! as EdgeInsets; - expect(padding.bottom, Grid.xl + keyboardInset); + expect(padding.bottom, Grid.xl + footerClearance + keyboardInset); }); testWidgets('keeps search results scrollable above the keyboard', ( tester, ) async { const keyboardInset = 300.0; + const footerClearance = 102.0; final state = SearchState( query: 'general', channelResults: [ @@ -318,6 +321,7 @@ void main() { child: Builder( builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith( + padding: const EdgeInsets.only(bottom: footerClearance), viewInsets: const EdgeInsets.only(bottom: keyboardInset), ), child: const SearchPage(), @@ -332,7 +336,7 @@ void main() { ); final padding = results.padding! as EdgeInsets; - expect(padding.bottom, Grid.xl + keyboardInset); + expect(padding.bottom, Grid.xl + footerClearance + keyboardInset); }); testWidgets('keeps no-results feedback above the keyboard', (tester) async { diff --git a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart new file mode 100644 index 0000000000..9e54177d61 --- /dev/null +++ b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart @@ -0,0 +1,23 @@ +import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('uses the logical bottom safe-area inset', (tester) async { + double? height; + + await tester.pumpWidget( + MediaQuery( + data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)), + child: Builder( + builder: (context) { + height = mobileTabFooterBackdropHeight(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(height, 170); + }); +} From f7a3988ba13b590d9a55a7e8413fc3fb5ffbef18 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 09:13:54 -0600 Subject: [PATCH 24/99] fix(desktop): preserve shared agent fidelity (#3553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes two distinct fidelity failures in direct agent sharing: - The sender now puts the same effective avatar shown on the agent card into People-share and file-export snapshot PNGs, including profile/kind:0 fallback avatars. - The importer now persists the visible PNG body as the portable avatar instead of ignoring it in favor of sender-local manifest references. - Export materializes inherited runtime, provider, and model identifiers verbatim, while preserving explicit definition values. It does not translate or substitute configuration for a different recipient setup. - Sharing waits for a profile-only fallback avatar query, preventing an early-click race. The PNG import path keeps the existing safety invariant: decode is capped at 2048×2048 / 32 MiB and re-encoded avatars above the 2 MiB inline limit fall back to the manifest reference. The exact transparent 1×1 no-avatar placeholder is ignored. The original Tyler↔Wes screenshot demonstrates both stages: Wren's attachment had an avatar that disappeared after **Add agent** (receiver/import failure), while Pinky's attachment was already blank (sender/projection failure). ### Related issue N/A — reported and traced in the linked Buzz conversation. ### Testing - `cargo test --manifest-path desktop/src-tauri/Cargo.toml commands::personas::snapshot` — 57 passed - `pnpm exec tsc --noEmit` - Biome check on changed frontend/E2E files - Pre-push hooks: - desktop check - desktop tests - desktop Tauri tests — 1853 passed, 14 ignored - file-size ratchet The People-share E2E regression asserts that a profile-only avatar reaches `avatarPngDataUrl` in the real encode command payload. --------- Signed-off-by: Wes Co-authored-by: Carl --- .../src/commands/personas/snapshot.rs | 36 +++ .../personas/snapshot/fidelity_tests.rs | 210 ++++++++++++++++++ .../src/commands/personas/snapshot/import.rs | 10 +- desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/snapshot_avatar.rs | 42 ++++ desktop/src/features/agents/ui/AgentsView.tsx | 2 + .../features/agents/ui/PersonaShareDialog.tsx | 6 +- .../agents/ui/UnifiedAgentsSection.tsx | 23 +- .../features/agents/ui/usePersonaActions.ts | 7 +- desktop/tests/e2e/agents.spec.ts | 23 ++ 10 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/snapshot_avatar.rs diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index a3ba731875..583296dac0 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -164,6 +164,33 @@ fn parse_format_is_png(s: &str) -> Result { } } +fn materialize_portable_runtime_defaults( + record: &mut ManagedAgentRecord, + global: &crate::managed_agents::GlobalAgentConfig, +) { + if record + .model + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + record.model = global.model.clone(); + } + if record + .provider + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + record.provider = global.provider.clone(); + } + if record + .runtime + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + record.runtime = global.preferred_runtime.clone(); + } +} + /// Shared production encoding path. /// /// Resolves the agent definition, validates inputs, fetches optional memory, @@ -196,6 +223,13 @@ pub(crate) async fn materialize_snapshot_bytes( let definitions = load_agent_definitions(&app)?; let (def_record, is_definition) = resolve_from_lists(&id, &instances, &definitions) .map(|(r, is_def)| (r.clone(), is_def))?; + let mut def_record = def_record; + // A snapshot is a verbatim portable copy of the effective runtime, + // provider, and model configuration, not a pointer to the sender's + // machine-wide defaults. This does not translate or substitute values + // for a different recipient setup. + let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + materialize_portable_runtime_defaults(&mut def_record, &global); let memory_pubkey = if memory_level != MemoryLevel::None { let mpk = memory_source_pubkey.as_deref().unwrap_or(""); @@ -400,6 +434,8 @@ pub async fn encode_agent_snapshot_for_send( }) } +#[cfg(test)] +mod fidelity_tests; #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs new file mode 100644 index 0000000000..00a1457393 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -0,0 +1,210 @@ +use super::import::decode_snapshot_from_bytes; +use super::*; +use crate::managed_agents::{ + agent_snapshot::{ + AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, AgentSnapshotProfile, + FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }, + BackendKind, ManagedAgentRecord, RespondTo, +}; +use std::collections::BTreeMap; + +fn make_definition(slug: &str) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + slug: Some(slug.to_string()), + name: slug.to_string(), + display_name: None, + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::default(), + respond_to_allowlist: vec![], + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: false, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + relay_mesh: None, + } +} + +/// Build a minimal valid AgentSnapshot for import tests. +fn make_snapshot( + memory_level: MemoryLevel, + entries: Vec, +) -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Test Agent".to_string(), + source_is_builtin: false, + system_prompt: Some("You are helpful.".to_string()), + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test Agent".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: memory_level, + entries, + }, + } +} + +// ── Portable effective configuration ───────────────────────────────────── + +#[test] +fn inherited_runtime_provider_and_model_are_materialized_for_export() { + let mut record = make_definition("wren"); + let global = crate::managed_agents::GlobalAgentConfig { + preferred_runtime: Some("goose".to_string()), + provider: Some("databricks_v2".to_string()), + model: Some("databricks-gpt-5-6-sol".to_string()), + ..Default::default() + }; + + materialize_portable_runtime_defaults(&mut record, &global); + + assert_eq!(record.runtime.as_deref(), Some("goose")); + assert_eq!(record.provider.as_deref(), Some("databricks_v2")); + assert_eq!(record.model.as_deref(), Some("databricks-gpt-5-6-sol")); +} + +#[test] +fn explicit_runtime_provider_and_model_win_over_global_defaults() { + let mut record = make_definition("wren"); + record.runtime = Some("claude".to_string()); + record.provider = Some("anthropic".to_string()); + record.model = Some("claude-opus-5".to_string()); + let global = crate::managed_agents::GlobalAgentConfig { + preferred_runtime: Some("goose".to_string()), + provider: Some("databricks_v2".to_string()), + model: Some("databricks-gpt-5-6-sol".to_string()), + ..Default::default() + }; + + materialize_portable_runtime_defaults(&mut record, &global); + + assert_eq!(record.runtime.as_deref(), Some("claude")); + assert_eq!(record.provider.as_deref(), Some("anthropic")); + assert_eq!(record.model.as_deref(), Some("claude-opus-5")); +} + +/// PNG image-body avatar overrides manifest avatar fields and all definition +/// config survives the exact production decoder. +#[test] +fn import_png_body_avatar_and_full_model_round_trip() { + use crate::managed_agents::agent_snapshot::{decode_avatar_data_url, encode_snapshot_png}; + + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.definition.runtime = Some("goose".to_string()); + snapshot.definition.model = Some("databricks-gpt-5-6-sol".to_string()); + snapshot.definition.provider = Some("databricks_v2".to_string()); + snapshot.profile.avatar_data_url = None; + snapshot.profile.avatar_url = Some("https://sender.invalid/avatar.png".to_string()); + + let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 3, + image::Rgba([23, 91, 177, 255]), + )); + let mut avatar_png = std::io::Cursor::new(Vec::new()); + avatar + .write_to(&mut avatar_png, image::ImageFormat::Png) + .unwrap(); + let png_bytes = encode_snapshot_png(&snapshot, Some(avatar_png.get_ref())).unwrap(); + + let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap(); + assert_eq!(decoded.definition.runtime.as_deref(), Some("goose")); + assert_eq!( + decoded.definition.model.as_deref(), + Some("databricks-gpt-5-6-sol") + ); + assert_eq!( + decoded.definition.provider.as_deref(), + Some("databricks_v2") + ); + assert_eq!( + decoded.profile.avatar_url.as_deref(), + Some("https://sender.invalid/avatar.png") + ); + + let avatar_data_url = decoded + .profile + .avatar_data_url + .as_deref() + .expect("PNG image body must become the effective portable avatar"); + let avatar_bytes = decode_avatar_data_url(avatar_data_url).unwrap(); + let imported_avatar = image::load_from_memory(&avatar_bytes).unwrap(); + assert_eq!((imported_avatar.width(), imported_avatar.height()), (4, 3)); + assert_eq!( + imported_avatar.to_rgba8().get_pixel(0, 0).0, + [23, 91, 177, 255] + ); +} + +/// The transparent 1×1 no-avatar card must not override a manifest fallback. +#[test] +fn import_png_placeholder_keeps_manifest_avatar_fallback() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = None; + snapshot.profile.avatar_url = Some("https://example.com/avatar.png".to_string()); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + + let decoded = decode_snapshot_from_bytes(&png_bytes).unwrap(); + assert!(decoded.profile.avatar_data_url.is_none()); + assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 9d7d238918..7648941d23 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -220,7 +220,15 @@ pub(crate) fn decode_snapshot_from_bytes( file_bytes.len() / (1024 * 1024) )); } - let snapshot = decode_snapshot_png(file_bytes)?; + let mut snapshot = decode_snapshot_png(file_bytes)?; + // The PNG image body is the portable avatar. It deliberately wins over + // manifest avatar fields, whose URL may only be reachable by the + // sender. A 1×1 export placeholder leaves the manifest fallback intact. + if let Some(avatar_data_url) = + crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(file_bytes)? + { + snapshot.profile.avatar_data_url = Some(avatar_data_url); + } if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { return Err( "Snapshot is malformed: memory.level is 'none' but entries are present." diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index b0e86f8edb..be9b07cf11 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -29,6 +29,7 @@ pub mod retention; mod runtime; mod runtime_commands; mod runtime_types; +pub(crate) mod snapshot_avatar; pub(crate) mod spawn_hash; pub(crate) mod storage; pub(crate) mod team_events; diff --git a/desktop/src-tauri/src/managed_agents/snapshot_avatar.rs b/desktop/src-tauri/src/managed_agents/snapshot_avatar.rs new file mode 100644 index 0000000000..a1044b31f4 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/snapshot_avatar.rs @@ -0,0 +1,42 @@ +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use image::ImageDecoder; +use std::io::Cursor; + +const MAX_AVATAR_INLINE_BYTES: usize = 2 * 1024 * 1024; +const MAX_AVATAR_DIMENSION: u32 = 2048; +const MAX_AVATAR_DECODE_ALLOC: u64 = 32 * 1024 * 1024; + +/// Materialize a snapshot PNG's visible pixels as a bounded portable avatar. +/// The exact transparent 1×1 no-avatar placeholder and images that cannot fit +/// the persisted inline-avatar budget leave the manifest fallback intact. +pub(crate) fn snapshot_png_avatar_data_url(png_bytes: &[u8]) -> Result, String> { + let reader = image::ImageReader::with_format(Cursor::new(png_bytes), image::ImageFormat::Png); + let mut decoder = reader + .into_decoder() + .map_err(|e| format!("Failed to decode snapshot avatar: {e}"))?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_AVATAR_DIMENSION); + limits.max_image_height = Some(MAX_AVATAR_DIMENSION); + limits.max_alloc = Some(MAX_AVATAR_DECODE_ALLOC); + decoder + .set_limits(limits) + .map_err(|e| format!("Snapshot avatar exceeds safe decoding limits: {e}"))?; + let (width, height) = decoder.dimensions(); + let image = image::DynamicImage::from_decoder(decoder) + .map_err(|e| format!("Failed to decode snapshot avatar: {e}"))?; + if width == 1 && height == 1 && image.to_rgba8().get_pixel(0, 0).0 == [0, 0, 0, 0] { + return Ok(None); + } + + let mut clean_png = Vec::new(); + image + .write_to(&mut Cursor::new(&mut clean_png), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode snapshot avatar: {e}"))?; + if clean_png.len() > MAX_AVATAR_INLINE_BYTES { + return Ok(None); + } + Ok(Some(format!( + "data:image/png;base64,{}", + STANDARD.encode(clean_png) + ))) +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 6e55f92dfe..f24a3c06d7 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -358,6 +358,7 @@ export function AgentsView() { )} isPending={personas.isPending} linkedAgentPubkey={personas.personaToShare.linkedAgentPubkey} + effectiveAvatarUrl={personas.personaToShare.effectiveAvatarUrl} onCatalogShareLevelChange={(shareLevel) => { const shareTarget = personas.personaToShare; if (!shareTarget) return; @@ -392,6 +393,7 @@ export function AgentsView() { personas.handleExportSnapshot( personas.personaToExportSnapshot.persona, personas.personaToExportSnapshot.linkedAgentPubkey, + personas.personaToExportSnapshot.effectiveAvatarUrl, memoryLevel, format, ); diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index af45e8f071..c641de9c70 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -54,6 +54,7 @@ type PersonaShareDialogProps = { catalogShareLevel: CatalogPersonaShareLevel; isPending: boolean; linkedAgentPubkey: string | null; + effectiveAvatarUrl: string | null; onCatalogShareLevelChange: (shareLevel: CatalogPersonaShareLevel) => void; onExport: () => void; onOpenChange: (open: boolean) => void; @@ -694,6 +695,7 @@ export function PersonaShareDialog({ catalogShareLevel, isPending, linkedAgentPubkey, + effectiveAvatarUrl, onCatalogShareLevelChange, onExport, onOpenChange, @@ -715,12 +717,12 @@ export function PersonaShareDialog({ memoryLevel: linkedAgentPubkey ? memoryLevel : "none", format: "png", memorySourcePubkey: linkedAgentPubkey, - avatarPngDataUrl: await resolveSnapshotAvatarPng(persona.avatarUrl), + avatarPngDataUrl: await resolveSnapshotAvatarPng(effectiveAvatarUrl), }), [ encodeSnapshotMutation.mutateAsync, + effectiveAvatarUrl, linkedAgentPubkey, - persona.avatarUrl, persona.id, ], ); diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 34f9f9819f..9bbe3feef7 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -58,6 +58,7 @@ type UnifiedAgentsSectionProps = { onSharePersona: ( persona: AgentPersona, linkedAgent: ManagedAgent | undefined, + effectiveAvatarUrl: string | null, ) => void; onDeactivatePersona: (persona: AgentPersona) => void; onDeletePersona: (persona: AgentPersona) => void; @@ -157,9 +158,11 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const profileAgent = pickProfileAgent(group.agents); return ( ( + onSharePersona(persona, linkedAgent, effectiveAvatarUrl) + } /> - } + )} agent={profileAgent} defaultModel={defaultModel} key={group.persona.id} @@ -250,7 +255,10 @@ function AgentPersonaCard({ onStartAgent, onStartPersona, }: { - actions?: React.ReactNode; + actions?: ( + effectiveAvatarUrl: string | null, + isEffectiveAvatarLoading: boolean, + ) => React.ReactNode; agent: ManagedAgent | undefined; defaultModel: string; persona: AgentPersona; @@ -282,7 +290,10 @@ function AgentPersonaCard({ return ( (null); const [personaToExportSnapshot, setPersonaToExportSnapshot] = React.useState<{ persona: AgentPersona; linkedAgentPubkey: string | null; + effectiveAvatarUrl: string | null; } | null>(null); const [snapshotImportState, setSnapshotImportState] = React.useState<{ fileBytes: number[]; @@ -447,17 +449,20 @@ export function usePersonaActions() { function openShare( persona: AgentPersona, linkedAgent: ManagedAgent | undefined, + effectiveAvatarUrl: string | null, ) { clearFeedback("library"); setPersonaToShare({ persona, linkedAgentPubkey: linkedAgent?.pubkey ?? null, + effectiveAvatarUrl, }); } function handleExportSnapshot( persona: AgentPersona, linkedAgentPubkey: string | null, + effectiveAvatarUrl: string | null, memoryLevel: SnapshotMemoryLevel, format: SnapshotFormat, ) { @@ -469,7 +474,7 @@ export function usePersonaActions() { memoryLevel, format, memorySourcePubkey: linkedAgentPubkey, - avatarUrl: persona.avatarUrl, + avatarUrl: effectiveAvatarUrl, }, { onSuccess: (saved) => { diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 2e7cdc9e83..13eda788b1 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1733,6 +1733,20 @@ test("one share level selector drives both the link and send paths", async ({ }) => { await page.emulateMedia({ reducedMotion: "no-preference" }); const linkedAgentPubkey = TEST_IDENTITIES.alice.pubkey; + const profileAvatarUrl = "https://mock.relay/media/profile-only-avatar.png"; + const profileAvatarBytes = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + ), + (character) => character.charCodeAt(0), + ); + await page.route(profileAvatarUrl, async (route) => { + await route.fulfill({ + body: Buffer.from(profileAvatarBytes), + contentType: "image/png", + status: 200, + }); + }); await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); await installMockBridge(page, { personas: [ @@ -1751,6 +1765,12 @@ test("one share level selector drives both the link and send paths", async ({ }, ], searchProfiles: [ + { + pubkey: linkedAgentPubkey, + displayName: "Animation Auditor", + avatarUrl: profileAvatarUrl, + isAgent: true, + }, { pubkey: TEST_IDENTITIES.charlie.pubkey, displayName: "Charlie", @@ -1999,6 +2019,9 @@ test("one share level selector drives both the link and send paths", async ({ expect.objectContaining({ memoryLevel: "core", memorySourcePubkey: linkedAgentPubkey, + avatarPngDataUrl: `data:image/png;base64,${Buffer.from( + profileAvatarBytes, + ).toString("base64")}`, }), expect.objectContaining({ memoryLevel: "everything", From 294c8c821de51442a8c384c0bdb66b1a10224ca0 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 29 Jul 2026 11:17:35 -0400 Subject: [PATCH 25/99] perf(desktop): move observer-feed archive and decrypt commands off main thread (#3415) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening the agent observer feed could beachball the app. In Tauri 2, a sync (`pub fn`) command body runs on the **main thread** — only `async fn` commands run on the runtime pool. Five commands on the observer-feed open path were sync, so panel open ran SQLite I/O and secp256k1 work on the macOS main thread: | Command | Main-thread work | |---|---| | `decrypt_observer_event` | Schnorr ID + signature verify, then NIP-44 decrypt — once per frame | | `read_archived_observer_events_for_channel` | Opens the archive DB, runs the channel-index JOIN, returns up to 200 raw JSON blobs per page | | `read_unindexed_observer_rows` | Opens the DB, returns **all** not-yet-indexed kind-24200 rows in one shot | | `index_observer_channel_id` | Opens the DB, loops N upserts | | `delete_save_subscription` | Opens the DB, one delete | Eager hydration loads up to 10 pages × 200 frames on panel open, so that's up to 10 main-thread DB reads plus up to 2,000 sequential verify+decrypt calls before any scrolling. The one-shot backfill makes it worse on the first open after history accumulates: one read of every unindexed row, a decrypt per row, then a batch upsert — all on the main thread, and all proportional to archive size. The four archive commands now route their DB work through the existing `run_archive_db_task` helper (`spawn_blocking` + `open_db`), matching `list_save_subscriptions`, `read_archived_events`, and `archive_events` directly around them. `decrypt_observer_event` becomes `async fn` + `tauri::async_runtime::spawn_blocking`, with `state.signing_keys()` extracted before the spawn since `State` is not `Send` — the same pattern `sign_event` uses from #1222. No frontend changes: `invoke` is already promise-based, so the TS wrappers in `tauriArchive.ts` and `tauriObserver.ts` are unchanged. This removes the freeze, not the work. Eager hydration still takes the same wall time — the feed shows a loading state instead of blocking the UI. Batching the per-frame decrypt IPC (2,000 round-trips into one command) would cut the latency itself; that's deliberately out of scope here. Signed-off-by: Will Pfleger --- desktop/src-tauri/src/archive/mod.rs | 96 ++++++++++++---------- desktop/src-tauri/src/commands/identity.rs | 28 ++++--- 2 files changed, 69 insertions(+), 55 deletions(-) diff --git a/desktop/src-tauri/src/archive/mod.rs b/desktop/src-tauri/src/archive/mod.rs index a65b126a4d..42c6812674 100644 --- a/desktop/src-tauri/src/archive/mod.rs +++ b/desktop/src-tauri/src/archive/mod.rs @@ -483,21 +483,23 @@ pub async fn list_save_subscriptions( /// Does NOT purge already-archived event data — retention is decoupled in v1. /// GC of orphaned event rows happens in P4 purge commands, not here. #[tauri::command] -pub fn delete_save_subscription( +pub async fn delete_save_subscription( state: State<'_, AppState>, scope_type: ScopeType, scope_value: String, ) -> Result { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let conn = open_db()?; - store::delete_save_subscription( - &conn, - &identity_pk, - &relay_url, - scope_type.as_str(), - &scope_value, - ) + run_archive_db_task(move |conn| { + store::delete_save_subscription( + conn, + &identity_pk, + &relay_url, + scope_type.as_str(), + &scope_value, + ) + }) + .await } // ── read_archived_events ───────────────────────────────────────────────────── @@ -516,7 +518,7 @@ pub fn delete_save_subscription( /// newest-first order. Compound cursor `(before_created_at, before_id)` works /// identically to `read_archived_events`. #[tauri::command] -pub fn read_archived_observer_events_for_channel( +pub async fn read_archived_observer_events_for_channel( state: State<'_, AppState>, channel_id: String, before_created_at: Option, @@ -525,16 +527,18 @@ pub fn read_archived_observer_events_for_channel( ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let conn = open_db()?; - store::read_archived_observer_events_for_channel( - &conn, - &identity_pk, - &relay_url, - &channel_id, - before_created_at, - before_id.as_deref(), - limit.unwrap_or(DEFAULT_READ_LIMIT), - ) + run_archive_db_task(move |conn| { + store::read_archived_observer_events_for_channel( + conn, + &identity_pk, + &relay_url, + &channel_id, + before_created_at, + before_id.as_deref(), + limit.unwrap_or(DEFAULT_READ_LIMIT), + ) + }) + .await } // ── index_observer_channel_id ───────────────────────────────────────────────── @@ -548,24 +552,26 @@ pub fn read_archived_observer_events_for_channel( /// /// Idempotent: rows that are already indexed are left unchanged. #[tauri::command] -pub fn index_observer_channel_id( +pub async fn index_observer_channel_id( state: State<'_, AppState>, entries: Vec, ) -> Result<(), String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let conn = open_db()?; - for entry in &entries { - store::upsert_observer_channel_index( - &conn, - &identity_pk, - &relay_url, - &entry.event_id, - entry.channel_id.as_deref(), - entry.created_at, - )?; - } - Ok(()) + run_archive_db_task(move |conn| { + for entry in &entries { + store::upsert_observer_channel_index( + conn, + &identity_pk, + &relay_url, + &entry.event_id, + entry.channel_id.as_deref(), + entry.created_at, + )?; + } + Ok(()) + }) + .await } /// A single (event_id, channel_id?, created_at) record used by @@ -591,21 +597,23 @@ pub struct ObserverChannelIndexEntry { /// Together these constitute the one-shot idempotent backfill required by the /// Slice 1 acceptance criteria (Thufir Pass 4). #[tauri::command] -pub fn read_unindexed_observer_rows( +pub async fn read_unindexed_observer_rows( state: State<'_, AppState>, ) -> Result, String> { let identity_pk = identity_pubkey(&state)?; let relay_url = relay_ws_url_with_override(&state); - let conn = open_db()?; - let rows = store::read_unindexed_observer_rows(&conn, &identity_pk, &relay_url)?; - Ok(rows - .into_iter() - .map(|(id, raw_json, created_at)| RawObserverRow { - id, - raw_json, - created_at, - }) - .collect()) + run_archive_db_task(move |conn| { + let rows = store::read_unindexed_observer_rows(conn, &identity_pk, &relay_url)?; + Ok(rows + .into_iter() + .map(|(id, raw_json, created_at)| RawObserverRow { + id, + raw_json, + created_at, + }) + .collect()) + }) + .await } /// Wire type returned by `read_unindexed_observer_rows`. diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 33783c05a5..2840c0ade6 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -135,23 +135,29 @@ pub async fn sign_event( } #[tauri::command] -pub fn decrypt_observer_event( +pub async fn decrypt_observer_event( event_json: String, state: State<'_, AppState>, ) -> Result { let keys = state.signing_keys()?; - let event = Event::from_json(event_json).map_err(|error| format!("invalid event: {error}"))?; - // Defense-in-depth: verify event ID and signature before decrypting. - if !event.verify_id() { - return Err("observer event has invalid ID".into()); - } - if !event.verify_signature() { - return Err("observer event has invalid signature".into()); - } + tauri::async_runtime::spawn_blocking(move || { + let event = + Event::from_json(event_json).map_err(|error| format!("invalid event: {error}"))?; - buzz_core_pkg::observer::decrypt_observer_payload(&keys, &event) - .map_err(|error| format!("decrypt observer event failed: {error}")) + // Defense-in-depth: verify event ID and signature before decrypting. + if !event.verify_id() { + return Err("observer event has invalid ID".into()); + } + if !event.verify_signature() { + return Err("observer event has invalid signature".into()); + } + + buzz_core_pkg::observer::decrypt_observer_payload(&keys, &event) + .map_err(|error| format!("decrypt observer event failed: {error}")) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? } #[tauri::command] From 51bb97d2be658854bb8a39983af568e90591d375 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 09:20:49 -0600 Subject: [PATCH 26/99] Run Tauri clippy in pre-push (#3555) ## Summary - run Desktop Tauri clippy from pre-push for every path that can affect the Tauri crate - reuse `just desktop-tauri-clippy`, keeping the local command identical to Desktop Core CI - leave the existing Tauri test hook unchanged ## Why PR #3553 exposed a hook gap: `cargo test` allowed an unused-import warning that CI's `clippy -D warnings` correctly rejected. Running the same recipe before push catches that class of failure locally without duplicating CI flags in Lefthook. ## Validation - `lefthook run pre-push --command desktop-tauri-clippy --force` - confirmed it invokes `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` - command passed Signed-off-by: Wes Co-authored-by: Carl --- lefthook.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lefthook.yml b/lefthook.yml index 87eaa87c21..0680dd29a4 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -61,6 +61,11 @@ pre-push: glob: ["desktop/**", "pnpm-lock.yaml"] exclude: ["desktop/src-tauri/**"] run: just desktop-test + desktop-tauri-clippy: + # Keep local lint parity with Desktop Core CI for every path that can + # affect the Tauri crate or its path dependencies. + glob: ["desktop/src-tauri/**", "crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "justfile"] + run: just desktop-tauri-clippy desktop-tauri-test: # ci.yml:113 — Desktop Core triggers on `rust` OR `desktop-rust`; # desktop/src-tauri path-depends on crates/buzz-core, buzz-persona, From a13085e9ac9a7c8dbd9426a6b88fc75abf62220e Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 09:44:30 -0600 Subject: [PATCH 27/99] chore(release): release Buzz Desktop version 0.5.1 (#3566) ## Buzz Desktop release v0.5.1 ### Changes since v0.5.0: - perf(desktop): move observer-feed archive and decrypt commands off main thread ([#3415](https://github.com/block/buzz/pull/3415)) ([`294c8c821`](https://github.com/block/buzz/commit/294c8c821de51442a8c384c0bdb66b1a10224ca0)) - fix(desktop): preserve shared agent fidelity ([#3553](https://github.com/block/buzz/pull/3553)) ([`f7a3988ba`](https://github.com/block/buzz/commit/f7a3988ba13b590d9a55a7e8413fc3fb5ffbef18)) - feat(agent): route Claude/GPT model families to their native gateway wire ([#3538](https://github.com/block/buzz/pull/3538)) ([`6438dedf8`](https://github.com/block/buzz/commit/6438dedf83a9dbe1853e484326911bf6c7f1618c)) - Refine community invite limits ([#3529](https://github.com/block/buzz/pull/3529)) ([`24d90d128`](https://github.com/block/buzz/commit/24d90d1280a9325c6cbcf8eea30ac54db5afd2cb)) - feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) ([#3463](https://github.com/block/buzz/pull/3463)) ([`c405ad1d4`](https://github.com/block/buzz/commit/c405ad1d4b1da061c11b3d26761252d41dcc62d3)) - feat: add explicit entry for claude-opus-5 in model config ([#2831](https://github.com/block/buzz/pull/2831)) ([`90e058ebf`](https://github.com/block/buzz/commit/90e058ebf68137e048a409aec6616519379ff726)) - fix(desktop): clear stale thread new-message pill ([#3411](https://github.com/block/buzz/pull/3411)) ([`55a3ed7b9`](https://github.com/block/buzz/commit/55a3ed7b9217cee5b23e0a5441947dc929b2a38c)) - fix(ci): ratchet file sizes against the base tree ([#3352](https://github.com/block/buzz/pull/3352)) ([`9227bdf58`](https://github.com/block/buzz/commit/9227bdf58ad6664ae3c1078888f2181ec19c4da4)) - feat(desktop): apply WebKit rendering workarounds at startup on Linux ([#3271](https://github.com/block/buzz/pull/3271)) ([`3ece4461d`](https://github.com/block/buzz/commit/3ece4461df8a7b9663a8e68327483b8377d4086d)) - fix(desktop): stabilize flaky DM expansion E2E ordering assertions ([#2004](https://github.com/block/buzz/pull/2004)) ([`913d564ce`](https://github.com/block/buzz/commit/913d564ce0f35924291bf3eeab6508517a6d8d1f)) - fix(desktop): paint community rail full height ([#3382](https://github.com/block/buzz/pull/3382)) ([`1d3b810ad`](https://github.com/block/buzz/commit/1d3b810ad70d6325718ed91e723f32c4a376d5e1)) - feat(desktop): add custom harness inline from agent dialogs ([#3252](https://github.com/block/buzz/pull/3252)) ([`b0503d80c`](https://github.com/block/buzz/commit/b0503d80c298b1ece3b0a43b41d316829a3379e7)) - feat(desktop): refine agent catalog sharing ([#2439](https://github.com/block/buzz/pull/2439)) ([`a35771fc4`](https://github.com/block/buzz/commit/a35771fc441cdc3c6f517f419037206783b502d2)) - fix(desktop): keep drafts out of the Inbox All view ([#3217](https://github.com/block/buzz/pull/3217)) ([`3afa129ee`](https://github.com/block/buzz/commit/3afa129ee785cc74d921d0ba969254a8255c4cc0)) - fix(desktop): restore the inbox icon in the sidebar ([#3341](https://github.com/block/buzz/pull/3341)) ([`00ede2e7a`](https://github.com/block/buzz/commit/00ede2e7aa7eb95571b7db3ebbd163adbf6cf74e)) - fix(desktop): gate codex-acp on a minimum supported version ([#3254](https://github.com/block/buzz/pull/3254)) ([`4e3998f36`](https://github.com/block/buzz/commit/4e3998f36e36d68b9a93dcbd85f0864450bb8f5f)) - feat(cli): add users set-status command for NIP-38 profile status ([#3253](https://github.com/block/buzz/pull/3253)) ([`60158fce3`](https://github.com/block/buzz/commit/60158fce3e670f11bb35d42627857ccaea50ff06)) - fix(composer): scope multiline block formatting ([#3246](https://github.com/block/buzz/pull/3246)) ([`5457c947a`](https://github.com/block/buzz/commit/5457c947a74f5ba4b979f9c6411aa7626a858387)) **To release:** merge this PR. The tag and build will happen automatically. Signed-off-by: Wes --- CHANGELOG.md | 22 ++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfd3b16d0a..956faa1ed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## v0.5.1 + +- perf(desktop): move observer-feed archive and decrypt commands off main thread ([#3415](https://github.com/block/buzz/pull/3415)) ([`294c8c821`](https://github.com/block/buzz/commit/294c8c821de51442a8c384c0bdb66b1a10224ca0)) +- fix(desktop): preserve shared agent fidelity ([#3553](https://github.com/block/buzz/pull/3553)) ([`f7a3988ba`](https://github.com/block/buzz/commit/f7a3988ba13b590d9a55a7e8413fc3fb5ffbef18)) +- feat(agent): route Claude/GPT model families to their native gateway wire ([#3538](https://github.com/block/buzz/pull/3538)) ([`6438dedf8`](https://github.com/block/buzz/commit/6438dedf83a9dbe1853e484326911bf6c7f1618c)) +- Refine community invite limits ([#3529](https://github.com/block/buzz/pull/3529)) ([`24d90d128`](https://github.com/block/buzz/commit/24d90d1280a9325c6cbcf8eea30ac54db5afd2cb)) +- feat(agent): fix Anthropic prompt caching with Databricks (+ MCP proxy/TLS passthrough) ([#3463](https://github.com/block/buzz/pull/3463)) ([`c405ad1d4`](https://github.com/block/buzz/commit/c405ad1d4b1da061c11b3d26761252d41dcc62d3)) +- feat: add explicit entry for claude-opus-5 in model config ([#2831](https://github.com/block/buzz/pull/2831)) ([`90e058ebf`](https://github.com/block/buzz/commit/90e058ebf68137e048a409aec6616519379ff726)) +- fix(desktop): clear stale thread new-message pill ([#3411](https://github.com/block/buzz/pull/3411)) ([`55a3ed7b9`](https://github.com/block/buzz/commit/55a3ed7b9217cee5b23e0a5441947dc929b2a38c)) +- fix(ci): ratchet file sizes against the base tree ([#3352](https://github.com/block/buzz/pull/3352)) ([`9227bdf58`](https://github.com/block/buzz/commit/9227bdf58ad6664ae3c1078888f2181ec19c4da4)) +- feat(desktop): apply WebKit rendering workarounds at startup on Linux ([#3271](https://github.com/block/buzz/pull/3271)) ([`3ece4461d`](https://github.com/block/buzz/commit/3ece4461df8a7b9663a8e68327483b8377d4086d)) +- fix(desktop): stabilize flaky DM expansion E2E ordering assertions ([#2004](https://github.com/block/buzz/pull/2004)) ([`913d564ce`](https://github.com/block/buzz/commit/913d564ce0f35924291bf3eeab6508517a6d8d1f)) +- fix(desktop): paint community rail full height ([#3382](https://github.com/block/buzz/pull/3382)) ([`1d3b810ad`](https://github.com/block/buzz/commit/1d3b810ad70d6325718ed91e723f32c4a376d5e1)) +- feat(desktop): add custom harness inline from agent dialogs ([#3252](https://github.com/block/buzz/pull/3252)) ([`b0503d80c`](https://github.com/block/buzz/commit/b0503d80c298b1ece3b0a43b41d316829a3379e7)) +- feat(desktop): refine agent catalog sharing ([#2439](https://github.com/block/buzz/pull/2439)) ([`a35771fc4`](https://github.com/block/buzz/commit/a35771fc441cdc3c6f517f419037206783b502d2)) +- fix(desktop): keep drafts out of the Inbox All view ([#3217](https://github.com/block/buzz/pull/3217)) ([`3afa129ee`](https://github.com/block/buzz/commit/3afa129ee785cc74d921d0ba969254a8255c4cc0)) +- fix(desktop): restore the inbox icon in the sidebar ([#3341](https://github.com/block/buzz/pull/3341)) ([`00ede2e7a`](https://github.com/block/buzz/commit/00ede2e7aa7eb95571b7db3ebbd163adbf6cf74e)) +- fix(desktop): gate codex-acp on a minimum supported version ([#3254](https://github.com/block/buzz/pull/3254)) ([`4e3998f36`](https://github.com/block/buzz/commit/4e3998f36e36d68b9a93dcbd85f0864450bb8f5f)) +- feat(cli): add users set-status command for NIP-38 profile status ([#3253](https://github.com/block/buzz/pull/3253)) ([`60158fce3`](https://github.com/block/buzz/commit/60158fce3e670f11bb35d42627857ccaea50ff06)) +- fix(composer): scope multiline block formatting ([#3246](https://github.com/block/buzz/pull/3246)) ([`5457c947a`](https://github.com/block/buzz/commit/5457c947a74f5ba4b979f9c6411aa7626a858387)) + + ## v0.5.0 - feat(invites): add use-limited invite links ([#3141](https://github.com/block/buzz/pull/3141)) ([`d500c2d5c`](https://github.com/block/buzz/commit/d500c2d5cf5d9aabe0ca4ebebfcafdbe5f5b7fd3)) diff --git a/desktop/package.json b/desktop/package.json index adac095a47..7943b949b9 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.0", + "version": "0.5.1", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 66553ef595..d4f7a4a2d4 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1010,7 +1010,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.0" +version = "0.5.1" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 324218a49d..8bb643fea3 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.5.0" +version = "0.5.1" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 7a480c4c18..85ad5c0b2d 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.0", + "version": "0.5.1", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 9752b816a95e88751b9c7e4d3343fe3210de5d43 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 09:52:50 -0600 Subject: [PATCH 28/99] Serialize Tauri pre-push checks (#3567) ## Summary - combine Desktop Tauri clippy and tests into one pre-push command - run clippy first, then tests - keep unrelated pre-push commands parallel ## Why PR #3555 added clippy as a separate command while the pre-push group uses `parallel: true`. That can start clippy and tests simultaneously against the same Cargo target directory, leaving one command waiting on Cargo's build lock and making pushes appear stalled. Serializing only these two Cargo-heavy checks avoids lock contention while retaining the CI-equivalent clippy command and existing test coverage. ## Validation - `lefthook validate` - forced `desktop-tauri-checks` through Lefthook with an instrumented `just`; observed `desktop-tauri-clippy` followed by `desktop-tauri-test` Signed-off-by: Wes Co-authored-by: Carl --- lefthook.yml | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/lefthook.yml b/lefthook.yml index 0680dd29a4..75d205722f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -61,17 +61,12 @@ pre-push: glob: ["desktop/**", "pnpm-lock.yaml"] exclude: ["desktop/src-tauri/**"] run: just desktop-test - desktop-tauri-clippy: + desktop-tauri-checks: # Keep local lint parity with Desktop Core CI for every path that can - # affect the Tauri crate or its path dependencies. + # affect the Tauri crate or its path dependencies. Run clippy and tests + # serially so parallel pre-push hooks do not contend for Cargo's lock. glob: ["desktop/src-tauri/**", "crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "justfile"] - run: just desktop-tauri-clippy - desktop-tauri-test: - # ci.yml:113 — Desktop Core triggers on `rust` OR `desktop-rust`; - # desktop/src-tauri path-depends on crates/buzz-core, buzz-persona, - # buzz-sdk, buzz-agent (desktop/src-tauri/Cargo.toml:88-91). - glob: ["desktop/src-tauri/**", "crates/**", "migrations/**", "schema/**", "Cargo.toml", "Cargo.lock", "rust-toolchain.toml", "deny.toml", "scripts/run-tests.sh", "justfile"] - run: just desktop-tauri-test + run: just desktop-tauri-clippy && just desktop-tauri-test mobile-test: glob: ["mobile/**"] run: just mobile-test From 9beb3b8c6ebc3e2fb0c17f4ca4d522731b81434c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 29 Jul 2026 12:23:02 -0400 Subject: [PATCH 29/99] fix(cli): mask credential env values in --help output (#3570) clap renders live env var values in help text by default. Three args carrying credentials were exposed this way: - `BUZZ_PRIVATE_KEY` in `buzz-cli` (`crates/buzz-cli/src/lib.rs`) - `BUZZ_AUTH_TAG` in `buzz-cli` - `BUZZ_PRIVATE_KEY` in `buzz-acp` (`crates/buzz-acp/src/config.rs`) Add `hide_env_values = true` to each. Env var names remain visible for discoverability; only their runtime values are withheld from `--help` output. Also adds a regression guard in each crate's test module that walks the clap command tree (recursing into subcommands for `buzz-cli`) and asserts every arg whose env var name contains `KEY`, `SECRET`, `TOKEN`, `PASSWORD`, `CRED`, or `AUTH` has `hide_env_values` set. This prevents future credential-bearing args from being added without the masking in place. Signed-off-by: Will Pfleger Co-authored-by: Duncan --- crates/buzz-acp/src/config.rs | 32 +++++++++++++++++++++++- crates/buzz-cli/src/lib.rs | 46 +++++++++++++++++++++++++++++++++-- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 19304bf186..dab61be30a 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -240,7 +240,7 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_RELAY_URL", default_value = "ws://localhost:3000")] pub relay_url: String, - #[arg(long, env = "BUZZ_PRIVATE_KEY")] + #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] pub private_key: String, /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. @@ -2898,4 +2898,34 @@ channels = "ALL" let agent = "a".repeat(SESSION_TITLE_MAX_CHARS); assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); } + + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH + /// must set `hide_env_values = true` to prevent credential leakage in --help. + #[test] + fn secret_env_args_hide_their_values_in_help() { + use clap::CommandFactory; + + const SECRET_PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CRED", "AUTH"]; + + let cmd = CliArgs::command(); + let violations: Vec = cmd + .get_arguments() + .filter_map(|arg| { + let env_key = arg.get_env()?; + let env_name = env_key.to_string_lossy().to_uppercase(); + let is_secret = SECRET_PATTERNS.iter().any(|pat| env_name.contains(pat)); + if is_secret && !arg.is_hide_env_values_set() { + Some(env_name) + } else { + None + } + }) + .collect(); + + assert!( + violations.is_empty(), + "Found secret-bearing env args without hide_env_values=true. \ + Add `hide_env_values = true` to each: {violations:?}" + ); + } } diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0b46734584..7465625804 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -82,11 +82,11 @@ struct Cli { relay: String, /// Nostr private key (hex or nsec). This is the CLI's identity. - #[arg(long, env = "BUZZ_PRIVATE_KEY")] + #[arg(long, env = "BUZZ_PRIVATE_KEY", hide_env_values = true)] private_key: Option, /// NIP-OA auth tag JSON (owner attestation). Injected into every signed event. - #[arg(long, env = "BUZZ_AUTH_TAG")] + #[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)] auth_tag: Option, /// Output format: 'json' (default, full fields) or 'compact' (reduced fields). @@ -2075,4 +2075,46 @@ mod tests { ); } } + + /// Collect all args (recursing into subcommands) whose env var name looks + /// like a credential but does NOT have `hide_env_values` set. + fn collect_unhidden_secret_args(cmd: &clap::Command) -> Vec<(String, String)> { + const SECRET_PATTERNS: &[&str] = &["KEY", "SECRET", "TOKEN", "PASSWORD", "CRED", "AUTH"]; + + let mut violations: Vec<(String, String)> = Vec::new(); + + for arg in cmd.get_arguments() { + if let Some(env_key) = arg.get_env() { + let env_name = env_key.to_string_lossy().to_uppercase(); + let is_secret = SECRET_PATTERNS.iter().any(|pat| env_name.contains(pat)); + if is_secret && !arg.is_hide_env_values_set() { + violations.push((cmd.get_name().to_string(), env_name)); + } + } + } + + for sub in cmd.get_subcommands() { + violations.extend(collect_unhidden_secret_args(sub)); + } + + violations + } + + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH + /// must set `hide_env_values = true` to prevent credential leakage in --help. + #[test] + fn secret_env_args_hide_their_values_in_help() { + let cmd = Cli::command(); + let violations = collect_unhidden_secret_args(&cmd); + assert!( + violations.is_empty(), + "Found secret-bearing env args without hide_env_values=true. \ + Add `hide_env_values = true` to each:\n{}", + violations + .iter() + .map(|(cmd, env)| format!(" command={cmd:?} env={env:?}")) + .collect::>() + .join("\n") + ); + } } From 4a1ebf25c782fc6a68f0a69e6f866f793a259a1f Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Wed, 29 Jul 2026 11:49:51 -0500 Subject: [PATCH 30/99] feat(agent): make Gemini and MLflow-route models usable through databricks_v2 (#3569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Makes Gemini — and every other non-Claude, non-GPT-5 model on the Databricks MLflow route (`databricks_v2`) — usable in an agent loop. These are the non-`benchmarks/` changes from `benchmark/harness-accounting-and-solo`, lifted onto a clean base off `main` so they can land independently while the harness work continues. Two defects made these models unusable, one fatal and one silent. Both live only in `openai_body` / `parse_openai`, which is the least-exercised of the three `databricks_v2` sub-routes — the `luna`/`sol` conditions run the Responses route and the `opus` conditions run the Anthropic route, so **this change is inert for every model already in use** and only lights up the MLflow path. ## Why a third route at all `databricks_v2_route_for_model` buckets by model family: `claude*` → Anthropic Messages, `gpt-5`/code-names → OpenAI Responses, **everything else → MLflow chat-completions**. Gemini, Qwen, gpt-oss, and friends all fall through to that third pair — and both bugs below live only there. ## D1 — dropped thought signatures (fatal) Gemini returns a `thoughtSignature` on every tool call and **requires it echoed back**. `openai_body` reserialized each call as `{id, type, function}` only, dropping the field, so the next request 400'd: ``` HTTP 400 Function call is missing a thought_signature in functionCall parts. ``` For a coding agent this fires on the **first** tool call, so the model never completes a single turn. **Position is load-bearing.** A four-shape replay probe against the live gateway established that the signature must sit as a *sibling* of `function` — nesting it inside `function{}` fails with the *same* 400 as omitting it. A fix that "preserves the field" without preserving its position passes a unit test and still 400s. The fix: `ToolCall` gains `provider_extra: Map`. `parse_openai` captures every top-level wire key except the three we model (`id`, `type`, `function`); `openai_body` re-emits them beside `function`. Keeping *whatever we did not model*, rather than naming `thoughtSignature`, means the next provider with an opaque per-call token needs no change here. The Responses and Anthropic replay shapes are fully modelled, so they pass `Default::default()` and stay **byte-identical** to before. ### D1b — duplicate tool-call ids (same root cause) Gemini returns the **function name** as the id, so two parallel calls to one function arrive sharing an id — and that id is what pairs a `role:"tool"` result back to its call, making two results indistinguishable. `dedupe_provider_ids` suffixes collisions (`get_weather`, `get_weather-2`). Safe because both halves of the pairing (the assistant `tool_calls[].id` and the result's `tool_call_id`) are re-emitted from this same value; the provider never sees its original id again. ## D2 — block-array content discarded (silent, worse than a crash) `parse_openai` read `content` with `as_str()`, which returns `""` for anything that isn't a JSON string. Gemini (and Qwen35, gpt-oss) send an array of typed blocks: ```json "content": [ {"type": "reasoning", "summary": [{"type": "summary_text", "text": "…"}]}, {"type": "text", "text": "391"} ] ``` So the model answered and the answer was thrown away — no error, no warning, just a turn that looked like the model had said nothing. On a benchmark this reads as "Gemini is bad at the task" rather than "buzz dropped the reply." `openai_content_parts` now accepts either shape — string as before, or a block array where `text` blocks concatenate into text and `reasoning` blocks into reasoning (Gemini nests the prose one level down under `summary`). Message-level `reasoning_content` / `reasoning` still win when present, so DeepSeek and vLLM-style hosts are unchanged; block reasoning is the last fallback. ## Also: a turn-start log line (`buzz-acp` `pool.rs`) Small, independent observability change that also rides in the non-benchmark delta: `run_prompt_task` now emits a `pool::prompt` "turn starting" line, labelled by the same `prompt_label` helper as `log_stop_reason`, so a log reads as start/stop pairs. An unpaired start is the only durable evidence that a turn was entered and never returned — without it, a stalled agent and an agent nobody woke leave identical (zero-completion) logs. ## Interaction with #3538 #3538 (already merged) rewrote `databricks_v2_route_for_model` to route by boundary-aware model-family segments. That change and this one touch **different functions** in `llm.rs` — routing vs. body/parse — and compose cleanly; the family routing decides *which* pair runs, and this fixes the MLflow pair it can now select. ## Testing - `cargo fmt --all -- --check`, `cargo clippy -p buzz-agent -p buzz-acp --all-targets -- -D warnings` — clean. - `cargo test -p buzz-agent -p buzz-acp` — all green (304 + 632 lib tests plus integration suites, 0 failures). Five new tests cover: block-array text extraction, plain-string regression, passthrough capture (and non-duplication of the modelled keys), replay position (`thoughtSignature` beside `function`, not inside it), and id de-duplication. - Wire evidence: the four-shape replay table and the reasoning-effort probe were run against `block-lakehouse-staging` (recorded in the design doc). ## Relationship to the benchmark branch The full design write-up (four-shape replay table, position-matters analysis, effort verification, and open pricing item) lives in `docs/08-gemini-provider-fixes.md` on `benchmark/harness-accounting-and-solo`. The benchmark manifests and endpoint-config entries that exercise these models are separable and stay on that branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Atish Patel Co-authored-by: Claude Opus 4.8 (1M context) --- crates/buzz-acp/src/pool.rs | 27 +++- crates/buzz-agent/src/agent.rs | 3 + crates/buzz-agent/src/llm.rs | 227 +++++++++++++++++++++++++++++++-- crates/buzz-agent/src/types.rs | 53 +++++++- 4 files changed, 294 insertions(+), 16 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index b1fd68d044..038f8a714c 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1897,6 +1897,18 @@ pub async fn run_prompt_task( None => prompt_sections.iter().map(String::as_str).collect(), }; + // Turn start, labelled exactly as `log_stop_reason` labels the end, so a + // log reads as start/stop pairs. Purely observational: an unpaired start is + // the only durable evidence that a turn was entered and never returned, and + // without it a stalled agent and an agent nobody woke leave identical logs — + // zero completions either way, so anything reading them afterwards has to + // guess which happened. + tracing::info!( + target: "pool::prompt", + "turn starting for {}", + prompt_label(&source) + ); + // When control_rx is Some (channel tasks), wrap the prompt in select! so // the main loop can cancel, interrupt, or rotate it. Heartbeats // (control_rx=None) take the simple await path — they are not controllable. @@ -3131,12 +3143,19 @@ fn classify_control_cancel_failure( } } -/// Log a stop reason at the appropriate tracing level. -fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { - let label = match source { +/// How a turn's source is named in the `pool::prompt` log lines. +/// +/// Shared by the turn-start and turn-stop lines so a log can be read as pairs. +fn prompt_label(source: &PromptSource) -> String { + match source { PromptSource::Channel(cid) => format!("channel {cid}"), PromptSource::Heartbeat => "heartbeat".to_string(), - }; + } +} + +/// Log a stop reason at the appropriate tracing level. +fn log_stop_reason(source: &PromptSource, stop_reason: &StopReason) { + let label = prompt_label(source); match stop_reason { StopReason::EndTurn => { tracing::info!(target: "pool::prompt", "turn complete for {label}: end_turn"); diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index cbf27357f7..ed04daca2c 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -696,6 +696,9 @@ pub(crate) fn push_hook_outputs_as_tool_results( provider_id: provider_id.clone(), name: tool_name, arguments: serde_json::json!({}), + // Synthesised locally, so there is no provider wire form to + // preserve. + provider_extra: Default::default(), }], }); history.push(HistoryItem::ToolResult(ToolResult { diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f69a963533..22d3f8b73e 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -3,7 +3,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use reqwest::Client; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use tokio::sync::Mutex; use tokio::time::Instant; @@ -848,11 +848,24 @@ fn openai_body( let calls: Vec = tool_calls .iter() .map(|c| { - json!({ - "id": c.provider_id, "type": "function", - "function": { "name": c.name, - "arguments": serde_json::to_string(&c.arguments) - .unwrap_or_else(|_| "{}".into()) } }) + let mut call = serde_json::Map::new(); + call.insert("id".into(), json!(c.provider_id)); + call.insert("type".into(), json!("function")); + // Provider-owned fields go back beside `function`, + // which is where the provider put them. Position is + // load-bearing, not cosmetic: Gemini rejects a + // `thoughtSignature` nested inside `function{}` with + // the same 400 it gives for one that is missing. + for (k, v) in &c.provider_extra { + call.insert(k.clone(), v.clone()); + } + call.insert( + "function".into(), + json!({ "name": c.name, + "arguments": serde_json::to_string(&c.arguments) + .unwrap_or_else(|_| "{}".into()) }), + ); + Value::Object(call) }) .collect(); msg.insert("tool_calls".into(), Value::Array(calls)); @@ -1120,10 +1133,15 @@ fn parse_responses(v: Value) -> Result { let args: Value = serde_json::from_str(raw).map_err(|e| { AgentError::Llm(format!("function_call.arguments not valid JSON: {e}")) })?; + // No passthrough on this route: `responses_body` replays a + // function call as `{call_id, name, arguments}` and the Responses + // API asks for nothing else, so an empty map keeps the request + // byte-identical to before. tool_calls.push(make_tool_call( str_field(item, "call_id"), str_field(item, "name"), args, + Default::default(), )?); } Some("reasoning") => { @@ -1301,6 +1319,76 @@ fn str_field(v: &Value, key: &str) -> String { v.get(key).and_then(Value::as_str).unwrap_or("").to_owned() } +/// Append `part` to `buf` on its own line, ignoring empties. +fn push_part(buf: &mut String, part: &str) { + if part.is_empty() { + return; + } + if !buf.is_empty() { + buf.push('\n'); + } + buf.push_str(part); +} + +/// Split an OpenAI-shaped `message.content` into `(text, reasoning)`. +/// +/// Standard OpenAI sends a string. Several models on the Databricks MLflow route +/// — Gemini, Qwen35, gpt-oss — send an array of typed blocks instead, and +/// `as_str()` yields nothing for an array, so their entire answer was being +/// discarded: no error, no warning, just a turn that looked like the model had +/// said nothing. `parse_anthropic` already walks a block array; this gives +/// `parse_openai` the same tolerance. +fn openai_content_parts(content: Option<&Value>) -> (String, String) { + let mut text = String::new(); + let mut reasoning = String::new(); + match content { + Some(Value::String(s)) => text.push_str(s), + Some(Value::Array(blocks)) => { + for b in blocks { + match b.get("type").and_then(Value::as_str) { + Some("text") => push_part(&mut text, &str_field(b, "text")), + Some("reasoning") => match b.get("summary").and_then(Value::as_array) { + // Gemini nests the prose one level down under `summary`. + Some(summary) => { + for s in summary { + push_part(&mut reasoning, &str_field(s, "text")); + } + } + None => push_part(&mut reasoning, &str_field(b, "text")), + }, + // An untyped block carrying text is still the model talking; + // treating it as text loses nothing and keeps one more + // provider out of the silent-empty-answer failure mode. + _ => push_part(&mut text, &str_field(b, "text")), + } + } + } + _ => {} + } + (text, reasoning) +} + +/// Make `provider_id` unique across one assistant turn's tool calls. +/// +/// Gemini returns the function name as the id, so two parallel calls to the same +/// function arrive sharing one id — and that id is what pairs a `role:"tool"` +/// result back to its call, leaving two results indistinguishable. Rewriting is +/// safe because both halves of that pairing are re-emitted from this same value; +/// the provider never sees its original id again. +fn dedupe_provider_ids(calls: &mut [ToolCall]) { + let mut seen: BTreeSet = BTreeSet::new(); + for c in calls.iter_mut() { + if seen.contains(&c.provider_id) { + let mut n = 2; + while seen.contains(&format!("{}-{n}", c.provider_id)) { + n += 1; + } + c.provider_id = format!("{}-{n}", c.provider_id); + } + seen.insert(c.provider_id.clone()); + } +} + fn parse_anthropic(v: Value) -> Result { let stop = map_stop(v.get("stop_reason").and_then(Value::as_str)); let mut tool_calls = Vec::new(); @@ -1323,10 +1411,12 @@ fn parse_anthropic(v: Value) -> Result { reasoning.push_str(t); } } + // Anthropic's replay shape is fully modelled, so nothing to keep. Some("tool_use") => tool_calls.push(make_tool_call( str_field(b, "id"), str_field(b, "name"), b.get("input").cloned().unwrap_or(Value::Null), + Default::default(), )?), _ => {} } @@ -1358,17 +1448,23 @@ fn parse_openai(v: Value) -> Result { let msg = choice .get("message") .ok_or_else(|| AgentError::Llm("missing message".into()))?; - let text = str_field(msg, "content"); + let (text, block_reasoning) = openai_content_parts(msg.get("content")); // DeepSeek and vLLM-style OpenAI-compat hosts expose reasoning tokens on the // message object. Prefer `reasoning_content` (DeepSeek's field name); fall - // back to `reasoning` (some other providers). Both are absent for standard - // OpenAI responses, which leaves this empty without any special-casing. + // back to `reasoning` (some other providers), and last to reasoning blocks + // found inside `content`. All three are absent for standard OpenAI + // responses, which leaves this empty without any special-casing. let reasoning = { let rc = str_field(msg, "reasoning_content"); - if rc.is_empty() { + let rc = if rc.is_empty() { str_field(msg, "reasoning") } else { rc + }; + if rc.is_empty() { + block_reasoning + } else { + rc } }; let mut tool_calls = Vec::new(); @@ -1380,13 +1476,25 @@ fn parse_openai(v: Value) -> Result { let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}"); let args: Value = serde_json::from_str(raw) .map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?; + // Everything on the wire object we do not model, kept for replay. + let extra = tc + .as_object() + .map(|o| { + o.iter() + .filter(|(k, _)| !matches!(k.as_str(), "id" | "type" | "function")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); tool_calls.push(make_tool_call( str_field(tc, "id"), str_field(f, "name"), args, + extra, )?); } } + dedupe_provider_ids(&mut tool_calls); let input_tokens = openai_chat_input_tokens(&v); let output_tokens = sum_usage(&v, &["completion_tokens"]); let cached_input_tokens = openai_chat_cached_tokens(&v); @@ -1401,7 +1509,12 @@ fn parse_openai(v: Value) -> Result { }) } -fn make_tool_call(id: String, name: String, args: Value) -> Result { +fn make_tool_call( + id: String, + name: String, + args: Value, + provider_extra: Map, +) -> Result { if id.is_empty() || name.is_empty() { return Err(AgentError::Llm("tool_call missing id or name".into())); } @@ -1418,6 +1531,7 @@ fn make_tool_call(id: String, name: String, args: Value) -> Result Value { + json!({"choices": [{"finish_reason": "tool_calls", "message": { + "role": "assistant", + "content": [ + {"type": "reasoning", "summary": [{"type": "summary_text", "text": "weighing it"}]}, + {"type": "text", "text": "391"} + ], + "tool_calls": [{ + "id": "get_weather", "type": "function", "thoughtSignature": "SIG-A", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"} + }] + }}]}) + } + + #[test] + fn parse_openai_reads_text_out_of_a_block_array() { + // Before this, `as_str()` on the array yielded "" and the model's answer + // was discarded with no error at all. + let r = parse_openai(gemini_choice()).unwrap(); + assert_eq!(r.text, "391"); + assert_eq!(r.reasoning, "weighing it"); + } + + #[test] + fn parse_openai_still_reads_a_plain_string_content() { + let v = json!({"choices": [{"finish_reason": "stop", "message": { + "role": "assistant", "content": "plain"}}]}); + let r = parse_openai(v).unwrap(); + assert_eq!(r.text, "plain"); + assert_eq!(r.reasoning, ""); + } + + #[test] + fn parse_openai_keeps_unmodelled_tool_call_fields() { + let r = parse_openai(gemini_choice()).unwrap(); + let extra = &r.tool_calls[0].provider_extra; + assert_eq!(extra.get("thoughtSignature"), Some(&json!("SIG-A"))); + // `id`/`type`/`function` are modelled, so they must not be duplicated + // into the passthrough — they would be re-emitted twice. + assert!(!extra.contains_key("id")); + assert!(!extra.contains_key("type")); + assert!(!extra.contains_key("function")); + } + + #[test] + fn openai_body_replays_the_signature_beside_function_not_inside_it() { + // Position is what the gateway checks: nested inside `function{}` it is + // rejected with the same 400 as a missing signature. + let r = parse_openai(gemini_choice()).unwrap(); + let history = vec![HistoryItem::Assistant { + text: r.text.clone(), + tool_calls: r.tool_calls.clone(), + }]; + let body = openai_body( + &cfg(Provider::DatabricksV2), + "sys", + &history, + &[], + "databricks-gemini-3-6-flash", + None, + ); + let call = &body["messages"][1]["tool_calls"][0]; + assert_eq!(call["thoughtSignature"], json!("SIG-A")); + assert!(call["function"].get("thoughtSignature").is_none()); + assert_eq!(call["function"]["name"], json!("get_weather")); + } + + #[test] + fn parse_openai_makes_duplicate_tool_call_ids_unique() { + // Gemini returns the function name as the id, so parallel calls to one + // function collide and their results become indistinguishable. + let v = json!({"choices": [{"finish_reason": "tool_calls", "message": { + "role": "assistant", "content": "", + "tool_calls": [ + {"id": "get_weather", "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Paris\"}"}}, + {"id": "get_weather", "type": "function", + "function": {"name": "get_weather", "arguments": "{\"city\":\"Rome\"}"}} + ]}}]}); + let r = parse_openai(v).unwrap(); + assert_eq!(r.tool_calls[0].provider_id, "get_weather"); + assert_eq!(r.tool_calls[1].provider_id, "get_weather-2"); + } + #[test] fn parse_openai_uses_prompt_tokens() { let v = serde_json::json!({ diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 1b5f30b1ce..a3d48a7cf1 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -1,5 +1,5 @@ use serde::Deserialize; -use serde_json::Value; +use serde_json::{Map, Value}; /// Byte-equivalent charged to the handoff/context-pressure gate for a single /// image tool result. The gate maps bytes to tokens at 1 byte/token (see @@ -93,6 +93,13 @@ impl HistoryItem { + serde_json::to_vec(&c.arguments) .map(|b| b.len()) .unwrap_or(0) + // `provider_extra` (e.g. a Gemini + // `thoughtSignature`) is re-serialized into + // every replayed call, so it counts toward the + // request body and the context-pressure gate. + + serde_json::to_vec(&c.provider_extra) + .map(|b| b.len()) + .unwrap_or(0) }) .sum::() } @@ -108,6 +115,17 @@ pub struct ToolCall { pub provider_id: String, pub name: String, pub arguments: Value, + /// Fields the provider put on the tool call that we do not model, kept so + /// the assistant turn can be replayed the way it arrived. + /// + /// Gemini on the Databricks MLflow route returns a `thoughtSignature` per + /// call and *requires* it echoed back: replaying without it fails the whole + /// request with `Function call is missing a thought_signature in functionCall + /// parts`. For an agent loop that lands on the very first tool call, so the + /// model is unusable without this. Carrying whatever we did not model, + /// rather than naming that one field, means the next provider with an opaque + /// per-call token needs no change here. + pub provider_extra: Map, } #[derive(Debug, Clone)] @@ -353,6 +371,39 @@ mod tests { assert!(item.estimated_bytes() >= 3_118_884); } + #[test] + fn assistant_size_counts_provider_extra() { + // A Gemini `thoughtSignature` rides the wire on every replayed call, so + // both size measures must see it — otherwise `truncate_history` and the + // handoff gate under-count and let the real request exceed the budget. + let mut extra = Map::new(); + extra.insert("thoughtSignature".into(), Value::String("S".repeat(500))); + let with_extra = HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "id".into(), + name: "t".into(), + arguments: Value::Null, + provider_extra: extra, + }], + }; + let without_extra = HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "id".into(), + name: "t".into(), + arguments: Value::Null, + provider_extra: Map::new(), + }], + }; + assert!(with_extra.estimated_bytes() > without_extra.estimated_bytes() + 500); + assert_eq!( + with_extra.estimated_bytes(), + with_extra.context_pressure_bytes(), + "provider_extra is text, so both measures must agree" + ); + } + #[test] fn text_content_size_is_identical_for_both_measures() { // Only images diverge; text must size the same under both paths. From ddd468723ac1f0663abd19d23ca3484b2dd40bf5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 29 Jul 2026 13:08:32 -0400 Subject: [PATCH 31/99] revert(acp): remove dead GOOSE_ACP_SCHEDULER_DISABLED env injection (#3576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary [block/buzz#3144](https://github.com/block/buzz/pull/3144) injected `GOOSE_ACP_SCHEDULER_DISABLED=true` into every `AcpClient::spawn` call as a forward-compatible no-op, intended to suppress the cron scheduler in goose ACP children once the matching reader landed in goose. That reader only ever existed in [aaif-goose/goose#10738](https://github.com/aaif-goose/goose/pull/10738), which was closed unmerged. [goose#10781](https://github.com/aaif-goose/goose/pull/10781) (Lifei Zhou, merged 2026-07-29) disables the ACP scheduler by default at the source: `goose acp` now requires `--enable-scheduler` to start a scheduler. Buzz-spawned children therefore get no scheduler with zero configuration — making the `GOOSE_ACP_SCHEDULER_DISABLED` injection permanently dead code. ## What changes Removes from `crates/buzz-acp/src/acp.rs`: - `GOOSE_SCHEDULER_DISABLED_ENV` constant - `cmd.env(GOOSE_SCHEDULER_DISABLED_ENV, "true")` injection in `AcpClient::spawn` - `spawn_injects_scheduler_disabled_env_by_default` test - `spawn_scheduler_disabled_env_overrides_conflicting_extra_env` test - `spawn_and_read_child_env` helper (unreferenced once the two tests above are gone) No other files are affected. ## Why now Leaving dead code that references an env var no reader will ever consume misleads future maintainers about the actual scheduler-isolation mechanism. The isolation is now an upstream default, not a Buzz injection. Reverts: [block/buzz#3144](https://github.com/block/buzz/pull/3144) Related: [aaif-goose/goose#10781](https://github.com/aaif-goose/goose/pull/10781) Signed-off-by: Will Pfleger --- crates/buzz-acp/src/acp.rs | 85 -------------------------------------- 1 file changed, 85 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 3514580519..8a698954a0 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -20,10 +20,6 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB -/// Env var that tells a goose ACP child not to start its cron scheduler. -/// Injected unconditionally by [`AcpClient::spawn`]; see the call site for why. -pub(crate) const GOOSE_SCHEDULER_DISABLED_ENV: &str = "GOOSE_ACP_SCHEDULER_DISABLED"; - /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -517,16 +513,6 @@ impl AcpClient { cmd.env("CODEX_CONFIG", merged); } - // Buzz-managed agents must never execute the operator's personal cron - // schedule. A goose ACP child starts a scheduler over the shared - // `schedule.json`, so a pool of N children fires every scheduled job N - // times — under the wrong identity and racing standalone goose. - // - // Set last, and with no operator-wins escape hatch, so it beats both a - // conflicting persona `extra_env` entry and any inherited parent value. - // Agent builds that don't recognize the variable ignore it. - cmd.env(GOOSE_SCHEDULER_DISABLED_ENV, "true"); - // Spawn the agent in its own process group so SIGKILL doesn't propagate // to the harness's own process group on Unix. // tokio::process::Command::process_group is a stable tokio API (no extra imports needed). @@ -2866,46 +2852,6 @@ mod tests { .expect("failed to spawn test script") } - /// Spawn a script that echoes the named env vars as the child observes - /// them, one per line. `` means the child did not receive the var. - async fn spawn_and_read_child_env( - vars: &[&str], - extra_env: &[(String, String)], - ) -> Vec { - let script = vars - .iter() - .map(|var| format!("printf '%s\\n' \"${{{var}:-}}\"")) - .collect::>() - .join("\n"); - let mut client = AcpClient::spawn("bash", &["-c".into(), script], extra_env, false) - .await - .expect("failed to spawn env probe script"); - let mut observed = Vec::with_capacity(vars.len()); - for var in vars { - observed.push( - client - .reader - .next() - .await - .unwrap_or_else(|| panic!("child produced no output for {var}")) - .expect("child stdout was not readable"), - ); - } - observed - } - - /// Every spawned agent must be told not to run the operator's cron - /// schedule, without the caller having to opt in. - #[tokio::test] - async fn spawn_injects_scheduler_disabled_env_by_default() { - let observed = spawn_and_read_child_env(&[GOOSE_SCHEDULER_DISABLED_ENV], &[]).await; - assert_eq!( - observed, - vec!["true"], - "{GOOSE_SCHEDULER_DISABLED_ENV} must be injected into every spawn" - ); - } - /// Spawn a probe script whose file name carries a runtime identity (e.g. /// `hermes-acp`) and return the value of `var` as the child observed it. /// `` means the child did not receive the var. @@ -2978,37 +2924,6 @@ mod tests { ); } - /// Persona config must not be able to re-enable the scheduler: this is a - /// correctness invariant, not an operator-tunable default, so the - /// injection is set after (and therefore wins over) the `extra_env` loop. - /// - /// The control var pins that `extra_env` really did reach the child, so a - /// pass here means the conflicting entry lost the fight rather than - /// `extra_env` being dropped wholesale. - #[tokio::test] - async fn spawn_scheduler_disabled_env_overrides_conflicting_extra_env() { - let extra_env = vec![ - ( - GOOSE_SCHEDULER_DISABLED_ENV.to_string(), - "false".to_string(), - ), - ( - "BUZZ_ENV_PROBE_CONTROL".to_string(), - "delivered".to_string(), - ), - ]; - let observed = spawn_and_read_child_env( - &[GOOSE_SCHEDULER_DISABLED_ENV, "BUZZ_ENV_PROBE_CONTROL"], - &extra_env, - ) - .await; - assert_eq!( - observed, - vec!["true", "delivered"], - "a persona extra_env entry must not override {GOOSE_SCHEDULER_DISABLED_ENV}" - ); - } - #[tokio::test] async fn idle_timeout_fires_on_silent_process() { let mut client = spawn_script("sleep 10").await; From 7e9b77f72d82e019a99f074f1c9829be30c57ae1 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 11:36:12 -0600 Subject: [PATCH 32/99] Fix inline raster avatars in agent catalog (#3581) ## Summary - render existing shared personas whose catalog avatar is a bounded inline PNG, JPEG, GIF, or WebP data URL - keep rejecting arbitrary, malformed, unsupported, and oversized `data:` URLs - preserve hosted relay URLs as the forward format; catalog browsing remains read-only ## Root cause Paul's live shared kind:30175 head contains a 144,878-character `data:image/png;base64,...` avatar. The catalog projection accepted HTTP(S) URLs and bounded percent-encoded SVG emoji avatars only, so it projected Paul's avatar to `null` before `ProfileAvatar` rendered it. The owner still saw the local persona avatar, producing the reported owner/viewer mismatch. This patch accepts only four raster MIME types with strict base64 shape and a 256 KiB total URL cap at the existing catalog parsing boundary. It repairs already-signed heads such as Paul without viewer-side uploads or publication side effects. Hosted media remains the canonical forward path. #3578 uploads inline raster avatars during snapshot import, preventing the known source from creating future inline persona/profile values; existing signed catalog heads still need this compatibility path until their owners republish. ## Agent instruction finding The catalog publishes `AgentDefinition.system_prompt` verbatim as the user-authored **Agent instruction**, as documented by the sharing UI and NIP-AP. No Buzz base/core/runtime prompt is concatenated in the publish, catalog, or import path. This PR therefore does not remove authored instructions and accidentally strip copied agents of their behavior. ## Validation - targeted `personaCatalogRelay.test.mjs`: 24 passed - Desktop typecheck: passed - pre-push Desktop frontend suite: 3,771 passed - pre-push Desktop checks: passed - `git diff --check`: passed - independent review: no blockers, 9.4/10 Signed-off-by: Wes Co-authored-by: Carl --- .../agents/lib/personaCatalogRelay.test.mjs | 24 ++++++++++++++++++ .../agents/lib/personaCatalogRelay.ts | 25 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index 24f6959b1c..fbaf1f5274 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -205,6 +205,30 @@ test("test_non_svg_data_avatar_is_rejected", () => { assert.equal(catalogAvatarUrl("data:image/png,%89PNG"), null); }); +test("test_legacy_inline_raster_avatar_survives_the_catalog", () => { + for (const mime of ["png", "jpeg", "gif", "webp"]) { + const avatar = `data:image/${mime};base64,iVBORw0KGgo=`; + assert.equal(catalogAvatarUrl(avatar), avatar); + } +}); + +test("test_inline_raster_avatar_rejects_unbounded_or_malformed_payloads", () => { + const prefix = "data:image/png;base64,"; + const payloadLength = 256 * 1_024 - prefix.length; + const validPayloadLength = payloadLength - (payloadLength % 4); + const withinCap = `${prefix}${"a".repeat(validPayloadLength - 2)}==`; + assert.ok(withinCap.length <= 256 * 1_024); + assert.equal(catalogAvatarUrl(withinCap), withinCap); + assert.equal( + catalogAvatarUrl( + `${withinCap}${"a".repeat(256 * 1_024 - withinCap.length + 1)}`, + ), + null, + ); + assert.equal(catalogAvatarUrl("data:image/png;base64,not base64"), null); + assert.equal(catalogAvatarUrl("data:image/bmp;base64,aA=="), null); +}); + test("test_oversized_inline_svg_avatar_is_rejected", () => { const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`; assert.equal(withinCap.length, 8_192); diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.ts b/desktop/src/features/agents/lib/personaCatalogRelay.ts index c85a976ba6..02c3f8e202 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/personaCatalogRelay.ts @@ -93,6 +93,16 @@ function isSafeHttpUrl(value: unknown): value is string { const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,"; const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192; +/** + * Shared persona heads can carry an uploaded avatar as an inline raster. Keep + * those self-contained images renderable without accepting arbitrary `data:` + * URLs: only the raster MIME types browsers decode in ``, strict base64 + * shape, and a bound no larger than the relay's event-content ceiling. + */ +const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024; +const INLINE_RASTER_AVATAR_RE = + /^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u; + function isInlineSvgAvatar(value: unknown): value is string { return ( typeof value === "string" && @@ -101,6 +111,17 @@ function isInlineSvgAvatar(value: unknown): value is string { ); } +function isInlineRasterAvatar(value: unknown): value is string { + if ( + typeof value !== "string" || + value.length > MAX_INLINE_RASTER_AVATAR_LENGTH + ) { + return false; + } + const match = INLINE_RASTER_AVATAR_RE.exec(value); + return match !== null && (match[1]?.length ?? 0) % 4 === 0; +} + function optionalString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value : null; } @@ -121,7 +142,9 @@ function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null { } const avatarUrl = - isSafeHttpUrl(parsed.avatar_url) || isInlineSvgAvatar(parsed.avatar_url) + isSafeHttpUrl(parsed.avatar_url) || + isInlineSvgAvatar(parsed.avatar_url) || + isInlineRasterAvatar(parsed.avatar_url) ? parsed.avatar_url : null; const namePool = Array.isArray(parsed.name_pool) From 324bd6b464de5751e12abbd155376046ce3d2afc Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 12:03:20 -0600 Subject: [PATCH 33/99] Fix shared agent avatar import profiles (#3578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > Carl is updating this pull request on Wes's behalf. ## Summary - upload an embedded raster avatar through the existing authenticated media pipeline before minting or persisting an imported shared agent - store and publish only the resulting hosted URL so agent kind:0 profiles remain within content limits ## Root cause Snapshot import recovered raster avatar pixels as a large inline base64 data URL. That value was persisted and placed into the agent's kind:0 profile. The relay rejected the oversized profile, so other clients could not resolve the imported agent's avatar. ## Scope This is intentionally the forward fix only. It changes two Desktop files and does **not** add migration or reconciliation behavior for previously imported agents. Existing affected imports must be re-imported or fixed manually. ## Validation - successful pre-push Desktop suite: 1,863 passed, 14 ignored, 0 failed - all pre-push Rust/Desktop gates green, including all-target clippy - valid >256 KiB PNG import → production MIME detection/sanitization → bounded signed kind:0 containing only the hosted URL - upload failure, malformed data, and URL-only avatar cases covered - independent fresh review by Princess Donut: clean, no blocking findings Signed-off-by: Wes Co-authored-by: Carl --- desktop/src-tauri/src/commands/media.rs | 18 ++- .../src/commands/personas/snapshot/import.rs | 150 +++++++++++++++++- 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index bf8692ff70..ed3b340238 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -411,7 +411,7 @@ fn should_retry_legacy_upload(status: reqwest::StatusCode) -> bool { } async fn send_upload_attempt( - state: &State<'_, AppState>, + state: &AppState, url: String, auth_header: &str, mime: &str, @@ -455,10 +455,22 @@ async fn send_upload_attempt( response.map_err(|error| classify_request_error(&error)) } +pub(crate) async fn upload_image_bytes( + body: Vec, + state: &AppState, +) -> Result { + let mime = detect_and_validate_mime(&body)?; + if !mime.starts_with("image/") { + return Err("profile avatar must be an image".to_string()); + } + let body = sanitize_image_for_upload(body, &mime)?; + do_upload(body, &mime, state, None).await +} + async fn do_upload( body: Vec, mime: &str, - state: &State<'_, AppState>, + state: &AppState, progress: Option<(tauri::AppHandle, String)>, ) -> Result { let sha256 = hex::encode(Sha256::digest(&body)); @@ -559,7 +571,7 @@ pub async fn upload_media( /// files from ever leaving the client on image-only surfaces. async fn process_picked_path( path: std::path::PathBuf, - state: &State<'_, AppState>, + state: &AppState, images_only: bool, ) -> Result { // Pin the inode by opening the fd BEFORE spawn_blocking. This prevents a diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index 7648941d23..d23efe7730 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -256,6 +256,24 @@ pub(crate) fn decode_snapshot_from_bytes( Ok(snapshot) } +async fn materialize_import_avatar( + avatar_data_url: Option<&str>, + avatar_url: Option<&str>, + upload: F, +) -> Result, String> +where + F: FnOnce(Vec) -> Fut, + Fut: std::future::Future>, +{ + let Some(avatar_data_url) = avatar_data_url else { + return Ok(avatar_url.map(str::to_string)); + }; + let avatar_bytes = + crate::managed_agents::agent_snapshot::decode_avatar_data_url(avatar_data_url) + .ok_or_else(|| "Snapshot avatar data is malformed.".to_string())?; + upload(avatar_bytes).await.map(Some) +} + // ── `preview_agent_snapshot_import` ────────────────────────────────────────── /// Decode and validate a snapshot file, returning a preview for the @@ -354,12 +372,21 @@ pub async fn confirm_agent_snapshot_import( )?; let minted_parallelism = minted.parallelism; - // Effective avatar: data URL wins; URL fallback when data URL is absent. - let effective_avatar: Option = snapshot - .profile - .avatar_data_url - .clone() - .or_else(|| snapshot.profile.avatar_url.clone()); + // Profile metadata must contain a hosted URL. Inline avatar data can be far + // larger than the relay's kind:0 content limit, so upload imported pixels + // before minting or persisting the new agent. Failing here keeps import + // atomic instead of creating an agent whose profile can never publish. + let effective_avatar = materialize_import_avatar( + snapshot.profile.avatar_data_url.as_deref(), + snapshot.profile.avatar_url.as_deref(), + |avatar_bytes| async { + crate::commands::media::upload_image_bytes(avatar_bytes, &state) + .await + .map(|descriptor| descriptor.url) + .map_err(|error| format!("Could not upload the imported avatar: {error}")) + }, + ) + .await?; // Wire-format string for the persona definition's respond_to field. // Omit when it is the default (owner-only) to keep definitions clean. @@ -711,3 +738,114 @@ async fn submit_engram_event( } Ok(()) } + +#[cfg(test)] +mod import_avatar_tests { + use super::materialize_import_avatar; + use std::cell::Cell; + + #[tokio::test] + async fn inline_avatar_is_uploaded_and_replaced_with_hosted_url() { + let uploaded = Cell::new(false); + let result = materialize_import_avatar( + Some("data:image/png;base64,iVBORw0KGgo="), + Some("https://sender.invalid/avatar.png"), + |bytes| { + uploaded.set(true); + async move { + assert_eq!(bytes, b"\x89PNG\r\n\x1a\n"); + Ok("https://relay.example/media/avatar.png".to_string()) + } + }, + ) + .await + .unwrap(); + + assert!(uploaded.get()); + assert_eq!( + result.as_deref(), + Some("https://relay.example/media/avatar.png") + ); + } + + #[tokio::test] + async fn hosted_avatar_skips_upload() { + let result = + materialize_import_avatar(None, Some("https://sender.example/avatar.png"), |_| async { + panic!("hosted avatars must not be uploaded") + }) + .await + .unwrap(); + + assert_eq!(result.as_deref(), Some("https://sender.example/avatar.png")); + } + + #[tokio::test] + async fn relay_sized_inline_avatar_becomes_bounded_signed_profile() { + use base64::{engine::general_purpose::STANDARD, Engine}; + use image::ImageEncoder; + use nostr::JsonUtil; + + let mut pixels = vec![0_u8; 512 * 512 * 4]; + let mut seed = 0x1234_5678_u32; + for byte in &mut pixels { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + *byte = seed as u8; + } + let mut source = Vec::new(); + image::codecs::png::PngEncoder::new(&mut source) + .write_image(&pixels, 512, 512, image::ExtendedColorType::Rgba8) + .unwrap(); + assert!(source.len() > 256 * 1024); + let data_url = format!("data:image/png;base64,{}", STANDARD.encode(&source)); + assert!(data_url.len() > 256 * 1024); + + let avatar = materialize_import_avatar(Some(&data_url), None, |bytes| async move { + let mime = crate::commands::media::detect_and_validate_mime(&bytes)?; + assert_eq!(mime, "image/png"); + let sanitized = crate::commands::media::sanitize_image_for_upload(bytes, &mime)?; + image::load_from_memory(&sanitized).map_err(|error| error.to_string())?; + Ok("https://relay.example/media/avatar.png".to_string()) + }) + .await + .unwrap() + .unwrap(); + + let event = + crate::events::build_profile(Some("Imported agent"), None, Some(&avatar), None, None) + .unwrap() + .sign_with_keys(&nostr::Keys::generate()) + .unwrap(); + assert!(event.content.len() < 64 * 1024); + assert!(!event.content.contains("data:image/")); + assert!(event + .content + .contains("https://relay.example/media/avatar.png")); + assert!(event.as_json().len() < 256 * 1024); + } + + #[tokio::test] + async fn upload_failure_aborts_avatar_materialization() { + let result = materialize_import_avatar( + Some("data:image/png;base64,iVBORw0KGgo="), + None, + |_| async { Err("relay upload failed".to_string()) }, + ) + .await; + + assert_eq!(result.unwrap_err(), "relay upload failed"); + } + + #[tokio::test] + async fn malformed_inline_avatar_fails_before_upload() { + let result = + materialize_import_avatar(Some("data:image/png;base64,not-base64!"), None, |_| async { + panic!("malformed avatars must not be uploaded") + }) + .await; + + assert_eq!(result.unwrap_err(), "Snapshot avatar data is malformed."); + } +} From 259de6afbe0cc0d106e57ebdb2323064990e4122 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Wed, 29 Jul 2026 19:49:57 +0100 Subject: [PATCH 34/99] Improve emoji autocomplete matching (#3571) ## Summary - Show all colon emoji autocomplete matches - Rank exact and prefix shortcodes before weaker matches - Add a regression test and screenshot ## Validation - `pnpm test` - `pnpm build` - `pnpm exec playwright test --project=smoke tests/e2e/custom-emoji.spec.ts --grep "exact standard shortcode"` - `just desktop-tauri-clippy` Native Tauri tests were attempted but could not link because the local disk filled during compilation. --------- Signed-off-by: kenny lopez --- .../messages/lib/useEmojiAutocomplete.ts | 38 +++++--- .../messages/ui/EmojiAutocomplete.tsx | 94 +++++++++++-------- desktop/src/shared/lib/emojiSearch.test.mjs | 30 ++++++ desktop/src/shared/lib/emojiSearch.ts | 27 ++++++ desktop/src/testing/e2eBridge.ts | 5 +- desktop/tests/e2e/custom-emoji.spec.ts | 52 ++++++++++ 6 files changed, 193 insertions(+), 53 deletions(-) diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts index 53ef44b28d..57e458bc91 100644 --- a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -4,7 +4,11 @@ import { init, SearchIndex } from "emoji-mart"; import data from "@emoji-mart/data"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import { fuzzyStandardEmoji, rankByShortcode } from "@/shared/lib/emojiSearch"; +import { + fuzzyStandardEmoji, + rankByShortcode, + rankShortcodeMatchesFirst, +} from "@/shared/lib/emojiSearch"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import type { AutocompleteEdit } from "./useRichTextEditor"; @@ -18,7 +22,7 @@ export type EmojiSuggestion = { const EMOJI_DEBOUNCE_MS = 120; const MIN_QUERY_LENGTH = 2; -const MAX_RESULTS = 8; +const UNLIMITED_RESULTS = Number.POSITIVE_INFINITY; init({ data }); @@ -81,7 +85,7 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { emojiQuery, customEmojiRef.current, (e) => e.shortcode, - MAX_RESULTS, + UNLIMITED_RESULTS, ).map((e) => ({ id: e.shortcode, name: e.shortcode, @@ -89,7 +93,10 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { url: rewriteRelayUrl(e.url), })); - SearchIndex.search(emojiQuery) + SearchIndex.search(emojiQuery, { + caller: "useEmojiAutocomplete", + maxResults: UNLIMITED_RESULTS, + }) .then( ( results: Array<{ @@ -106,23 +113,28 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { native: emoji.skins[0]?.native ?? "", })) .filter((e) => e.native !== ""); - // Top up remaining slots with fuzzy shortcode matches emoji-mart - // missed — its token-prefix search can't cross `_` (so `pointup` - // finds nothing). Skip ids already shown to avoid duplicates. + // Add fuzzy shortcode matches emoji-mart missed — its token-prefix + // search can't cross `_` (so `pointup` finds nothing). Skip ids + // already shown to avoid duplicates. const shown = new Set( [...customMatches, ...standard].map((e) => e.id), ); const fuzzy: EmojiSuggestion[] = fuzzyStandardEmoji( emojiQuery, - MAX_RESULTS - customMatches.length - standard.length, + UNLIMITED_RESULTS, shown, ).map((e) => ({ id: e.id, name: e.name, native: e.native })); - // Custom emoji first (community-specific), then standard, then fuzzy. - const merged = [...customMatches, ...standard, ...fuzzy].slice( - 0, - MAX_RESULTS, + // Rank exact/prefix shortcode matches across custom and standard emoji + // before semantic and weaker matches (for example, `joy` before + // `bufo_joy`). Keep emoji-mart's name/keyword results ahead of loose + // substring and subsequence matches. + setSuggestions( + rankShortcodeMatchesFirst( + emojiQuery, + [...standard, ...customMatches, ...fuzzy], + (emoji) => emoji.id, + ), ); - setSuggestions(merged); setEmojiSelectedIndex(0); }, ) diff --git a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx index 3b6fa3a2d2..d44933aef1 100644 --- a/desktop/src/features/messages/ui/EmojiAutocomplete.tsx +++ b/desktop/src/features/messages/ui/EmojiAutocomplete.tsx @@ -2,6 +2,10 @@ import * as React from "react"; import type { EmojiSuggestion } from "@/features/messages/lib/useEmojiAutocomplete"; import { cn } from "@/shared/lib/cn"; +import { + type ListVirtualizer, + VirtualizedList, +} from "@/shared/ui/VirtualizedList"; import { POPOVER_CUSTOM_ENTER_MOTION_CLASS, POPOVER_SHADOW_STYLE, @@ -21,15 +25,21 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ onSelect, position = "above", }: EmojiAutocompleteProps) { - const listRef = React.useRef(null); + const listVirtualizerRef = React.useRef(null); React.useEffect(() => { - const activeItem = listRef.current?.children[selectedIndex] as - | HTMLElement - | undefined; - activeItem?.scrollIntoView({ block: "nearest" }); + listVirtualizerRef.current?.scrollToIndex(selectedIndex, { + align: "auto", + }); }, [selectedIndex]); + const handleVirtualizer = React.useCallback( + (virtualizer: ListVirtualizer) => { + listVirtualizerRef.current = virtualizer; + }, + [], + ); + if (suggestions.length === 0) { return null; } @@ -43,47 +53,55 @@ export const EmojiAutocomplete = React.memo(function EmojiAutocomplete({ >
- {suggestions.map((suggestion, index) => ( - - ))} + suggestion.id} + items={suggestions} + onVirtualizer={handleVirtualizer} + renderItem={(suggestion, index) => ( + + )} + />
); diff --git a/desktop/src/shared/lib/emojiSearch.test.mjs b/desktop/src/shared/lib/emojiSearch.test.mjs index 93cb1e286a..01a52293f5 100644 --- a/desktop/src/shared/lib/emojiSearch.test.mjs +++ b/desktop/src/shared/lib/emojiSearch.test.mjs @@ -5,6 +5,7 @@ import { fuzzyStandardEmoji, normalizeShortcode, rankByShortcode, + rankShortcodeMatchesFirst, scoreShortcodeMatch, } from "./emojiSearch.ts"; @@ -77,6 +78,35 @@ test("rankByShortcode respects the limit", () => { assert.equal(ranked.length, 2); }); +test("exact shortcode matches rank ahead of weaker custom shortcode matches", () => { + const items = [ + { code: "bufo_joy", source: "custom" }, + { code: "joy", source: "standard" }, + { code: "joy_cat", source: "custom" }, + { code: "face_with_tears_of_joy", source: "standard" }, + ]; + const ranked = rankShortcodeMatchesFirst("joy", items, (item) => item.code); + + assert.deepEqual( + ranked.map((item) => item.code), + ["joy", "joy_cat", "bufo_joy", "face_with_tears_of_joy"], + ); +}); + +test("semantic results stay ahead of loose shortcode matches", () => { + const items = [ + { code: "frowning_face", source: "semantic" }, + { code: "sandwich", source: "custom" }, + { code: "sad", source: "standard" }, + ]; + const ranked = rankShortcodeMatchesFirst("sad", items, (item) => item.code); + + assert.deepEqual( + ranked.map((item) => item.code), + ["sad", "frowning_face", "sandwich"], + ); +}); + test("fuzzyStandardEmoji surfaces point_up for `pointup`", () => { const hits = fuzzyStandardEmoji("pointup", 8, new Set()); const ids = hits.map((e) => e.id); diff --git a/desktop/src/shared/lib/emojiSearch.ts b/desktop/src/shared/lib/emojiSearch.ts index 2a0d74b9bb..ec4ea529b5 100644 --- a/desktop/src/shared/lib/emojiSearch.ts +++ b/desktop/src/shared/lib/emojiSearch.ts @@ -114,6 +114,33 @@ export function rankByShortcode( return scored.slice(0, limit).map((s) => s.item); } +/** + * Place exact and prefix shortcode matches ahead of items that only matched an + * emoji name or keyword. This lets an exact standard emoji like `joy` beat a + * weaker custom shortcode match such as `bufo_joy`, while retaining emoji-mart's + * order for name- and keyword-only results ahead of loose shortcode matches. + */ +export function rankShortcodeMatchesFirst( + query: string, + items: readonly T[], + shortcodeOf: (item: T) => string, +): T[] { + const strongShortcodeMatches = rankByShortcode( + query, + items, + shortcodeOf, + Number.POSITIVE_INFINITY, + ).filter((item) => { + const match = scoreShortcodeMatch(query, shortcodeOf(item)); + return match !== null && match.tier <= TIER_PREFIX; + }); + const matchedItems = new Set(strongShortcodeMatches); + return [ + ...strongShortcodeMatches, + ...items.filter((item) => !matchedItems.has(item)), + ]; +} + export interface StandardEmoji { id: string; name: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 7b13273c60..4cfc553df1 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -913,8 +913,8 @@ function createMockRelayMembershipEvent(): RelayEvent { * sets from distinct pubkeys so the e2e exercises the union/collapse path, not * a single relay-owned set. `:buzz:` is the stable shortcode exercised by * custom-emoji.spec.ts (claimed by BOTH members with different URLs, so the - * palette must collapse it to one deterministic winner); `:narf:` proves a - * second member's distinct emoji unions in. + * palette must collapse it to one deterministic winner); `:narf:` and + * `:bufo_joy:` prove a second member's distinct emoji unions in. */ function createMockCustomEmojiSetEvents(): RelayEvent[] { return [ @@ -941,6 +941,7 @@ function createMockCustomEmojiSetEvents(): RelayEvent[] { // member B claims :buzz: with a DIFFERENT url — unionCustomEmoji must // collapse it to one deterministic winner, never expose two URLs. ["emoji", "buzz", "https://example.com/e2e/buzz-b.png"], + ["emoji", "bufo_joy", "https://example.com/e2e/bufo-joy.png"], ], "b".repeat(64), ), diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index aa345570f9..ae20aaef25 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -1,6 +1,9 @@ import { expect, test } from "@playwright/test"; +import * as fs from "node:fs"; +import * as path from "node:path"; import { installMockBridge } from "../helpers/bridge"; +import { waitForAnimations } from "../helpers/animations"; // Custom-emoji end-to-end guard. // @@ -77,6 +80,55 @@ test("typing a known :shortcode: renders an inline emoji node in the composer", await expect(input).not.toContainText(`:${SHORTCODE}:`); }); +test("emoji autocomplete ranks an exact standard shortcode before a custom substring", async ({ + page, +}, testInfo) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially(":joy"); + + const autocomplete = page.getByTestId("emoji-autocomplete"); + await expect(autocomplete).toBeVisible(); + const labels = await autocomplete.locator("button").allTextContents(); + expect(labels[0]).toContain(":joy:"); + expect( + labels.findIndex((label) => label.includes(":bufo_joy:")), + ).toBeGreaterThan(0); + + const screenshotDir = path.resolve( + "test-results/emoji-autocomplete-screenshots", + ); + fs.mkdirSync(screenshotDir, { recursive: true }); + const screenshotPath = path.join( + screenshotDir, + "joy-exact-before-custom-substring.png", + ); + await waitForAnimations(page); + await autocomplete.screenshot({ path: screenshotPath }); + await testInfo.attach("joy-exact-before-custom-substring", { + path: screenshotPath, + contentType: "image/png", + }); +}); + +test("emoji autocomplete keeps semantic matches ahead of loose shortcode fallbacks", async ({ + page, +}) => { + await openGeneral(page); + + const input = page.getByTestId("message-input"); + await input.click(); + await input.pressSequentially(":sad"); + + const autocomplete = page.getByTestId("emoji-autocomplete"); + await expect(autocomplete).toBeVisible(); + await expect(autocomplete.locator("button").first()).not.toContainText( + ":sandwich:", + ); +}); + test("custom emoji deletes as a single unit (like a built-in emoji)", async ({ page, }) => { From 047533c56c2a2d03f23ef3edb990e58405767aac Mon Sep 17 00:00:00 2001 From: Krishna C Date: Wed, 29 Jul 2026 15:02:42 -0400 Subject: [PATCH 35/99] fix(mobile): keep TLS on relays joined by invite (#3139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Communities joined via an invite link never connect: the app dials `ws://` on port 80 instead of `wss://` on 443 and sits on "Reconnecting…" indefinitely. `RelayConfig.baseUrl` is documented as an HTTP origin, but the two onboarding flows disagree on what they persist: - **Device pairing** validates and stores `https://` — `pairing_provider.dart:657` throws on anything else. - **Invite join** stores the relay URL straight off the invite link, and `deep_link.dart:165` always emits `ws://` or `wss://`. `wsUrl` only special-cased `https://`, so a `wss://` base fell through to the plaintext branch: ```dart final scheme = uri.scheme == 'https' ? 'wss' : 'ws'; // 'wss' is not 'https' ``` The claim request itself succeeds, because `_claimUrlFromRelay` (`invite_join_provider.dart:242`) maps `wss → https` explicitly. Only the socket path is missing that conversion — which is why the community appears, correctly named, and then never loads. The same `baseUrl` also feeds `/query` (`relay_session.dart:136`), media upload (`media_upload.dart:765`), Blossom auth (`media_auth.dart:128`) and `relayClientProvider` (`relay_provider.dart:113`), so those requests were malformed too. Where port 80 *does* answer, it is additionally a silent TLS downgrade after `validateInviteRelayUri` insisted on `wss://`. This folds the websocket schemes back to their HTTP equivalents in `baseUrl` itself, so every consumer is correct by construction rather than needing a second getter remembered at each call site, and communities **already persisted** with `wss://` are repaired on read without a migration. `community_icon_provider.dart:46` already performs this same conversion locally. One subtlety worth flagging for review: the normalization is derived in the getter rather than applied in the constructor, so the constructor stays `const`. The compile-time fallback at `relay_provider.dart:77` relies on const canonicalization for a stable identity across rebuilds, and Riverpod's `defaultUpdateShouldNotify` is `previous != next` (`element.dart:361`), which falls back to identity for this class. A `factory` constructor here yields a fresh instance per rebuild, which tears down and resubscribes every listener — `channels_provider_test.dart` catches it as an unexpected unsubscribe during reconnect. ### Related issue Fixes #2662. ### Testing `flutter test` — **705 passed, 1 skipped, 0 failed** `flutter analyze` — No issues found `dart format --set-exit-if-changed .` — 249 files, 0 changed Run against the Hermit-pinned SDK (Flutter 3.41.7 / Dart 3.11.5), matching CI. 10 new unit tests in `mobile/test/shared/relay/relay_config_test.dart` covering both onboarding schemes, `http`/`https` passthrough, non-default ports, and agreement between the invite and pairing paths for the same relay. Verified end-to-end against a self-hosted relay behind `tailscale serve`, which terminates TLS on 443 and leaves port 80 closed. Relay logs show the invite claim succeeding over HTTPS at the moment of joining, while no WebSocket connection ever arrives — no `WebSocket connection established`, no NIP-42 auth, no `kind:0` profile, no push registration — across the relay's entire history, even though the member row is present and correct. Port-80 refusals are not logged by `tailscaled`'s netstack, which is why the retries leave no trace server-side. Reproduced on both iOS and Android. --------- Signed-off-by: Krishna C --- .../activity/compose_drafts_provider.dart | 8 +- .../search/recent_searches_provider.dart | 10 +- .../shared/relay/identity_scoped_prefs.dart | 50 ++++++++++ mobile/lib/shared/relay/relay.dart | 1 + mobile/lib/shared/relay/relay_provider.dart | 38 +++++++- .../compose_drafts_provider_test.dart | 29 ++++++ .../search/recent_searches_provider_test.dart | 21 +++++ .../relay/identity_scoped_prefs_test.dart | 92 +++++++++++++++++++ .../test/shared/relay/relay_config_test.dart | 67 ++++++++++++++ 9 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 mobile/lib/shared/relay/identity_scoped_prefs.dart create mode 100644 mobile/test/shared/relay/identity_scoped_prefs_test.dart create mode 100644 mobile/test/shared/relay/relay_config_test.dart diff --git a/mobile/lib/features/activity/compose_drafts_provider.dart b/mobile/lib/features/activity/compose_drafts_provider.dart index b9a079e9a8..b5755f19c8 100644 --- a/mobile/lib/features/activity/compose_drafts_provider.dart +++ b/mobile/lib/features/activity/compose_drafts_provider.dart @@ -79,7 +79,13 @@ class ComposeDraftsNotifier extends Notifier> { _prefsKey = '$_draftsPrefsKey:${config.baseUrl}:$pubkey'; final prefs = ref.read(savedPrefsProvider); - final raw = prefs.getString(_prefsKey); + final raw = readMigratedPref( + prefs, + canonicalKey: _prefsKey, + legacyKey: '$_draftsPrefsKey:${config.storedOrigin}:$pubkey', + read: prefs.getString, + write: prefs.setString, + ); if (raw == null) return const []; try { final decoded = jsonDecode(raw); diff --git a/mobile/lib/features/search/recent_searches_provider.dart b/mobile/lib/features/search/recent_searches_provider.dart index 813672a9cc..2fad17832e 100644 --- a/mobile/lib/features/search/recent_searches_provider.dart +++ b/mobile/lib/features/search/recent_searches_provider.dart @@ -19,8 +19,16 @@ class RecentSearchesNotifier extends Notifier> { final pubkey = ref.watch(myPubkeyProvider) ?? 'anon'; _prefsKey = '$_recentSearchesPrefsKey:${config.baseUrl}:$pubkey'; + final prefs = ref.read(savedPrefsProvider); final stored = - ref.read(savedPrefsProvider).getStringList(_prefsKey) ?? const []; + readMigratedPref>( + prefs, + canonicalKey: _prefsKey, + legacyKey: '$_recentSearchesPrefsKey:${config.storedOrigin}:$pubkey', + read: prefs.getStringList, + write: prefs.setStringList, + ) ?? + const []; return List.unmodifiable( stored .map((query) => query.trim()) diff --git a/mobile/lib/shared/relay/identity_scoped_prefs.dart b/mobile/lib/shared/relay/identity_scoped_prefs.dart new file mode 100644 index 0000000000..e080b1ca8c --- /dev/null +++ b/mobile/lib/shared/relay/identity_scoped_prefs.dart @@ -0,0 +1,50 @@ +import 'dart:async'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Reads an identity-scoped preference, migrating values left under a +/// pre-canonicalization relay origin. +/// +/// Identity-scoped keys embed the relay origin, and that origin used to be +/// whatever scheme the onboarding flow happened to persist — `wss://` for an +/// invite join, `https://` for device pairing. Now that [RelayConfig.baseUrl] +/// canonicalizes the scheme, an install that joined by invite would compute a +/// different key than the one its data was written under, leaving that data on +/// disk but unreachable. +/// +/// The value under [canonicalKey] wins. Otherwise a value under [legacyKey] is +/// returned straight away and promoted onto the canonical key in the +/// background. The legacy entry is removed only once the copy reports success, +/// so a migration interrupted mid-flight leaves the original readable and the +/// next read simply retries. +/// +/// Pass matching accessors for the stored type — `getString`/`setString`, or +/// `getStringList`/`setStringList`. +T? readMigratedPref( + SharedPreferences prefs, { + required String canonicalKey, + required String legacyKey, + required T? Function(String key) read, + required Future Function(String key, T value) write, +}) { + final canonical = read(canonicalKey); + if (canonical != null) return canonical; + + // Pairing-created communities already store an HTTP origin, so the two keys + // coincide and there is nothing to migrate. + if (canonicalKey == legacyKey) return null; + + final legacy = read(legacyKey); + if (legacy == null) return null; + + unawaited(_promote(prefs, legacyKey, write(canonicalKey, legacy))); + return legacy; +} + +Future _promote( + SharedPreferences prefs, + String legacyKey, + Future copy, +) async { + if (await copy) await prefs.remove(legacyKey); +} diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart index d168b9a097..bc11325414 100644 --- a/mobile/lib/shared/relay/relay.dart +++ b/mobile/lib/shared/relay/relay.dart @@ -1,4 +1,5 @@ export 'app_lifecycle_provider.dart'; +export 'identity_scoped_prefs.dart'; export 'media_auth.dart'; export 'media_image.dart'; export 'media_upload.dart'; diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 97b88dd3df..061dd6cb38 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -10,12 +10,46 @@ import 'relay_client.dart'; /// - `baseUrl` — where the relay lives (used for WS + media upload) /// - `nsec` — the user's signing key (drives NIP-42 AUTH and event sigs) class RelayConfig { - final String baseUrl; + const RelayConfig({required String baseUrl, this.nsec}) : _baseUrl = baseUrl; + + /// Relay origin exactly as the active community stored it. + final String _baseUrl; /// Nostr secret key (bech32 nsec) for signing events and NIP-42 AUTH. final String? nsec; - const RelayConfig({required this.baseUrl, this.nsec}); + /// The origin as persisted, before scheme canonicalization. + /// + /// Exists solely so identity-scoped storage keys written before [baseUrl] + /// was canonicalized stay reachable — see [readMigratedPref]. Never use it + /// for network I/O; [baseUrl] and [wsUrl] are the addresses to connect to. + String get storedOrigin => _baseUrl; + + /// Relay origin as an HTTP(S) URL. + /// + /// Communities are persisted with whichever scheme their onboarding flow + /// used: device pairing stores `https://` (it rejects anything else), while + /// an invite join stores the `wss://` relay URL carried by the invite link. + /// Every consumer treats this as an HTTP origin — [wsUrl], the `/query` + /// endpoint, media upload and Blossom auth — so a `wss://` base silently + /// degrades all of them. Folding the websocket schemes back here keeps both + /// onboarding paths equivalent, including for already-persisted communities. + /// + /// Derived rather than normalized in the constructor so that the constructor + /// stays `const`: the compile-time fallback below relies on canonicalization + /// to keep its identity stable across rebuilds, and Riverpod's default + /// `updateShouldNotify` is `previous != next`, which falls back to identity + /// here. A fresh instance per rebuild would resubscribe every listener. + String get baseUrl { + final uri = Uri.tryParse(_baseUrl); + if (uri == null) return _baseUrl; + final scheme = switch (uri.scheme) { + 'wss' => 'https', + 'ws' => 'http', + _ => null, + }; + return scheme == null ? _baseUrl : uri.replace(scheme: scheme).toString(); + } /// Derive the websocket URL from the HTTP base URL. String get wsUrl { diff --git a/mobile/test/features/activity/compose_drafts_provider_test.dart b/mobile/test/features/activity/compose_drafts_provider_test.dart index 1f93dcb674..15899e6597 100644 --- a/mobile/test/features/activity/compose_drafts_provider_test.dart +++ b/mobile/test/features/activity/compose_drafts_provider_test.dart @@ -34,6 +34,35 @@ void main() { return container; } + test( + 'an invite-joined community keeps its drafts after origin canonicalization', + () async { + // Written by a build that stored the invite link's wss:// origin + // verbatim; RelayConfig now canonicalizes that to https://, so the key + // the app computes no longer matches the key on disk. + const legacyKey = 'compose_drafts_v1:wss://relay-a.example:pk_a'; + const canonicalKey = 'compose_drafts_v1:https://relay-a.example:pk_a'; + SharedPreferences.setMockInitialValues({ + legacyKey: + '[{"key":"ch1","channel_id":"ch1","text":"unsent work",' + '"updated_at":1700000000}]', + }); + + final container = await containerWithPrefs( + relayUrl: 'wss://relay-a.example', + ); + + final drafts = container.read(composeDraftsProvider); + expect(drafts, hasLength(1), reason: 'draft survives the upgrade'); + expect(drafts.single.text, 'unsent work'); + + await Future.delayed(Duration.zero); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString(canonicalKey), isNotNull); + expect(prefs.getString(legacyKey), isNull); + }, + ); + test('composeDraftKey separates channel and thread composers', () { expect(composeDraftKey('ch1'), 'ch1'); expect(composeDraftKey('ch1', threadHeadId: 't1'), 'ch1:t1'); diff --git a/mobile/test/features/search/recent_searches_provider_test.dart b/mobile/test/features/search/recent_searches_provider_test.dart index 30f13c329c..df0a08a8ea 100644 --- a/mobile/test/features/search/recent_searches_provider_test.dart +++ b/mobile/test/features/search/recent_searches_provider_test.dart @@ -35,6 +35,27 @@ void main() { return container; } + test('an invite-joined community keeps its recent searches after ' + 'origin canonicalization', () async { + const legacyKey = 'recent_searches_v1:wss://relay-a.example:pk-a'; + const canonicalKey = 'recent_searches_v1:https://relay-a.example:pk-a'; + SharedPreferences.setMockInitialValues({ + legacyKey: ['nostr', 'relays'], + }); + + final container = await containerWithPrefs( + relayUrl: 'wss://relay-a.example', + pubkey: 'pk-a', + ); + + expect(container.read(recentSearchesProvider), ['nostr', 'relays']); + + await Future.delayed(Duration.zero); + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getStringList(canonicalKey), ['nostr', 'relays']); + expect(prefs.getStringList(legacyKey), isNull); + }); + test( 'normalizes, deduplicates, caps, and persists submitted queries', () async { diff --git a/mobile/test/shared/relay/identity_scoped_prefs_test.dart b/mobile/test/shared/relay/identity_scoped_prefs_test.dart new file mode 100644 index 0000000000..4fce3f254b --- /dev/null +++ b/mobile/test/shared/relay/identity_scoped_prefs_test.dart @@ -0,0 +1,92 @@ +import 'package:buzz/shared/relay/identity_scoped_prefs.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const canonical = 'k_v1:https://relay.example:pk'; + const legacy = 'k_v1:wss://relay.example:pk'; + + Future prefsWith(Map values) async { + SharedPreferences.setMockInitialValues(values); + return SharedPreferences.getInstance(); + } + + String? readString(SharedPreferences prefs) => readMigratedPref( + prefs, + canonicalKey: canonical, + legacyKey: legacy, + read: prefs.getString, + write: prefs.setString, + ); + + test('returns the canonical value when present', () async { + final prefs = await prefsWith({canonical: 'new', legacy: 'old'}); + expect(readString(prefs), 'new'); + }); + + test('falls back to the legacy value and returns it immediately', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + expect(readString(prefs), 'carried over'); + }); + + test('promotes the legacy value onto the canonical key', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + readString(prefs); + await Future.delayed(Duration.zero); + + expect(prefs.getString(canonical), 'carried over'); + expect(prefs.getString(legacy), isNull, reason: 'legacy entry is cleared'); + }); + + test('is idempotent across repeated reads', () async { + final prefs = await prefsWith({legacy: 'carried over'}); + readString(prefs); + await Future.delayed(Duration.zero); + expect(readString(prefs), 'carried over'); + await Future.delayed(Duration.zero); + expect(prefs.getString(canonical), 'carried over'); + }); + + test('returns null when neither key holds a value', () async { + final prefs = await prefsWith({}); + expect(readString(prefs), isNull); + }); + + test('does not touch storage when the keys coincide', () async { + // A pairing-created community already stores an HTTP origin, so canonical + // and legacy are the same string and there is nothing to migrate. + SharedPreferences.setMockInitialValues({canonical: 'only'}); + final prefs = await SharedPreferences.getInstance(); + final value = readMigratedPref( + prefs, + canonicalKey: canonical, + legacyKey: canonical, + read: prefs.getString, + write: prefs.setString, + ); + await Future.delayed(Duration.zero); + + expect(value, 'only'); + expect(prefs.getString(canonical), 'only'); + }); + + test('migrates string lists as well as strings', () async { + final prefs = await prefsWith({ + legacy: ['alpha', 'beta'], + }); + final value = readMigratedPref>( + prefs, + canonicalKey: canonical, + legacyKey: legacy, + read: prefs.getStringList, + write: prefs.setStringList, + ); + await Future.delayed(Duration.zero); + + expect(value, ['alpha', 'beta']); + expect(prefs.getStringList(canonical), ['alpha', 'beta']); + expect(prefs.getStringList(legacy), isNull); + }); +} diff --git a/mobile/test/shared/relay/relay_config_test.dart b/mobile/test/shared/relay/relay_config_test.dart new file mode 100644 index 0000000000..d0decb3685 --- /dev/null +++ b/mobile/test/shared/relay/relay_config_test.dart @@ -0,0 +1,67 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:buzz/shared/relay/relay_provider.dart'; + +void main() { + group('RelayConfig.baseUrl normalization', () { + test('folds a wss:// community URL to https://', () { + // Invite joins persist the relay URL straight off the invite link, which + // deep_link.dart always emits as ws:// or wss://. + final config = RelayConfig(baseUrl: 'wss://relay.example.com'); + expect(config.baseUrl, 'https://relay.example.com'); + }); + + test('folds a ws:// community URL to http://', () { + final config = RelayConfig(baseUrl: 'ws://relay.example.com:3000'); + expect(config.baseUrl, 'http://relay.example.com:3000'); + }); + + test('leaves an https:// community URL untouched', () { + // Device pairing rejects anything but https://, so these already conform. + final config = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(config.baseUrl, 'https://relay.example.com'); + }); + + test('leaves an http:// community URL untouched', () { + final config = RelayConfig(baseUrl: 'http://localhost:3000'); + expect(config.baseUrl, 'http://localhost:3000'); + }); + + test('preserves a non-default port', () { + final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443'); + expect(config.baseUrl, 'https://relay.example.com:8443'); + }); + }); + + group('RelayConfig.wsUrl', () { + test('keeps TLS for a relay joined by invite', () { + // Regression: a wss:// base used to fall through to the non-https branch + // and downgrade to ws://, dialing port 80 — which never connects on a + // relay that only serves 443, and drops TLS everywhere else. + final config = RelayConfig(baseUrl: 'wss://relay.example.com'); + expect(config.wsUrl, 'wss://relay.example.com'); + }); + + test('keeps TLS for a relay added by pairing', () { + final config = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(config.wsUrl, 'wss://relay.example.com'); + }); + + test('both onboarding paths agree on the same relay', () { + final invited = RelayConfig(baseUrl: 'wss://relay.example.com'); + final paired = RelayConfig(baseUrl: 'https://relay.example.com'); + expect(invited.wsUrl, paired.wsUrl); + expect(invited.baseUrl, paired.baseUrl); + }); + + test('stays plaintext for local development', () { + final config = RelayConfig(baseUrl: 'http://localhost:3000'); + expect(config.wsUrl, 'ws://localhost:3000'); + }); + + test('preserves a non-default port', () { + final config = RelayConfig(baseUrl: 'wss://relay.example.com:8443'); + expect(config.wsUrl, 'wss://relay.example.com:8443'); + }); + }); +} From b42a8d447e3a2b85b2313dc4fdd123731fd8bba3 Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 13:46:47 -0600 Subject: [PATCH 36/99] fix(desktop): reconcile thread arrivals at bottom (#3585) ## Summary - reconcile stale native-scroll anchors when a reply arrives at the physical floor - clear the thread new-message affordance instead of incrementing it from stale cached state - preserve the existing mid-history path and add direct lifecycle regression coverage ## Why PR #3411 fixed geometry-driven reconciliation, but the reply-arrival branch still trusted a cached `message` anchor without checking the rendered position. Native anchoring could return a short thread to the floor without another scroll/resize callback, then the next reply incremented the pill anyway. ## Verification - Desktop checks passed - Desktop typecheck passed - focused lifecycle test passed (6/6) - push hook full Desktop unit suite passed (3,770/3,770) - `git diff --check` passed Signed-off-by: Wes Co-authored-by: Carl --- .../ui/useAnchoredScroll.lifecycle.test.mjs | 93 ++++++++++++++++++- .../features/messages/ui/useAnchoredScroll.ts | 16 ++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs index 1fdfb05857..ee3fec1a98 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs +++ b/desktop/src/features/messages/ui/useAnchoredScroll.lifecycle.test.mjs @@ -247,13 +247,20 @@ function Harness({ channelId, onTargetSettled, refs }) { return null; } -function BottomStateHarness({ messages, onState, refs }) { +function BottomStateHarness({ + messages, + onState, + refs, + targetMessageId = null, +}) { const anchored = useAnchoredScroll({ channelId: "conversation", contentRef: refs.content, isLoading: false, messages, + pinTargetCentered: targetMessageId !== null, scrollContainerRef: refs.container, + targetMessageId, }); onState(anchored); return null; @@ -329,6 +336,90 @@ test("channel change attaches pinned-center observers after refs mount", async ( }); }); +test("arrival at the physical floor does not preserve a stale unread state", async () => { + const refs = { + container: { current: null }, + content: { current: null }, + }; + const root = createRoot(document.createElement("div")); + const nodes = makePinnedCenterNodes(); + refs.container.current = nodes.container; + refs.content.current = nodes.content; + let state = null; + const render = (messages) => + root.render( + React.createElement(BottomStateHarness, { + messages, + onState: (nextState) => { + state = nextState; + }, + refs, + }), + ); + + await act(async () => render([{ id: "first" }])); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + assert.equal(state.isAtBottom, false); + + // Native anchoring can return the viewport to the floor without a scroll or + // resize callback, leaving only the hook's cached message anchor stale. + nodes.container.scrollTop = + nodes.container.scrollHeight - nodes.container.clientHeight; + await act(async () => render([{ id: "first" }, { id: "second" }])); + + assert.equal(state.isAtBottom, true); + assert.equal(state.newMessageCount, 0); + await act(async () => root.unmount()); +}); + +test("arrival does not steal an active layout target during floor-like reflow", async () => { + const refs = { + container: { current: null }, + content: { current: null }, + }; + const root = createRoot(document.createElement("div")); + const nodes = makePinnedCenterNodes(); + refs.container.current = nodes.container; + refs.content.current = nodes.content; + let state = null; + const render = (messages, targetMessageId = null) => + root.render( + React.createElement(BottomStateHarness, { + messages, + onState: (nextState) => { + state = nextState; + }, + refs, + targetMessageId, + }), + ); + + await act(async () => render([{ id: "selected" }])); + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + nodes.container.scrollTop = 100; + await act(async () => state.onScroll()); + assert.equal(state.isAtBottom, false); + + // A focus/split presentation switch can commit fresh replies while the old + // container geometry momentarily reads as the physical floor. The explicit + // layout target must win so the reading row is restored after reflow. + nodes.container.scrollTop = + nodes.container.scrollHeight - nodes.container.clientHeight; + await act(async () => + render([{ id: "selected" }, { id: "second" }], "selected"), + ); + + assert.equal(state.isAtBottom, false); + assert.equal(state.newMessageCount, 1); + await act(async () => root.unmount()); +}); + test("container resize clears a stale new-message state at the physical floor", async () => { const refs = { container: { current: null }, diff --git a/desktop/src/features/messages/ui/useAnchoredScroll.ts b/desktop/src/features/messages/ui/useAnchoredScroll.ts index add9439599..0bfcb3b3e2 100644 --- a/desktop/src/features/messages/ui/useAnchoredScroll.ts +++ b/desktop/src/features/messages/ui/useAnchoredScroll.ts @@ -714,6 +714,22 @@ export function useAnchoredScroll({ container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); } if (newLatestArrived) setNewMessageCount(0); + } else if ( + messagesArrived > 0 && + !targetMessageId && + !virtualizerOwnsPrependAnchoring && + isAtBottomNow(container) + ) { + // A native scroll/layout callback may not have reconciled a stale + // message anchor before this append commits. If the rendered result is + // still physically at the floor (common in short threads), do not turn + // that stale anchor into a visible unread affordance. Active navigation + // targets own the viewport and must be preserved across presentation + // reflow even when the old geometry momentarily reads as the floor. + anchorRef.current = { kind: "at-bottom" }; + container.scrollTo({ top: container.scrollHeight, behavior: "auto" }); + setIsAtBottom(true); + setNewMessageCount(0); } else if (messagesArrived > 0 && !virtualizerOwnsPrependAnchoring) { // Anchored mid-history. An older-history prepend grows the content above // the reading row; the browser's native scroll anchoring does NOT correct From 66e7054928cc29395f828467c3e8c81b7408dd29 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 29 Jul 2026 15:48:06 -0400 Subject: [PATCH 37/99] fix(desktop): deduplicate relay outage notification (#3579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary - Keep the relay reconnect notification dismissed across repeated connection retries during one continuous outage. - Re-arm the notification after recovery or relay lifecycle replacement, including switches between communities that use the same relay URL. - Preserve the existing dedicated path for authentication and other application-level errors. ### How an outage is tracked The AppShell-owned relay-card hook treats an outage as one contiguous runtime episode rather than assigning it a persisted ID. A hook-local `outageActiveRef` is armed by the first qualifying unreachable/degraded observation. While it is armed, intermediate retry states (`connecting`, `reconnecting`, `stalled`, and `disconnected`) belong to that same episode, so retry churn cannot clear dismissal or emit another notification. The hook receives the same lifecycle identity used by community initialization: community ID plus `reinitKey`. This distinguishes multiple communities even when they share a relay URL, and it also changes when the active community is explicitly reinitialized. The latch and dismissal are reset when that identity changes or when the relay singleton reports its authoritative `idle` teardown state. A successful `connected` state also closes the episode and re-arms the next outage. These boundaries deliberately bias toward re-notifying rather than suppressing a later outage: recovery, community switch/reinit, or relay teardown cannot leave the hook stuck believing an old outage is still active. No outage state is persisted beyond the mounted hook lifecycle. ### Related issue None found. ### Testing - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,769 passed - `pnpm --dir desktop check` - `pnpm --dir desktop build:e2e` - `pnpm --dir desktop exec playwright test tests/e2e/sidebar-relay-card.spec.ts --project=integration` — 11 passed --------- Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- desktop/src/app/AppShell.tsx | 1 + .../ui/useSidebarRelayConnectionCard.ts | 35 +++++++--- desktop/tests/e2e/sidebar-relay-card.spec.ts | 68 +++++++++++++++++++ 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 75f57257cc..abbbf29610 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -231,6 +231,7 @@ export function AppShell() { const relayConnectionCard = useSidebarRelayConnectionCard( channelsErrorMessage, communitiesHook.activeCommunity?.relayUrl, + `${communitiesHook.activeCommunity?.id ?? "none"}-${communitiesHook.reinitKey}`, ); const memberChannels = React.useMemo( () => channels.filter((channel) => channel.isMember), diff --git a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts index f92e1a02e8..11f2bd1064 100644 --- a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts +++ b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts @@ -63,6 +63,7 @@ function isDocumentVisible() { export function useSidebarRelayConnectionCard( errorMessage?: string, relayUrl?: string | null, + relayLifecycleKey = relaySuccessKey(relayUrl), ) { const relayConnectionState = useRelayConnection(); const hasRelayUnreachableError = errorMessage @@ -79,7 +80,6 @@ export function useSidebarRelayConnectionCard( relayConnectionState === "stalled" || (relayConnectionState === "disconnected" && !hasNonUnreachableError); const isRelayConnectionConnected = relayConnectionState === "connected"; - const isRelayConnectionDisconnected = relayConnectionState === "disconnected"; const [isDismissed, setIsDismissed] = React.useState(false); const hasSuccess = React.useSyncExternalStore( subscribeRelayConnectivitySuccess, @@ -95,6 +95,8 @@ export function useSidebarRelayConnectionCard( const isRelayConnectionSuccess = hasSuccess && isRelayConnectionConnected; const canShow = isRelayConnectionActuallyDegraded || isRelayConnectionSuccess; const show = canShow && !isDismissed; + const outageActiveRef = React.useRef(false); + const outageRelayLifecycleKeyRef = React.useRef(relayLifecycleKey); const wasProblemCardVisibleRef = React.useRef(false); const { isPending: isReconnectPending, @@ -111,30 +113,41 @@ export function useSidebarRelayConnectionCard( isReconnectPending || connectivityAction === "relay-connection"; React.useEffect(() => { - if (!isRelayConnectionActuallyDegraded && !isRelayConnectionSuccess) { + if (outageRelayLifecycleKeyRef.current !== relayLifecycleKey) { + outageRelayLifecycleKeyRef.current = relayLifecycleKey; + outageActiveRef.current = false; + wasProblemCardVisibleRef.current = false; setIsDismissed(false); } - }, [isRelayConnectionSuccess, isRelayConnectionActuallyDegraded]); - React.useEffect(() => { - if (isRelayConnectionStateDegraded || isRelayConnectionDisconnected) { - setRelayConnectivitySuccess(relayUrl, false); + if (relayConnectionState === "idle") { + outageActiveRef.current = false; + wasProblemCardVisibleRef.current = false; setIsDismissed(false); + return; } - }, [isRelayConnectionDisconnected, isRelayConnectionStateDegraded, relayUrl]); - React.useEffect(() => { if (isRelayConnectionActuallyDegraded) { + if (!outageActiveRef.current) { + outageActiveRef.current = true; + setRelayConnectivitySuccess(relayUrl, false); + setIsDismissed(false); + } wasProblemCardVisibleRef.current = show && !isRelayConnectionSuccess; return; } - if (wasProblemCardVisibleRef.current && isRelayConnectionConnected) { - wasProblemCardVisibleRef.current = false; - setRelayConnectivitySuccess(relayUrl, true); + if (outageActiveRef.current && isRelayConnectionConnected) { + outageActiveRef.current = false; + if (wasProblemCardVisibleRef.current) { + wasProblemCardVisibleRef.current = false; + setRelayConnectivitySuccess(relayUrl, true); + } } }, [ isRelayConnectionSuccess, + relayLifecycleKey, + relayConnectionState, relayUrl, show, isRelayConnectionActuallyDegraded, diff --git a/desktop/tests/e2e/sidebar-relay-card.spec.ts b/desktop/tests/e2e/sidebar-relay-card.spec.ts index 9505c0f10f..b9903614d2 100644 --- a/desktop/tests/e2e/sidebar-relay-card.spec.ts +++ b/desktop/tests/e2e/sidebar-relay-card.spec.ts @@ -84,6 +84,25 @@ async function setRelayConnectionState( }, state); } +async function emitRelayConnectionState( + page: Page, + state: RelayConnectionState, +) { + await page.evaluate((nextState) => { + const setConnectionState = ( + window as Window & { + __BUZZ_E2E_SET_RELAY_CONNECTION_STATE__?: ( + state: RelayConnectionState, + ) => void; + } + ).__BUZZ_E2E_SET_RELAY_CONNECTION_STATE__; + if (!setConnectionState) { + throw new Error("Mock relay connection state helper is not installed."); + } + setConnectionState(nextState); + }, state); +} + async function expectGenericReconnectCard(page: Page) { const card = page.getByTestId("sidebar-relay-unreachable"); await expect(card).toBeVisible(); @@ -109,6 +128,55 @@ test("sidebar generic relay failures use the reconnect card", async ({ await expectGenericReconnectCard(page); }); +test("relay outage notification stays dismissed through retries and re-arms after recovery", async ({ + page, +}) => { + await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); + await page.goto("/"); + await setRelayConnectionState(page, "disconnected"); + + const card = await expectGenericReconnectCard(page); + await card + .getByRole("button", { name: "Dismiss relay notification" }) + .click({ force: true }); + await expect(card).toBeHidden(); + + // Retry churn is still the same outage: no successful connection occurred. + await emitRelayConnectionState(page, "connecting"); + await emitRelayConnectionState(page, "disconnected"); + await emitRelayConnectionState(page, "reconnecting"); + await page.waitForTimeout(2_100); + await expect(card).toBeHidden(); + + // A successful connection ends the episode and re-arms the next outage. + await setChannelsReadError(page, null); + await emitRelayConnectionState(page, "connected"); + await setChannelsReadError(page, CONNECT_ERROR); + await emitRelayConnectionState(page, "disconnected"); + await expectGenericReconnectCard(page); +}); + +test("relay outage notification re-arms after same-URL lifecycle teardown", async ({ + page, +}) => { + await installMockBridge(page, { channelsReadError: CONNECT_ERROR }); + await page.goto("/"); + await setRelayConnectionState(page, "disconnected"); + + const card = await expectGenericReconnectCard(page); + await card + .getByRole("button", { name: "Dismiss relay notification" }) + .click({ force: true }); + await expect(card).toBeHidden(); + + // Community switches and reconnectCommunity() tear down the singleton to + // idle before applying the next lifecycle. The next lifecycle may reuse the + // same relay URL, so URL identity alone must not preserve the old dismissal. + await emitRelayConnectionState(page, "idle"); + await emitRelayConnectionState(page, "disconnected"); + await expectGenericReconnectCard(page); +}); + test("sidebar proxy sign-in failures use the reconnect card", async ({ page, }) => { From 7adc46268d5e93f0b1d4dc8e700af22815dcac1b Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 29 Jul 2026 16:37:53 -0400 Subject: [PATCH 38/99] feat(cli): mirror Desktop mention delivery (#3330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 ## Summary Agent-authored mentions currently depend on matching visible `@Name` text to channel profiles. That makes notification delivery ambiguous when names collide or profiles change, and it encourages an extra post-send lookup just to confirm that the intended `p` tags were emitted. This change makes `buzz messages send` mirror Desktop's existing model: the message keeps a readable name in its content while the recipient pubkey is supplied separately. ```bash buzz messages send \ --channel \ --content '@Alice could you review this?' \ --mention ``` `--mention` is repeatable. The CLI normalizes and deduplicates explicit pubkeys, merges them with any names it can resolve from the channel, and gives explicit identities priority under the existing 50-mention limit. Before uploading attachments, signing, or publishing, the command checks every resulting pubkey against the channel's current membership: - Members are mentioned normally. - Non-members stop the send and produce an actionable error. - `--allow-non-member-mentions` deliberately sends notifying `p` tags without adding anyone to the channel. Sending a message never changes membership. On success, `mention_pubkeys` is read from the exact signed event and returned with the relay response, so callers can verify the emitted recipients without another query. Managed-agent guidance teaches this single-command mention flow. Desktop mention behavior and the Nostr event schema are unchanged. Forum guidance is intentionally handled separately in #3596. ### Related issue None found. This replaces the earlier guidance-only approach in this PR with the underlying CLI behavior it required. ### Testing - `cargo test -p buzz-sdk` - `cargo test -p buzz-cli` - `cargo test -p buzz-acp` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml` --------- Signed-off-by: npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz <4b78193083ca25112966f20d4368247d44e3965ba7219497e4cf6a98b308b9c0@buzz.block.builderlab.xyz> Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> Co-authored-by: npub1fdupjvyregj3z2tx7gx5x6py04zw89jm5usef9lyea4f3vcgh8qq9zgkdz <4b78193083ca25112966f20d4368247d44e3965ba7219497e4cf6a98b308b9c0@buzz.block.builderlab.xyz> Co-authored-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/base_prompt.md | 2 + crates/buzz-acp/src/lib.rs | 16 + crates/buzz-cli/src/commands/messages.rs | 294 +++++++++++++++--- crates/buzz-cli/src/lib.rs | 3 + desktop/src-tauri/src/managed_agents/nest.rs | 2 +- .../src/managed_agents/nest/tests.rs | 12 + .../src/managed_agents/nest_skill.md | 9 +- 7 files changed, 288 insertions(+), 50 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index c42e65cb83..e360d24982 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -40,6 +40,8 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat - Use the person's **exact full display name** after `@` (e.g., `@Will Pfleger`, not `@Will`). Partial names fail silently. - Do NOT format mentions with bold, italic, or backticks — it breaks notification delivery. +- When you know intended recipient pubkeys, send readable `@Name` text and pass the identities separately in the same command: `buzz messages send ... --content "@Name ..." --mention `. Repeat `--mention` for multiple recipients. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add their own recipients. Include a pubkey for every presentation-only name that should notify. The success JSON's `mention_pubkeys` comes from the signed event and is the delivery evidence; no follow-up verification command is needed. +- Without `--mention`, the CLI resolves `@Name` against current channel members. It stops before sending on an unresolved/ambiguous name or a mentioned pubkey that is not a member. For a non-member, add them explicitly with `buzz channels add-member` only when authorized, then retry. Sending never changes membership automatically. - Only `@mention` when you need their attention. Don't mention in narrative (e.g., "coordinating with Duncan" — no `@`). Naming someone while talking *about* them is narrative — "waiting on @morgan", "until @morgan brings work", "I'll loop in @morgan later". Drop the `@`. Every mention sends a notification; a mention nobody needs to act on is a false alarm. ### Callback Mentions diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d63f720c65..403512a322 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -3625,6 +3625,22 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("single-quoted shell strings preserve `\\n` literally")); assert!(prompt.contains("buzz messages send ... --content -")); } + + #[test] + fn shared_base_prompt_teaches_single_command_mentions_and_preflight() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("--mention ")); + assert!(prompt.contains("every presentation-only name that should notify")); + assert!( + prompt.contains("permits unresolved or ambiguous `@Name` text as presentation-only") + ); + assert!(prompt.contains("success JSON's `mention_pubkeys`")); + assert!(prompt.contains("no follow-up verification command is needed")); + assert!(prompt.contains("stops before sending")); + assert!(prompt + .contains("add them explicitly with `buzz channels add-member` only when authorized")); + assert!(prompt.contains("never changes membership automatically")); + } } fn default_heartbeat_prompt() -> String { diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 290cc59fa8..40a9ae80b5 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -9,8 +9,7 @@ use crate::validate::{ validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; use buzz_sdk::mentions::{ - extract_at_mentions_with_known, extract_nostr_uris, merge_mentions, strip_code_regions, - MENTION_CAP, + extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP, }; /// Extract the thread root event ID from a Nostr tag array. @@ -119,47 +118,82 @@ async fn resolve_channel_id(client: &BuzzClient, event_id: &str) -> Result>, + has_explicit_mentions: bool, +) -> Result, CliError> { + let mut resolved = Vec::new(); + for name in names { + match name_to_pubkeys + .get(name) + .map(Vec::as_slice) + .unwrap_or_default() + { + [pubkey] => resolved.push(pubkey.clone()), + [] if has_explicit_mentions => {} + [] => { + return Err(CliError::Usage(format!( + "mention '@{name}' does not match a current channel member; retry with --mention " + ))) + } + _ if has_explicit_mentions => {} + candidates => { + return Err(CliError::Usage(format!( + "mention '@{name}' is ambiguous; candidates: {}. Retry with --mention ", + candidates.join(", ") + ))) + } + } + } + Ok(resolved) +} + +/// Resolve mention text against the channel membership snapshot. /// -/// Queries kind 39002 (channel members) then kind 0 (profiles), parses -/// display names once, and feeds them to [`extract_at_mentions_with_known`] -/// for multi-word matching. On any I/O or parse failure, returns an empty -/// vec — auto-tagging is best-effort and must never block a send. +/// Returns both the current member set and uniquely name-resolved pubkeys. +/// Lookup failures are fatal when mention processing is requested: publishing +/// visible mention text without its intended `p` tag is worse than not sending. async fn resolve_content_mentions( client: &BuzzClient, channel_id: &str, content: &str, -) -> Vec { - if !content.contains('@') { - return vec![]; + has_explicit_mentions: bool, +) -> Result<(Vec, Vec), CliError> { + let stripped = strip_code_regions(content); + if !stripped.contains('@') && !has_explicit_mentions { + return Ok((vec![], vec![])); } - // 1. Membership list (kind 39002 is parameterized-replaceable, addressed by `d` tag). let members_filter = serde_json::json!({ "kinds": [39002], "#d": [channel_id], "limit": 1, }); - let member_pubkeys = match fetch_member_pubkeys(client, &members_filter).await { - Some(pks) if !pks.is_empty() => pks, - _ => return vec![], - }; + let member_pubkeys = fetch_member_pubkeys(client, &members_filter) + .await + .ok_or_else(|| { + CliError::Other("could not load channel membership for mention preflight".into()) + })?; + + if !stripped.contains('@') { + return Ok((member_pubkeys, vec![])); + } - // 2. Profiles for those members (kind 0). let profiles_filter = serde_json::json!({ "kinds": [0], "authors": member_pubkeys, "limit": member_pubkeys.len(), }); - let profile_events = match fetch_events(client, &profiles_filter).await { - Some(v) => v, - None => return vec![], - }; + let profile_events = fetch_events(client, &profiles_filter) + .await + .ok_or_else(|| { + CliError::Other("could not load member profiles for mention resolution".into()) + })?; - // 3. Single parse: extract (pubkey, display_name) pairs from profile JSON. let mut name_to_pubkeys: std::collections::HashMap> = std::collections::HashMap::new(); - let mut display_names: Vec = Vec::new(); + let mut display_names = Vec::new(); for e in &profile_events { let Some(pubkey) = e.get("pubkey").and_then(|v| v.as_str()) else { continue; @@ -178,26 +212,82 @@ async fn resolve_content_mentions( else { continue; }; - let lower = name.to_ascii_lowercase(); name_to_pubkeys - .entry(lower) + .entry(name.to_ascii_lowercase()) .or_default() .push(pubkey.to_string()); display_names.push(name.to_string()); } - // 4. Two-pass extraction: known multi-word names first, single-word fallback. - let known_refs: Vec<&str> = display_names.iter().map(|s| s.as_str()).collect(); - let names = extract_at_mentions_with_known(content, &known_refs); + let known_refs: Vec<&str> = display_names.iter().map(String::as_str).collect(); + let names = extract_at_mentions_with_known(&stripped, &known_refs); + let resolved = resolve_names_to_pubkeys(&names, &name_to_pubkeys, has_explicit_mentions)?; + Ok((member_pubkeys, resolved)) +} + +fn normalize_explicit_mentions(values: &[String]) -> Result, CliError> { + let mut normalized = Vec::new(); + for value in values { + let pubkey = PublicKey::parse(value.trim()) + .map_err(|_| CliError::Usage(format!("invalid --mention pubkey: {value}")))?; + let hex = pubkey.to_hex(); + if !normalized.contains(&hex) { + normalized.push(hex); + } + } + if normalized.len() > MENTION_CAP { + return Err(CliError::Usage(format!( + "too many --mention values (max {MENTION_CAP})" + ))); + } + Ok(normalized) +} + +fn merge_message_mentions( + explicit: &[String], + uri_pubkeys: &[String], + auto_resolved: &[String], +) -> Result, CliError> { + let mut mentions = Vec::new(); + for pubkey in explicit + .iter() + .chain(uri_pubkeys.iter()) + .chain(auto_resolved.iter()) + { + if !mentions.contains(pubkey) { + mentions.push(pubkey.clone()); + } + } + if mentions.len() > MENTION_CAP { + return Err(CliError::Usage(format!( + "too many unique message mentions (max {MENTION_CAP})" + ))); + } + Ok(mentions) +} - // 5. Look up matched names → pubkeys via the map we already built. - names +fn missing_members(mentions: &[String], members: &[String]) -> Vec { + let members: std::collections::HashSet<&str> = members.iter().map(String::as_str).collect(); + mentions .iter() - .flat_map(|n| name_to_pubkeys.get(n).into_iter().flatten()) + .filter(|pk| !members.contains(pk.as_str())) .cloned() .collect() } +fn event_mention_pubkeys(event: &nostr::Event) -> Vec { + event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("p")) + .then(|| parts.get(1).cloned()) + .flatten() + }) + .collect() +} + /// Fetch raw events for `filter` via the relay's `/query` endpoint. /// Returns `None` on any I/O or parse failure. async fn fetch_events( @@ -478,6 +568,7 @@ pub struct SendMessageParams { pub reply_to: Option, pub broadcast: bool, pub files: Vec, + pub mentions: Vec, } pub async fn cmd_send_message( @@ -495,6 +586,30 @@ pub async fn cmd_send_message( } let channel_uuid = parse_uuid(&p.channel_id)?; + let explicit_mentions = normalize_explicit_mentions(&p.mentions)?; + let stripped = strip_code_regions(&p.content); + let uri_pubkeys = extract_nostr_uris(&stripped); + // Supplying any identity explicitly authorizes unresolved or ambiguous @Name text + // as presentation-only, matching Desktop's separate visible-label and p-tag model. + // Uniquely resolvable member names still add their own p-tags; callers must supply + // every intended identity whose visible label cannot be resolved uniquely. + let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty(); + let (member_pubkeys, auto_resolved) = + resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?; + let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; + + let missing = missing_members(&mention_pubkeys, &member_pubkeys); + if !missing.is_empty() { + return Err(CliError::Usage( + serde_json::json!({ + "message": "mentioned pubkeys are not channel members; add them explicitly before retrying", + "missing_member_pubkeys": missing, + "add_member_command": format!("buzz channels add-member --channel {} --pubkey --role ", p.channel_id), + }) + .to_string(), + )); + } + // Upload files and build imeta tags let mut media_tags: Vec> = Vec::new(); let mut media_content = String::new(); @@ -526,16 +641,7 @@ pub async fn cmd_send_message( None }; - // Resolve @name mentions in the author-written body only — not the media markdown we - // append above, which is derived from upload metadata and can't carry `@names`. - let mut auto_resolved = resolve_content_mentions(client, &p.channel_id, &p.content).await; - - // NIP-27: also extract nostr:npub1… inline references (skipping code regions) - let stripped = strip_code_regions(&p.content); - let uri_pubkeys = extract_nostr_uris(&stripped); - merge_mentions(&mut auto_resolved, &uri_pubkeys, MENTION_CAP); - - let mention_refs: Vec<&str> = auto_resolved.iter().map(|s| s.as_str()).collect(); + let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); let builder = match p.kind { Some(45001) => { @@ -572,9 +678,17 @@ pub async fn cmd_send_message( }; let event = client.sign_event(builder)?; - + let emitted_mentions = event_mention_pubkeys(&event); let resp = client.submit_event(event).await?; - println!("{}", normalize_write_response(&resp)); + let mut output: serde_json::Value = serde_json::from_str(&normalize_write_response(&resp)) + .unwrap_or_else(|_| serde_json::json!({ "response": resp })); + if let Some(object) = output.as_object_mut() { + object.insert( + "mention_pubkeys".into(), + serde_json::json!(emitted_mentions), + ); + } + println!("{output}"); Ok(()) } @@ -765,6 +879,7 @@ pub async fn dispatch( reply_to, broadcast, files, + mentions, } => { cmd_send_message( client, @@ -775,6 +890,7 @@ pub async fn dispatch( reply_to, broadcast, files, + mentions, }, ) .await @@ -876,7 +992,11 @@ pub async fn dispatch( #[cfg(test)] mod tests { - use super::{find_root_from_tags, match_profiles_by_name, parse_member_pubkeys}; + use super::{ + event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, + missing_members, normalize_explicit_mentions, parse_member_pubkeys, + resolve_names_to_pubkeys, + }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, }; @@ -1103,6 +1223,94 @@ mod tests { assert_eq!(parse_member_pubkeys(&event), vec![PK_VALID_A, PK_VALID_A]); } + #[test] + fn explicit_mentions_accept_hex_and_npub_and_deduplicate() { + use nostr::ToBech32; + let npub = nostr::PublicKey::from_hex(PK_VALID_A) + .unwrap() + .to_bech32() + .unwrap(); + assert_eq!( + normalize_explicit_mentions(&[PK_VALID_A.into(), npub]).unwrap(), + vec![PK_VALID_A] + ); + assert!(normalize_explicit_mentions(&["not-a-key".into()]).is_err()); + } + + #[test] + fn explicit_mentions_authorize_presentation_text_without_name_resolution() { + let names = vec!["renamed user".into()]; + let profiles = std::collections::HashMap::new(); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + Vec::::new() + ); + assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err()); + } + + #[test] + fn explicit_mentions_authorize_ambiguous_presentation_text() { + let names = vec!["alice".into()]; + let profiles = std::collections::HashMap::from([( + "alice".into(), + vec![PK_VALID_A.into(), PK_VALID_B.into()], + )]); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + Vec::::new() + ); + let error = resolve_names_to_pubkeys(&names, &profiles, false).unwrap_err(); + assert!(error.to_string().contains(PK_VALID_A)); + assert!(error.to_string().contains(PK_VALID_B)); + } + + #[test] + fn explicit_mentions_make_all_at_names_presentation_only() { + let names = vec!["alice".into(), "bob".into()]; + let profiles = std::collections::HashMap::from([("alice".into(), vec![PK_VALID_A.into()])]); + assert_eq!( + resolve_names_to_pubkeys(&names, &profiles, true).unwrap(), + vec![PK_VALID_A] + ); + assert!(resolve_names_to_pubkeys(&names, &profiles, false).is_err()); + } + + #[test] + fn combined_mention_union_errors_instead_of_truncating() { + let explicit: Vec = (0..50).map(|i| format!("explicit-{i}")).collect(); + assert!(merge_message_mentions(&explicit, &[], &["resolved-bob".into()]).is_err()); + + let mut with_duplicate = explicit.clone(); + with_duplicate.push(explicit[0].clone()); + assert_eq!( + merge_message_mentions(&with_duplicate, &[explicit[1].clone()], &[]) + .unwrap() + .len(), + 50 + ); + } + + #[test] + fn membership_preflight_lists_only_missing_mentions() { + assert_eq!( + missing_members( + &[PK_VALID_A.into(), PK_VALID_B.into()], + &[PK_VALID_A.into()] + ), + vec![PK_VALID_B] + ); + } + + #[test] + fn mention_evidence_comes_from_signed_event_tags() { + use nostr::{EventBuilder, Keys, Tag}; + let event = EventBuilder::text_note("hello") + .tags(vec![Tag::parse(["p", PK_VALID_A]).unwrap()]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert_eq!(event_mention_pubkeys(&event), vec![PK_VALID_A]); + } + // ---- match_profiles_by_name (author resolution for `messages search --author`) ---- fn profile_event( diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 7465625804..df02c65be9 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -369,6 +369,9 @@ pub enum MessagesCmd { /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, + /// Pubkey to mention (hex or npub; repeatable). Supplying any explicit identity permits unresolved or ambiguous @Name text as presentation-only; uniquely resolved member names still notify. + #[arg(long = "mention")] + mentions: Vec, }, /// Send a code diff / patch to a channel SendDiff { diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index e13ab2baad..c8f008836d 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 4; +const NEST_SKILL_VERSION: u32 = 5; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index d2c415e725..031b049a49 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -29,6 +29,18 @@ fn init_nest_dir_prod_sets_buzz() { } } +#[test] +fn nest_skill_contains_safe_mention_workflow() { + assert!(BUZZ_CLI_SKILL_MD.contains("--mention ")); + assert!(BUZZ_CLI_SKILL_MD.contains("every presentation-only name that should notify")); + assert!(BUZZ_CLI_SKILL_MD + .contains("permits unresolved or ambiguous `@Name` text as presentation-only")); + assert!(BUZZ_CLI_SKILL_MD.contains("signed event's `mention_pubkeys`")); + assert!(BUZZ_CLI_SKILL_MD.contains("no follow-up verification command is needed")); + assert!(BUZZ_CLI_SKILL_MD.contains("Add membership separately only when authorized")); + assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); +} + #[test] fn ensure_nest_creates_all_dirs_and_agents_md() { let tmp = tempfile::tempdir().unwrap(); diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index fefdfa77fa..79a5ea301d 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -87,14 +87,11 @@ Write commands are unaffected. `--format json` (default) returns full fields. ## Communication Patterns -**Mentions that notify:** Use `@Name` directly in message content — the CLI auto-resolves channel members by name and adds the required p-tags. No `--mention` flag exists or is needed. `nostr:npub1…` inline references are also auto-resolved to p-tags without needing a flag. +**Mentions that notify:** Keep readable `@Name` text in message content and, when intended pubkeys are known, pass the identities in the same send with repeatable `--mention `. Any explicit identity (`--mention` or `nostr:npub...`) permits unresolved or ambiguous `@Name` text as presentation-only; uniquely resolved member names still add recipients. Include a pubkey for every presentation-only name that should notify. The CLI reports the signed event's `mention_pubkeys`; no follow-up verification command is needed. Without explicit identities, names resolve against current channel members. An unresolved/ambiguous name or non-member target stops before publishing. Add membership separately only when authorized, then retry; sending never changes membership automatically. ```bash -# ✅ Correct — notification delivered automatically -buzz messages send --channel --content "@Alice check this" - -# Multiple mentions — same pattern -buzz messages send --channel --content "@Alice @Bob review please" +buzz messages send --channel \ + --content "@Alice check this" --mention ``` ## DM Management From 581baa625400f72316776ba726f533735acf22db Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 29 Jul 2026 16:38:44 -0400 Subject: [PATCH 39/99] chore(ci): bump Linux AppImage build container to ubuntu:24.04 (#3602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What and why The Buzz AppImage is built on `ubuntu:22.04`, which links WebKitGTK against FreeType 2.11.1. Because `libfreetype.so.6` is on the linuxdeploy community excludelist, the bundled WebKit loads the **host's** FreeType at runtime instead of the bundled one. FreeType 2.13.0 (released 2023-02-09) added `FT_Bool read_variable` to `FT_ColorStopIterator`, growing the struct from 16 to 20 bytes. Any host running FreeType ≥ 2.13 (Fedora 42+, Ubuntu 24.04+) has a struct-layout mismatch with the 22.04-compiled WebKit. The mismatched offsets corrupt color-stop index arithmetic inside Skia's COLRv1 renderer, producing the assertion abort in issues #2548 and #2982: ``` stl_vector.h:1123: Assertion '__n < this->size()' failed. ... colrv1_configure_skpaint(FT_Face, ...) ... ``` ## Fix Bump the build container to `ubuntu:24.04` (noble), which ships FreeType **2.13.2**. Noble's struct layout matches every crash-affected host. The ABI mismatch disappears and the crash is eliminated at root. WebKitGTK also advances from **2.50.4** (jammy backport) to **2.52.3** (noble backport). ## Glibc floor change | Build base | glibc floor | Oldest supported AppImage distro | |---|---|---| | ubuntu:22.04 (before) | 2.35 | Ubuntu 22.04 LTS, Debian 12 | | ubuntu:24.04 (after) | 2.39 | Ubuntu 24.04 LTS, Fedora 40+ | Ubuntu 22.04 LTS and Debian 12 users lose AppImage support. Both distributions continue to receive first-class `.deb` / `.rpm` packages, which use the system WebKit and are unaffected. The crash-affected users (Fedora 42/44, Ubuntu 24.04+) all have glibc ≥ 2.39. ## Changes - `.github/workflows/linux-canary.yml:24` — container pin updated to `ubuntu:24.04@sha256:4fbb8e6a…` - `.github/workflows/release.yml:479` — same container pin updated - `.github/workflows/release.yml:501` — comment version string updated from 22.04 to 24.04 `fix-appimage.sh` and `desktop/src-tauri/**` are untouched. The #3573 fontconfig stopgap remains active; retirement is a separate follow-on PR once this fix is verified on a shipped build. ## Sequencing `docs/linux-rendering-troubleshooting.md` (introduced in #3573) will receive a glibc-floor callout section once #3573 merges — adding it here would conflict with #3573's open branch. Context: #2548, #2982. Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .github/workflows/linux-canary.yml | 2 +- .github/workflows/release.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 18d476e400..9806443378 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -21,7 +21,7 @@ jobs: name: Build Linux canary if: github.repository == 'block/buzz' runs-on: ubuntu-latest - container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 timeout-minutes: 60 permissions: contents: read diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c613924e57..b87e9c8c08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -476,7 +476,7 @@ jobs: if: github.repository == 'block/buzz' runs-on: ubuntu-latest # Digest-pinned like the SHA-pinned actions below; Renovate keeps it fresh. - container: ubuntu:22.04@sha256:0e0a0fc6d18feda9db1590da249ac93e8d5abfea8f4c3c0c849ce512b5ef8982 + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 needs: setup timeout-minutes: 60 permissions: @@ -498,7 +498,7 @@ jobs: env: DEBIAN_FRONTEND: noninteractive run: | - # Must run first: bare ubuntu:22.04 ships without curl, wget, git, or + # Must run first: bare ubuntu:24.04 ships without curl, wget, git, or # ca-certificates. activate-hermit bootstraps via curl+HTTPS (needs # both), and actions/checkout falls back to a REST tarball without git. # Running as root — no sudo needed. From 005b5b819a98ce85d4d80cd81b258fb6f9b8d51e Mon Sep 17 00:00:00 2001 From: Dave Grochowski Date: Wed, 29 Jul 2026 17:01:26 -0400 Subject: [PATCH 40/99] feat(tracing): correlate trace IDs in relay logs (#3608) ## Summary Correlates trace + span IDs with logs, allowing traces and logs to be bridged seamlessly ### Related issue none found ### Testing Unit tests Signed-off-by: David Grochowski Co-authored-by: Amp --- crates/buzz-relay/src/main.rs | 10 +- crates/buzz-relay/src/telemetry.rs | 362 ++++++++++++++++++++++++++++- 2 files changed, 368 insertions(+), 4 deletions(-) diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 3ed820d3c5..9e6ca828e0 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -102,6 +102,7 @@ async fn main() -> anyhow::Result<()> { // spans under the correct service identity. let resource = telemetry::service_resource(); let tracer_init = telemetry::try_init_tracer(resource.clone()); + let otel_enabled = matches!(&tracer_init, telemetry::TracerInit::Enabled(_)); let otel_layer = match &tracer_init { telemetry::TracerInit::Enabled(p) => { use opentelemetry::trace::TracerProvider as _; @@ -109,12 +110,18 @@ async fn main() -> anyhow::Result<()> { } _ => None, }; + let trace_context_lookup = telemetry::TraceContextLookup::default(); + let trace_context_lookup_layer = otel_enabled.then(|| { + trace_context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF) + }); tracing_subscriber::registry() .with( fmt::layer() .json() - .flatten_event(true) + .event_format(trace_context_lookup.json_formatter(otel_enabled)) .with_filter(log_env_filter(std::env::var("RUST_LOG").ok().as_deref())), ) .with(otel_layer.map(|layer| { @@ -122,6 +129,7 @@ async fn main() -> anyhow::Result<()> { std::env::var("BUZZ_OTEL_FILTER").ok().as_deref(), )) })) + .with(trace_context_lookup_layer) .init(); // Log any exporter-build failure now that the subscriber is installed. diff --git a/crates/buzz-relay/src/telemetry.rs b/crates/buzz-relay/src/telemetry.rs index 11c6d03512..91bd92f0f3 100644 --- a/crates/buzz-relay/src/telemetry.rs +++ b/crates/buzz-relay/src/telemetry.rs @@ -23,9 +23,157 @@ //! - `OTEL_TRACES_SAMPLER` (default: `parentbased_always_on`) //! - `OTEL_TRACES_SAMPLER_ARG` +use std::{ + fmt, + sync::{Arc, OnceLock}, +}; + +use opentelemetry::trace::{SpanId, TraceContextExt as _, TraceId}; use opentelemetry_otlp::ExporterBuildError; use opentelemetry_sdk::{resource::EnvResourceDetector, trace::SdkTracerProvider, Resource}; -use tracing_subscriber::EnvFilter; +use tracing::{Event, Subscriber}; +use tracing_subscriber::{ + fmt::{ + format::{Format, FormatEvent, FormatFields, Json, Writer}, + FmtContext, + }, + registry::LookupSpan, + EnvFilter, Layer, +}; + +/// Captures the subscriber dispatch used to resolve tracing span IDs to their +/// OpenTelemetry contexts. +#[derive(Clone, Default)] +pub struct TraceContextLookup { + dispatch: Arc>, +} + +impl TraceContextLookup { + /// Build a JSON formatter backed by this subscriber dispatch lookup. + pub fn json_formatter(&self, enabled: bool) -> TraceContextJson { + TraceContextJson { + inner: tracing_subscriber::fmt::format().json().flatten_event(true), + enabled, + context_lookup: self.clone(), + } + } + + fn nearest_otel_context(&self, span_id: &tracing::span::Id) -> Option { + let dispatch = self.dispatch.get()?.upgrade()?; + let registry = dispatch.downcast_ref::()?; + + let context = registry.span(span_id)?.scope().find_map(|span| { + let context = tracing_opentelemetry::get_otel_context(&span.id(), &dispatch)?; + context.span().span_context().is_valid().then_some(context) + }); + context + } +} + +impl Layer for TraceContextLookup { + fn on_register_dispatch(&self, subscriber: &tracing::Dispatch) { + let _ = self.dispatch.set(subscriber.downgrade()); + } +} + +/// JSON event formatter that adds the active OpenTelemetry trace context. +/// +/// Datadog recognizes the OpenTelemetry-standard `trace_id` and `span_id` +/// fields when they are lowercase hexadecimal strings. Events outside a valid +/// OpenTelemetry span retain the standard `tracing-subscriber` JSON format. +pub struct TraceContextJson { + inner: Format, + enabled: bool, + context_lookup: TraceContextLookup, +} + +struct CorrelationWriter<'writer> { + inner: Writer<'writer>, + trace_id: TraceId, + span_id: SpanId, + injected: bool, +} + +impl fmt::Write for CorrelationWriter<'_> { + fn write_str(&mut self, value: &str) -> fmt::Result { + if self.injected { + return self.inner.write_str(value); + } + + let Some(object_start) = value.find('{') else { + return self.inner.write_str(value); + }; + self.inner.write_str(&value[..=object_start])?; + write!( + self.inner, + "\"trace_id\":\"{}\",\"span_id\":\"{}\",", + self.trace_id, self.span_id + )?; + self.injected = true; + self.inner.write_str(&value[object_start + 1..]) + } +} + +impl FormatEvent for TraceContextJson +where + S: Subscriber + for<'lookup> LookupSpan<'lookup>, + N: for<'writer> FormatFields<'writer> + 'static, +{ + fn format_event( + &self, + ctx: &FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &Event<'_>, + ) -> fmt::Result { + if !self.enabled { + return self.inner.format_event(ctx, writer, event); + } + + let otel_context = match event.parent() { + Some(span_id) => self.context_lookup.nearest_otel_context(span_id), + None if event.is_contextual() => Some(opentelemetry::Context::current()), + None => None, + }; + let Some(otel_context) = otel_context else { + return self.inner.format_event(ctx, writer, event); + }; + let otel_span = otel_context.span(); + let span_context = otel_span.span_context(); + + if !span_context.is_valid() { + return self.inner.format_event(ctx, writer, event); + } + + let trace_id = span_context.trace_id(); + let span_id = span_context.span_id(); + + // Events may define fields with the correlation names themselves. In + // that uncommon case, overwrite them rather than emitting duplicate + // JSON keys. Preserve the allocation-free streaming path for ordinary + // events. + let fields = event.metadata().fields(); + if fields.field("trace_id").is_some() || fields.field("span_id").is_some() { + let mut json = String::new(); + self.inner + .format_event(ctx, Writer::new(&mut json), event)?; + let mut object: serde_json::Map = + serde_json::from_str(json.trim_end()).map_err(|_| fmt::Error)?; + object.insert("trace_id".into(), trace_id.to_string().into()); + object.insert("span_id".into(), span_id.to_string().into()); + writer.write_str(&serde_json::to_string(&object).map_err(|_| fmt::Error)?)?; + return writeln!(writer); + } + + let mut writer = CorrelationWriter { + inner: writer, + trace_id, + span_id, + injected: false, + }; + self.inner + .format_event(ctx, Writer::new(&mut writer), event) + } +} /// Build the filter for spans exported through OpenTelemetry. /// @@ -122,8 +270,13 @@ fn classify_exporter_result( #[cfg(test)] mod tests { use super::*; - use opentelemetry::KeyValue; - use std::sync::Mutex; + use opentelemetry::{trace::TracerProvider as _, KeyValue}; + use opentelemetry_sdk::trace::InMemorySpanExporter; + use std::{ + io, + sync::{Arc, Mutex}, + }; + use tracing_subscriber::prelude::*; // Env vars are process-global — serialize tests that mutate them to prevent // cross-test races when the suite runs with multiple threads. @@ -137,6 +290,209 @@ mod tests { .map(|(_, v)| v.to_string()) } + #[derive(Clone)] + struct CapturingWriter(Arc>>); + + impl io::Write for CapturingWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn trace_context_json_correlates_nested_span_logs() { + let output = Arc::new(Mutex::new(Vec::new())); + let output_writer = Arc::clone(&output); + let exporter = InMemorySpanExporter::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter.clone()) + .build(); + let tracer = provider.tracer("trace-context-json-test"); + let context_lookup = TraceContextLookup::default(); + + let subscriber = tracing_subscriber::registry() + .with( + tracing_subscriber::fmt::layer() + .json() + .event_format(context_lookup.json_formatter(true)) + .with_writer(move || CapturingWriter(Arc::clone(&output_writer))) + .with_filter(tracing_subscriber::filter::filter_fn(|metadata| { + metadata.target() != "stdout_filtered" + })), + ) + .with( + tracing_opentelemetry::layer() + .with_tracer(tracer) + .with_filter(tracing_subscriber::filter::filter_fn(|metadata| { + !matches!(metadata.target(), "filtered" | "otel_event_filtered") + })), + ) + .with( + context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF), + ); + + tracing::subscriber::with_default(subscriber, || { + let explicit = tracing::info_span!("explicit"); + let root = tracing::info_span!("root"); + root.in_scope(|| { + tracing::info!(answer = 42, "root event"); + tracing::info!( + trace_id = "event-provided-trace", + span_id = "event-provided-span", + "colliding-fields event" + ); + tracing::info!(parent: &explicit, "explicit-parent event"); + tracing::info!(parent: None, "explicit-root event"); + let child = tracing::info_span!("child"); + child.in_scope(|| tracing::info!("child event")); + + let filtered_child = tracing::info_span!(target: "filtered", "filtered-child"); + filtered_child.in_scope(|| tracing::info!("filtered-child event")); + tracing::info!( + parent: &filtered_child, + "explicit-filtered-child event" + ); + + let stdout_filtered_child = + tracing::info_span!(target: "stdout_filtered", "stdout-filtered-child"); + stdout_filtered_child.in_scope(|| tracing::info!("stdout-filtered-child event")); + + tracing::info!(target: "otel_event_filtered", "otel-filtered event"); + }); + let filtered = tracing::info_span!(target: "filtered", "filtered"); + filtered.in_scope(|| tracing::info!("filtered-span event")); + tracing::info!("unscoped event"); + }); + + provider.force_flush().unwrap(); + let spans = exporter.get_finished_spans().unwrap(); + let root = spans.iter().find(|span| span.name == "root").unwrap(); + let explicit = spans.iter().find(|span| span.name == "explicit").unwrap(); + let child = spans.iter().find(|span| span.name == "child").unwrap(); + let stdout_filtered_child = spans + .iter() + .find(|span| span.name == "stdout-filtered-child") + .unwrap(); + + let bytes = output.lock().unwrap().clone(); + let output = String::from_utf8(bytes).unwrap(); + let lines: Vec<&str> = output.lines().collect(); + let logs: Vec = lines + .iter() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(logs.len(), 11); + + assert_eq!(logs[0]["message"], "root event"); + assert_eq!(logs[0]["answer"], 42); + assert_eq!( + logs[0]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[0]["span_id"], root.span_context.span_id().to_string()); + assert_eq!(logs[0]["trace_id"].as_str().unwrap().len(), 32); + assert_eq!(logs[0]["span_id"].as_str().unwrap().len(), 16); + + assert_eq!(logs[1]["message"], "colliding-fields event"); + assert_eq!( + logs[1]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[1]["span_id"], root.span_context.span_id().to_string()); + assert_eq!(lines[1].matches("\"trace_id\":").count(), 1); + assert_eq!(lines[1].matches("\"span_id\":").count(), 1); + + assert_eq!(logs[2]["message"], "explicit-parent event"); + assert_eq!( + logs[2]["trace_id"], + explicit.span_context.trace_id().to_string() + ); + assert_eq!( + logs[2]["span_id"], + explicit.span_context.span_id().to_string() + ); + + assert_eq!(logs[3]["message"], "explicit-root event"); + assert!(logs[3].get("trace_id").is_none()); + assert!(logs[3].get("span_id").is_none()); + + assert_eq!(logs[4]["message"], "child event"); + assert_eq!( + logs[4]["trace_id"], + child.span_context.trace_id().to_string() + ); + assert_eq!(logs[4]["span_id"], child.span_context.span_id().to_string()); + assert_eq!(logs[0]["trace_id"], logs[4]["trace_id"]); + + assert_eq!(logs[5]["message"], "filtered-child event"); + assert_eq!( + logs[5]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[5]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[6]["message"], "explicit-filtered-child event"); + assert_eq!( + logs[6]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[6]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[7]["message"], "stdout-filtered-child event"); + assert_eq!( + logs[7]["trace_id"], + stdout_filtered_child.span_context.trace_id().to_string() + ); + assert_eq!( + logs[7]["span_id"], + stdout_filtered_child.span_context.span_id().to_string() + ); + + assert_eq!(logs[8]["message"], "otel-filtered event"); + assert_eq!( + logs[8]["trace_id"], + root.span_context.trace_id().to_string() + ); + assert_eq!(logs[8]["span_id"], root.span_context.span_id().to_string()); + + assert_eq!(logs[9]["message"], "filtered-span event"); + assert!(logs[9].get("trace_id").is_none()); + assert!(logs[9].get("span_id").is_none()); + + assert_eq!(logs[10]["message"], "unscoped event"); + assert!(logs[10].get("trace_id").is_none()); + assert!(logs[10].get("span_id").is_none()); + } + + #[test] + fn trace_context_lookup_does_not_enable_callsites() { + let context_lookup = TraceContextLookup::default(); + let subscriber = tracing_subscriber::registry().with( + context_lookup + .clone() + .with_filter(tracing_subscriber::filter::LevelFilter::OFF), + ); + + tracing::subscriber::with_default(subscriber, || { + assert!(context_lookup + .dispatch + .get() + .and_then(tracing::dispatcher::WeakDispatch::upgrade) + .is_some()); + assert!(!tracing::enabled!( + target: "trace_context_lookup_filter_test", + tracing::Level::ERROR + )); + }); + } + #[test] fn test_service_resource_default_when_env_unset() { let _guard = ENV_LOCK.lock().unwrap(); From 5aeed7c7a2f89f9ae201624ffd431ca12de624f9 Mon Sep 17 00:00:00 2001 From: Xule Lin <43122877+linxule@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:50:55 +0100 Subject: [PATCH 41/99] fix(desktop): discover bun-installed agent CLIs in ~/.bun/bin (#3343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `common_binary_paths()` probes mise shims, `~/.local/bin`, volta, asdf, and (further down `resolve_command_uncached`) nvm's default bin dir — but not bun's global bin directory, `~/.bun/bin`. bun's installer appends its bin dir to `~/.zshrc` / `~/.bashrc`, which are **interactive**-only. A login shell never sources them, so `find_via_login_shell()` can't recover the path either. That's the same failure mode already called out in this file for nvm: ```rust // Check nvm's default Node.js bin directory — nvm initializes via // ~/.zshrc (interactive) which is not loaded by a login shell, so // `node`, `npm`, and npm-global shims installed there are otherwise // invisible. ``` So for a GUI-launched desktop app, every rung of the resolution ladder misses a bun-installed CLI: 1. workspace dev dirs — no 2. `command_looks_like_path` — no, presets use bare names 3. Buzz-managed npm/node dirs — no 4. current process PATH — launchd's minimal PATH on a Finder launch 5. `find_via_login_shell` — `.zshrc` not sourced 6. `common_binary_paths()` — **`~/.bun/bin` absent** 7. nvm default bin — no This matters because bun is a common install route for the agent CLIs Buzz targets. Kimi Code in particular ships as an npm package (`@moonshot-ai/kimi-code`), so `bun add -g` puts it at `~/.bun/bin/kimi` — exactly where discovery doesn't look. ## Reproduction On macOS with `codex` and `kimi` installed via bun, launching Buzz from Finder: - Kimi Code shows **"CLI needed"** - both CLIs run fine in an interactive terminal Probing the way `find_via_login_shell` does, in a clean environment: ```console $ env -i HOME=$HOME /bin/zsh -l -c 'command -v -- codex; command -v -- kimi' (nothing) ``` Launching the app with the bun dir on PATH resolves both immediately: ```console $ env PATH="$HOME/.bun/bin:$PATH" /Applications/Buzz.app/Contents/MacOS/buzz-desktop ``` ## Change One entry appended to the home-relative list in `common_binary_paths()`. It goes **last** so it cannot shadow a directory that already resolves — the change can only add resolutions, never alter existing ones. ## Testing `cargo fmt --check` passes. I was not able to run the full `just ci` gate locally: `ring 0.17.14` fails to build in this environment against the macOS 26.2 SDK (`cc` error compiling `p256-nistz.c`), which is unrelated to this change. Relying on CI for the rest — the diff adds one `PathBuf` to an existing `Vec` and introduces no new API. ## Notes - Related to #3084, which adds `~/.kimi-code/bin` for the same class of GUI-launch discovery failure. That covers Kimi's standalone installer; this covers the bun/npm-global install route. They're complementary — I've left a note on that PR. - Only `~/.bun/bin` is added. bun's global packages live under `~/.bun/install/global/node_modules` but are symlinked into `~/.bun/bin`, so the single directory is sufficient. - Worth noting `~/.bun/bin` contains no `node`/`npm`/`npx`, so appending it can't shadow a system Node toolchain. Signed-off-by: Xule Lin <43122877+linxule@users.noreply.github.com> --- desktop/src-tauri/src/managed_agents/discovery.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index eecbf4de3e..c8a85be34a 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -19,7 +19,6 @@ const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/e const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; - fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); PATHS.get_or_init(|| { @@ -41,6 +40,7 @@ fn common_binary_paths() -> &'static [PathBuf] { home.join(".local/bin"), home.join(".volta/bin"), home.join(".asdf/shims"), + home.join(".bun/bin"), ]); } // Windows well-known dirs for npm global shims and standalone installer targets. From b18e559ae2fbbb0a064f59afbdf1e2675fe86ea3 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 29 Jul 2026 17:54:33 -0400 Subject: [PATCH 42/99] docs: add Linux rendering troubleshooting guide (#3573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds `docs/linux-rendering-troubleshooting.md` — the user-facing troubleshooting page for Linux rendering failures. ## What's in the doc **Crash: `colrv1_configure_skpaint` assertion abort (AppImage, Fedora 40+)** Root cause: the AppImage bundles WebKitGTK compiled against FreeType 2.11.1, but `libfreetype.so.6` is not bundled — WebKit loads the host's FreeType at runtime. FreeType 2.13.0 added a field to `FT_ColorStopIterator` (16 → 20 bytes); on hosts with FreeType ≥ 2.13 the struct-layout mismatch corrupts Skia's COLRv1 color-stop arithmetic, causing the assertion abort. Fix: upgrade to v0.5.2+ (build container bumped to `ubuntu:24.04` in [#3602](https://github.com/block/buzz/pull/3602)). Includes the glibc floor table (2.35 → 2.39) and `.deb`/`.rpm` guidance for Ubuntu 22.04 / Debian 12 users. A manual fontconfig workaround is preserved for users stuck on older AppImages. **Blank window / dmabuf renderer (NVIDIA, AppImage)** Covers the auto-fix shipped in v0.5.1 ([#3271](https://github.com/block/buzz/pull/3271)) and the `--safe-rendering` flag for cases where auto-detection misses. **AMD RDNA4 / transparent window ([#2643](https://github.com/block/buzz/issues/2643))** Documents the three-variable workaround verified by the reporter (`GDK_BACKEND=x11`, `WEBKIT_DISABLE_DMABUF_RENDERER=1`, `WEBKIT_SKIA_ENABLE_CPU_RENDERING=1`). Also includes a crash-log capture recipe and issue-filing checklist. Context: [#2548](https://github.com/block/buzz/issues/2548), [#2982](https://github.com/block/buzz/issues/2982), [#2643](https://github.com/block/buzz/issues/2643), [#2338](https://github.com/block/buzz/issues/2338). Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- docs/linux-rendering-troubleshooting.md | 135 ++++++++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/linux-rendering-troubleshooting.md diff --git a/docs/linux-rendering-troubleshooting.md b/docs/linux-rendering-troubleshooting.md new file mode 100644 index 0000000000..1e09ef1aca --- /dev/null +++ b/docs/linux-rendering-troubleshooting.md @@ -0,0 +1,135 @@ +# Linux Rendering Troubleshooting + +This guide covers the most common rendering failures on Linux and how to resolve them. It covers both the AppImage distribution and native package installs (`deb`, `rpm`). + +## Symptoms and fixes at a glance + +| Symptom | Likely cause | Fix | +|---------|-------------|-----| +| Blank or transparent window, then `SIGABRT` with `colrv1_configure_skpaint` in the output | COLRv1 color emoji font (AppImage only) | Upgrade to the latest AppImage (v0.5.2+) | +| Blank window on startup, no crash output | dmabuf renderer incompatibility (NVIDIA or AppImage) | `WEBKIT_DISABLE_DMABUF_RENDERER=1 ./Buzz.AppImage` or `--safe-rendering` | +| Blank window on any hardware, no crash output | Unknown GPU/driver combination | `--safe-rendering` flag (see below) | + +--- + +## Crash: `colrv1_configure_skpaint` assertion abort (AppImage) + +**Affected distributions:** Fedora 40+ and any distro shipping Google's Noto Color Emoji in COLRv1 format (`Noto-COLRv1.ttf`). Issues [#2548](https://github.com/block/buzz/issues/2548), [#2982](https://github.com/block/buzz/issues/2982). + +**Symptom:** Buzz starts, the window appears briefly (or stays blank), then the process aborts with output like: + +``` +././/include/c++/12/bits/stl_vector.h:1123: ... colrv1_configure_skpaint ...: +Assertion '__n < this->size()' failed. +``` + +**Root cause:** The AppImage bundles WebKitGTK compiled against FreeType 2.11.1 (Ubuntu 22.04's version), but `libfreetype.so.6` is not bundled — WebKit loads the host's FreeType at runtime instead. FreeType 2.13.0 (2023-02-09) added a field to `FT_ColorStopIterator`, growing the struct from 16 to 20 bytes. On Fedora 40+ hosts (FreeType ≥ 2.13), the struct-layout mismatch corrupts color-stop index arithmetic inside Skia's COLRv1 renderer, producing the assertion abort. + +**Fix:** Upgrade to the latest AppImage (v0.5.2+). The build container was bumped to `ubuntu:24.04` ([#3602](https://github.com/block/buzz/pull/3602)), which ships FreeType 2.13.2. The compiled layout now matches every crash-affected host (FreeType ≥ 2.13), eliminating the ABI mismatch. + +**AppImage glibc floor (v0.5.2+):** The `ubuntu:24.04` build raises the AppImage's minimum glibc requirement: + +| AppImage version | glibc floor | Oldest supported AppImage distro | +|---|---|---| +| v0.5.1 and earlier | 2.35 | Ubuntu 22.04 LTS, Debian 12 | +| v0.5.2+ | 2.39 | Ubuntu 24.04 LTS, Fedora 40+ | + +If you are on **Ubuntu 22.04 LTS or Debian 12**, upgrade to the latest **`.deb`/`.rpm`** package instead — native packages use the system WebKit and are unaffected by this change. + +**Workaround (before upgrading):** Add a fontconfig override that removes color-format fonts from Buzz's view: + +```bash +mkdir -p ~/.config/buzz-fontconfig +cat > ~/.config/buzz-fontconfig/fonts.conf <<'XML' + + + + /etc/fonts/fonts.conf + + + + true + + + + +XML +FONTCONFIG_FILE=~/.config/buzz-fontconfig/fonts.conf ./Buzz_*.AppImage +``` + +**Native packages (`deb`/`rpm`):** The COLRv1 crash ([#2548](https://github.com/block/buzz/issues/2548), [#2982](https://github.com/block/buzz/issues/2982)) is AppImage-only — native packages use the system WebKit, which has a consistent FreeType ABI, and are not affected. + +--- + +## Blank window on startup (no crash): dmabuf renderer + +**Affected hardware:** NVIDIA GPUs (proprietary and nouveau drivers) and AppImage installs on any GPU. Issue [#2338](https://github.com/block/buzz/issues/2338). + +**Symptom:** Buzz launches without any crash or assertion output, but the window is blank or invisible. The process is running (`ps aux | grep buzz`), but nothing renders. + +**Root cause:** WebKitGTK's dmabuf zero-copy buffer path is incompatible with some GPU/driver/compositor combinations. The WebKit child process silently fails to paint. + +**Fix (shipped automatically starting with the first release containing [#3271](https://github.com/block/buzz/pull/3271) (v0.5.1)):** Buzz sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` automatically before WebKit initializes when it detects an NVIDIA GPU (`/sys/class/drm` vendor ID `0x10de`) or when running as an AppImage. This restores a slightly slower shared-memory rendering path that works universally. + +**If automatic detection doesn't help (`--safe-rendering`):** Pass `--safe-rendering` to force both `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` for that launch: + +```bash +./Buzz_*.AppImage --safe-rendering +# or for a native install: +buzz-desktop --safe-rendering +``` + +`--safe-rendering` is a per-launch flag — it is not remembered between runs. If it fixes your issue, you can make it permanent by setting the env vars yourself: + +```bash +# ~/.bashrc or ~/.profile +export WEBKIT_DISABLE_DMABUF_RENDERER=1 +``` + +**Conflict detection:** If you set a WebKit variable in your environment and also pass `--safe-rendering`, Buzz will refuse to start and print exactly which variable conflicts. Unset the conflicting variable or drop the flag. + +--- + +## AMD RDNA4 / transparent window + +**Affected hardware:** AMD RDNA4 GPUs (RX 9000 series) with the `radv` driver. Issue [#2643](https://github.com/block/buzz/issues/2643). + +**Symptom:** The Buzz window is transparent or renders with graphical corruption on AMD RDNA4 hardware. + +**Workaround (verified by reporter):** Set these three variables before launching Buzz: + +```bash +export GDK_BACKEND=x11 +export WEBKIT_DISABLE_DMABUF_RENDERER=1 +export WEBKIT_SKIA_ENABLE_CPU_RENDERING=1 +./Buzz_*.AppImage +# or for native: +buzz-desktop +``` + +- `WEBKIT_SKIA_ENABLE_CPU_RENDERING=1` forces Skia to use CPU rendering, bypassing the RDNA4 Skia/radv paint failure. +- `GDK_BACKEND=x11` avoids the blank window that appears when running under a Plasma-Wayland compositor. +- `WEBKIT_DISABLE_DMABUF_RENDERER=1` prevents post-first-paint transparency from the dmabuf renderer. + +A dedicated fix for RDNA4 detection is being tracked in [#2643](https://github.com/block/buzz/issues/2643). + +--- + +## Diagnosing an unrecognised crash + +If none of the above match your situation: + +1. Run Buzz from a terminal and capture the output: + ```bash + ./Buzz_*.AppImage 2>&1 | tee buzz-crash.log + ``` + +2. Check for a core dump: + ```bash + coredumpctl list | tail + coredumpctl info + ``` + +3. Try `--safe-rendering` first — if it resolves the issue, it's a WebKit rendering incompatibility and the crash log will help narrow down which driver is involved. + +4. File a [new issue](https://github.com/block/buzz/issues/new) with your distro, GPU, driver version, and the terminal output. From 3e48f1b2365d326ee1c9582448d86a99b44ecd5d Mon Sep 17 00:00:00 2001 From: Wes Date: Wed, 29 Jul 2026 15:57:35 -0600 Subject: [PATCH 43/99] chore(release): release Buzz Desktop version 0.5.2 (#3624) ## Buzz Desktop release v0.5.2 ### Changes since v0.5.1: - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) - fix(desktop): deduplicate relay outage notification ([#3579](https://github.com/block/buzz/pull/3579)) ([`66e705492`](https://github.com/block/buzz/commit/66e7054928cc29395f828467c3e8c81b7408dd29)) - fix(desktop): reconcile thread arrivals at bottom ([#3585](https://github.com/block/buzz/pull/3585)) ([`b42a8d447`](https://github.com/block/buzz/commit/b42a8d447e3a2b85b2313dc4fdd123731fd8bba3)) - Improve emoji autocomplete matching ([#3571](https://github.com/block/buzz/pull/3571)) ([`259de6afb`](https://github.com/block/buzz/commit/259de6afbe0cc0d106e57ebdb2323064990e4122)) - Fix shared agent avatar import profiles ([#3578](https://github.com/block/buzz/pull/3578)) ([`324bd6b46`](https://github.com/block/buzz/commit/324bd6b464de5751e12abbd155376046ce3d2afc)) - Fix inline raster avatars in agent catalog ([#3581](https://github.com/block/buzz/pull/3581)) ([`7e9b77f72`](https://github.com/block/buzz/commit/7e9b77f72d82e019a99f074f1c9829be30c57ae1)) - feat(agent): make Gemini and MLflow-route models usable through databricks_v2 ([#3569](https://github.com/block/buzz/pull/3569)) ([`4a1ebf25c`](https://github.com/block/buzz/commit/4a1ebf25c782fc6a68f0a69e6f866f793a259a1f)) **To release:** merge this PR. The tag and build will happen automatically. Signed-off-by: Wes --- CHANGELOG.md | 11 +++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 956faa1ed3..d83087fc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## v0.5.2 + +- feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) +- fix(desktop): deduplicate relay outage notification ([#3579](https://github.com/block/buzz/pull/3579)) ([`66e705492`](https://github.com/block/buzz/commit/66e7054928cc29395f828467c3e8c81b7408dd29)) +- fix(desktop): reconcile thread arrivals at bottom ([#3585](https://github.com/block/buzz/pull/3585)) ([`b42a8d447`](https://github.com/block/buzz/commit/b42a8d447e3a2b85b2313dc4fdd123731fd8bba3)) +- Improve emoji autocomplete matching ([#3571](https://github.com/block/buzz/pull/3571)) ([`259de6afb`](https://github.com/block/buzz/commit/259de6afbe0cc0d106e57ebdb2323064990e4122)) +- Fix shared agent avatar import profiles ([#3578](https://github.com/block/buzz/pull/3578)) ([`324bd6b46`](https://github.com/block/buzz/commit/324bd6b464de5751e12abbd155376046ce3d2afc)) +- Fix inline raster avatars in agent catalog ([#3581](https://github.com/block/buzz/pull/3581)) ([`7e9b77f72`](https://github.com/block/buzz/commit/7e9b77f72d82e019a99f074f1c9829be30c57ae1)) +- feat(agent): make Gemini and MLflow-route models usable through databricks_v2 ([#3569](https://github.com/block/buzz/pull/3569)) ([`4a1ebf25c`](https://github.com/block/buzz/commit/4a1ebf25c782fc6a68f0a69e6f866f793a259a1f)) + + ## v0.5.1 - perf(desktop): move observer-feed archive and decrypt commands off main thread ([#3415](https://github.com/block/buzz/pull/3415)) ([`294c8c821`](https://github.com/block/buzz/commit/294c8c821de51442a8c384c0bdb66b1a10224ca0)) diff --git a/desktop/package.json b/desktop/package.json index 7943b949b9..2226a0cb12 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.1", + "version": "0.5.2", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d4f7a4a2d4..bf84cd0d33 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1010,7 +1010,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 8bb643fea3..7606e48ac6 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.5.1" +version = "0.5.2" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 85ad5c0b2d..2eba7815b2 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.1", + "version": "0.5.2", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From f95fdc1a102e17c6718a44323d9a2feaed702db7 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 29 Jul 2026 18:15:30 -0400 Subject: [PATCH 44/99] feat(agent,acp): wire provider total_tokens through NIP-AM publish chain (#3593) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Wires genuine provider-reported `total_tokens` through the full buzz-agent → buzz-acp publish chain so kind-44200 events carry real per-turn and cumulative totals for OpenAI-backed models, while preserving all existing behaviour for Anthropic and external harnesses (goose, claude-code). ## Why Live prod data showed 0 of 1,934 archived reports carry `totalTokens`. Both hardcoded `total_tokens: None` in `pool.rs` and the absent field in `buzz-agent`'s parser are root causes. This is the backend half of a two-track fix; the display-fallback half lands in [#2035](https://github.com/block/buzz/pull/2035). ## Changes **`crates/buzz-agent/src/types.rs`** - Added `total_tokens: Option` to `LlmResponse` with an explicit doc comment that NIP-AM forbids deriving it. - Added `TurnTotalState` enum (`Unseen | Exact(u64) | Unknown`) with `fold()` and `exact_value()` — the tri-state accumulator that distinguishes not-yet-observed from permanently poisoned. **`crates/buzz-agent/src/llm.rs`** - `parse_responses` and `parse_openai`: read `usage.total_tokens` from OpenAI Chat Completions (including Databricks routes) and the Responses API via `sum_usage`. - Anthropic: explicit `total_tokens: None` — no genuine total available; NIP-AM forbids summing categories. **`crates/buzz-agent/src/agent.rs`** - Added `turn_total_state: &'a mut TurnTotalState` to `RunCtx`. - Fold `response.total_tokens` into the accumulator after each usage-bearing response; non-usage-bearing responses (keepalive/stream frames) do not poison. **`crates/buzz-agent/src/lib.rs`** - Added `accumulated_total_state: TurnTotalState` to `Session` (default `Unseen`). - Per-turn state passed to `RunCtx`, folded into session cumulative after each turn. - Emits `accumulatedTotalTokens` in `usage_update` only when cumulative is `Exact(n)`. **`crates/buzz-acp/src/usage.rs`** - Added `accumulated_total_tokens: Option` (serde default) to `UsageUpdatePayload` — optional for goose compat. - Added `last_total: Option` to `SessionState`. - Added `turn_total_tokens` and `cumulative_total_tokens` to `TurnUsage` (field-local — never affect `delta_reliable`). - Derive turn-total delta only when prev and current are both `Some` and monotonic; absence, decrease, or no baseline leaves only the total delta null without touching input/output reliability. **`crates/buzz-acp/src/pool.rs`** - Replaced both hardcoded `total_tokens: None` in `publish_agent_turn_metric` with `usage.turn_total_tokens` and `usage.cumulative_total_tokens`. ## Tests 20 new tests across the four touched files: | File | Tests | |------|-------| | `types.rs` | `TurnTotalState` fold, accumulation, exact_value, default (7 tests) | | `llm.rs` | Chat present/absent, Responses present/absent, Anthropic always-None (5 tests) | | `usage.rs` | First turn no baseline, second-turn delta, cumulative decrease (field-local), current absent, goose-shaped deserialization, baseline absent (6 tests) | | `pool.rs` | Exact turn+cumulative mapping, null totals never derived (2 tests) | `cargo test -p buzz-acp -p buzz-agent` — all passing, 0 failures. ## Scope Boundary: `crates/buzz-agent/**` + `crates/buzz-acp/**` only. Desktop unchanged. `costUsd` explicitly out of scope. Related: [#2035](https://github.com/block/buzz/pull/2035) --------- Signed-off-by: Will Pfleger Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/pool.rs | 185 +++++++++++++++++++--- crates/buzz-acp/src/usage.rs | 203 ++++++++++++++++++++++++ crates/buzz-agent/src/agent.rs | 28 +++- crates/buzz-agent/src/lib.rs | 68 +++++--- crates/buzz-agent/src/llm.rs | 90 +++++++++++ crates/buzz-agent/src/types.rs | 230 ++++++++++++++++++++++++++++ crates/buzz-agent/tests/fake_llm.rs | 181 ++++++++++++++++++++++ 7 files changed, 940 insertions(+), 45 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 038f8a714c..d1e005cbcc 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3409,33 +3409,35 @@ fn acp_stop_to_core(r: &StopReason) -> buzz_core::agent_turn_metric::StopReason } } -/// Best-effort: build and publish a `kind:44200` NIP-AM agent turn metric event. +/// Build the `(turn, cumulative)` `TokenCounts` pair for a NIP-AM kind-44200 +/// payload from a completed `TurnUsage`. /// -/// Does nothing when `usage` is `None` (goose emitted no usage notification -/// for this turn) or when `owner_pubkey` is unconfigured (no NIP-AO identity). -/// Errors are logged at WARN and never surface to the caller — metric -/// publishing must never fail a turn. -async fn publish_agent_turn_metric( - ctx: &PromptContext, - usage: Option, - channel_id: Option, - session_id: &str, - turn_id: &str, - stop_reason: Option, +/// Extracted as a pure function so the mapping logic can be tested independently +/// of relay/crypto infrastructure. `publish_agent_turn_metric` is the only +/// production caller. +/// +/// - `turn` is `None` when `delta_reliable` is false; otherwise it carries the +/// per-turn i/o/total/cost deltas for this turn. +/// - `cumulative` always carries the session-aggregate i/o/cost totals. +/// `total_tokens` is `Some` only when the session accumulated a genuine +/// provider-reported total on every turn — never derived from i/o sums +/// (NIP-AM MUST NOT). +pub(crate) fn build_turn_metric_counts( + usage: &crate::usage::TurnUsage, +) -> ( + Option, + Option, ) { - use buzz_core::agent_turn_metric::{AgentTurnMetricPayload, TokenCounts}; - use nostr::{EventBuilder, Kind, Tag}; - - let (usage, owner_pk) = match (usage, ctx.agent_owner_pubkey.as_ref()) { - (Some(u), Some(pk)) => (u, pk), - _ => return, - }; + use buzz_core::agent_turn_metric::TokenCounts; let turn_counts = if usage.delta_reliable { Some(TokenCounts { input_tokens: usage.turn_input_tokens, output_tokens: usage.turn_output_tokens, - total_tokens: None, + // Field-local: present only when both the previous and current + // cumulative totals were available and monotonic. Never derived + // from input+output. + total_tokens: usage.turn_total_tokens, cost_usd: usage.turn_cost_usd, cache_read_tokens: None, cache_write_tokens: None, @@ -3450,11 +3452,40 @@ async fn publish_agent_turn_metric( let cumulative_counts = Some(TokenCounts { input_tokens: Some(usage.cumulative_input_tokens), output_tokens: Some(usage.cumulative_output_tokens), - total_tokens: None, + // Present when every turn in the session reported a genuine provider + // total. None when the session has never emitted one or any turn lacked + // one. Never derived from input+output (NIP-AM MUST NOT). + total_tokens: usage.cumulative_total_tokens, cost_usd: usage.cumulative_cost_usd, cache_read_tokens: None, cache_write_tokens: None, }); + (turn_counts, cumulative_counts) +} + +/// Best-effort: build and publish a `kind:44200` NIP-AM agent turn metric event. +/// +/// Does nothing when `usage` is `None` (goose emitted no usage notification +/// for this turn) or when `owner_pubkey` is unconfigured (no NIP-AO identity). +/// Errors are logged at WARN and never surface to the caller — metric +/// publishing must never fail a turn. +async fn publish_agent_turn_metric( + ctx: &PromptContext, + usage: Option, + channel_id: Option, + session_id: &str, + turn_id: &str, + stop_reason: Option, +) { + use buzz_core::agent_turn_metric::AgentTurnMetricPayload; + use nostr::{EventBuilder, Kind, Tag}; + + let (usage, owner_pk) = match (usage, ctx.agent_owner_pubkey.as_ref()) { + (Some(u), Some(pk)) => (u, pk), + _ => return, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); let payload = AgentTurnMetricPayload { harness: ctx.harness_name.clone(), @@ -5238,9 +5269,11 @@ mod tests { delta_reliable: true, turn_input_tokens: Some(100), turn_output_tokens: Some(50), + turn_total_tokens: None, turn_cost_usd: None, cumulative_input_tokens: 100, cumulative_output_tokens: 50, + cumulative_total_tokens: None, cumulative_cost_usd: None, model: None, }; @@ -5270,9 +5303,11 @@ mod tests { delta_reliable: true, turn_input_tokens: Some(200), turn_output_tokens: Some(80), + turn_total_tokens: None, turn_cost_usd: Some(0.001), cumulative_input_tokens: 200, cumulative_output_tokens: 80, + cumulative_total_tokens: None, cumulative_cost_usd: Some(0.001), model: None, }; @@ -5303,9 +5338,11 @@ mod tests { delta_reliable: true, turn_input_tokens: Some(50), turn_output_tokens: Some(20), + turn_total_tokens: None, turn_cost_usd: None, cumulative_input_tokens: 150, cumulative_output_tokens: 70, + cumulative_total_tokens: None, cumulative_cost_usd: None, model: None, }; @@ -5336,9 +5373,11 @@ mod tests { delta_reliable: false, // first turn from buzz-agent turn_input_tokens: None, turn_output_tokens: None, + turn_total_tokens: None, turn_cost_usd: None, cumulative_input_tokens: 400, cumulative_output_tokens: 100, + cumulative_total_tokens: None, cumulative_cost_usd: None, model: None, }; @@ -5354,6 +5393,110 @@ mod tests { .await; } + /// `build_turn_metric_counts` maps exact turn and cumulative totals from + /// `TurnUsage` to the corresponding `TokenCounts.total_tokens` fields. + /// Reverting the production fields at the call site to `None` would break + /// this test; the test constrains the real code path. + #[test] + fn test_build_turn_metric_counts_exact_totals_map_through() { + let usage = crate::usage::TurnUsage { + session_id: "sess-total".to_string(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(100), + turn_output_tokens: Some(30), + turn_total_tokens: Some(130), // genuine per-turn total + turn_cost_usd: None, + cumulative_input_tokens: 500, + cumulative_output_tokens: 120, + cumulative_total_tokens: Some(620), // genuine cumulative total + cumulative_cost_usd: None, + model: None, + }; + + let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); + + // Serialise to JSON — this is what ultimately goes on the wire. + let turn_json = serde_json::to_value(turn.as_ref().expect("turn counts present")).unwrap(); + let cum_json = + serde_json::to_value(cumulative.as_ref().expect("cumulative counts present")).unwrap(); + + // Per-turn total must be the genuine provider-reported value. + assert_eq!( + turn_json["totalTokens"], + serde_json::json!(130), + "per-turn total must map to TokenCounts.totalTokens in wire JSON" + ); + assert_eq!(turn_json["inputTokens"], serde_json::json!(100)); + assert_eq!(turn_json["outputTokens"], serde_json::json!(30)); + + // Cumulative total must be the genuine session total. + assert_eq!( + cum_json["totalTokens"], + serde_json::json!(620), + "cumulative total must map to TokenCounts.totalTokens in wire JSON" + ); + assert_eq!(cum_json["inputTokens"], serde_json::json!(500)); + assert_eq!(cum_json["outputTokens"], serde_json::json!(120)); + } + + /// When totals are absent, `build_turn_metric_counts` must produce null + /// `total_tokens` — never a derived input+output sum (NIP-AM MUST NOT). + /// Reverting the production fields to hardcoded `None` would leave this test + /// passing but input/output would disagree, making the null-path detectable. + #[test] + fn test_build_turn_metric_counts_null_totals_never_derived() { + let usage = crate::usage::TurnUsage { + session_id: "sess-nototal".to_string(), + turn_seq: 1, + delta_reliable: true, + turn_input_tokens: Some(200), + turn_output_tokens: Some(60), + turn_total_tokens: None, // provider did not supply a total + turn_cost_usd: None, + cumulative_input_tokens: 200, + cumulative_output_tokens: 60, + cumulative_total_tokens: None, // session has no total + cumulative_cost_usd: None, + model: None, + }; + + let (turn, cumulative) = crate::pool::build_turn_metric_counts(&usage); + + let turn_json = serde_json::to_value(turn.as_ref().expect("turn counts present")).unwrap(); + let cum_json = + serde_json::to_value(cumulative.as_ref().expect("cumulative counts present")).unwrap(); + + // total_tokens must be null in the wire JSON. + assert!( + turn_json["totalTokens"].is_null(), + "absent turn total must serialize as null — not derived from in+out" + ); + assert!( + cum_json["totalTokens"].is_null(), + "absent cumulative total must serialize as null — not derived from in+out" + ); + + // Input/output must still carry their real values. + assert_eq!( + turn_json["inputTokens"], + serde_json::json!(200), + "inputTokens must be present even when total is absent" + ); + assert_eq!( + turn_json["outputTokens"], + serde_json::json!(60), + "outputTokens must be present even when total is absent" + ); + + // The null total must not equal the input+output sum — it must be genuinely null. + let derived_sum = serde_json::json!(200u64 + 60u64); + assert_ne!( + turn_json["totalTokens"], derived_sum, + "total_tokens must never equal input+output when provider omitted it" + ); + } + fn make_prompt_context_no_owner() -> PromptContext { let agent_keys = nostr::Keys::generate(); make_prompt_context_impl(&agent_keys, None) diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 8cca9c96f8..1629eee935 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -92,6 +92,14 @@ pub(crate) struct UsageUpdatePayload { #[serde(default)] pub accumulated_cached_input_tokens: u64, pub accumulated_cost: Option, + /// Session-cumulative genuine provider total tokens. Optional — only + /// emitted by buzz-agent when every turn in the session so far supplied a + /// provider-reported total. Absent for goose (field ignore-if-absent for + /// backward compat), for Anthropic-backed turns, and for sessions where any + /// turn lacked a provider total. NIP-AM forbids deriving this by summing + /// categories, so the UI must approximate when this field is absent. + #[serde(default)] + pub accumulated_total_tokens: Option, /// Effective model id for this turn. Optional — goose payloads that /// predate this field deserialize cleanly as `None`. #[serde(default)] @@ -113,6 +121,10 @@ struct SessionState { last_output: u64, /// Cumulative cost at the end of the LAST PUBLISHED turn. last_cost: Option, + /// Cumulative total tokens at the end of the LAST PUBLISHED turn. + /// `None` when the session has never emitted a provider total (Unseen) or + /// when any prior turn lacked one (poisoned). + last_total: Option, } /// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing. @@ -131,6 +143,11 @@ pub struct TurnUsage { pub turn_input_tokens: Option, /// Per-turn output token delta; `None` when unreliable. pub turn_output_tokens: Option, + /// Per-turn total token delta; `None` when the cumulative total is + /// unavailable (no baseline, non-monotonic, or either snapshot was absent). + /// Field-local: a missing total never flips `delta_reliable` or invalidates + /// `turn_input_tokens`/`turn_output_tokens`. + pub turn_total_tokens: Option, /// Per-turn cost delta (`current − previous`); `None` when unreliable or /// either snapshot is missing. pub turn_cost_usd: Option, @@ -138,6 +155,9 @@ pub struct TurnUsage { pub cumulative_input_tokens: u64, /// Session-cumulative output tokens as reported by goose at end of turn. pub cumulative_output_tokens: u64, + /// Session-cumulative genuine provider total tokens as reported by buzz-agent; + /// `None` when the session has never emitted one or any turn lacked one. + pub cumulative_total_tokens: Option, /// Session-cumulative estimated cost in USD; `None` if goose did not report it. pub cumulative_cost_usd: Option, /// Effective model id for this turn (maps to NIP-AM `model`). `None` if the @@ -218,6 +238,7 @@ impl UsageTracker { let current_input = payload.accumulated_input_tokens; let current_output = payload.accumulated_output_tokens; let current_cost = payload.accumulated_cost; + let current_total = payload.accumulated_total_tokens; // Determine whether this session is currently in-flight so we know // whether to set `pending`. We compute the delta regardless so that @@ -262,6 +283,17 @@ impl UsageTracker { } }; + // Total-token delta: field-local — never affects `delta_reliable` or + // the input/output deltas. Null when: no baseline exists, either + // snapshot is absent, or cumulative total decreased. + let turn_total = match self.sessions.get(session_id) { + Some(prev) => match (current_total, prev.last_total) { + (Some(cur), Some(p)) if cur >= p => Some(cur - p), + _ => None, // no baseline, absent on either side, or decrease + }, + None => None, // no baseline yet + }; + if is_in_flight { // In-flight-match: update pending with the latest cumulative values. // Baseline is NOT advanced here — it advances only on take(). @@ -271,9 +303,11 @@ impl UsageTracker { delta_reliable, turn_input_tokens: turn_input, turn_output_tokens: turn_output, + turn_total_tokens: turn_total, turn_cost_usd: turn_cost, cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, + cumulative_total_tokens: current_total, cumulative_cost_usd: current_cost, model: payload.model.clone(), }); @@ -292,6 +326,7 @@ impl UsageTracker { last_input: current_input, last_output: current_output, last_cost: current_cost, + last_total: current_total, }, ); } @@ -319,6 +354,7 @@ impl UsageTracker { last_input: record.cumulative_input_tokens, last_output: record.cumulative_output_tokens, last_cost: record.cumulative_cost_usd, + last_total: record.cumulative_total_tokens, }, ); Some(record) @@ -368,6 +404,7 @@ mod tests { accumulated_output_tokens: output, accumulated_cached_input_tokens: 0, accumulated_cost: cost, + accumulated_total_tokens: None, model: None, } } @@ -380,6 +417,7 @@ mod tests { accumulated_output_tokens: output, accumulated_cached_input_tokens: 0, accumulated_cost: cost, + accumulated_total_tokens: None, model: None, } } @@ -877,6 +915,7 @@ mod tests { accumulated_output_tokens: output, accumulated_cached_input_tokens: 0, accumulated_cost: cost, + accumulated_total_tokens: None, model: model.map(str::to_string), } } @@ -929,4 +968,168 @@ mod tests { "TurnUsage.model must be None when payload omits the field" ); } + + // ── accumulatedTotalTokens: field-local delta, session poisoning ─────── + + fn payload_with_total(input: u64, output: u64, total: Option) -> UsageUpdatePayload { + UsageUpdatePayload { + used: input + output, + context_limit: 200_000, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: 0, + accumulated_cost: None, + accumulated_total_tokens: total, + model: None, + } + } + + #[test] + fn first_update_without_baseline_turn_total_is_none() { + // No baseline exists → turn total null, but delta_reliable/input/output + // follow the normal first-turn rule (delta_reliable = false). + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-t1"); + tracker.record("sess-t1", &payload_with_total(100, 20, Some(120))); + let usage = tracker.take().expect("pending"); + + assert!(!usage.delta_reliable, "first turn: delta unreliable"); + assert!( + usage.turn_total_tokens.is_none(), + "no baseline → turn total must be None" + ); + assert_eq!( + usage.cumulative_total_tokens, + Some(120), + "cumulative total passes through even on first turn" + ); + } + + #[test] + fn second_turn_with_totals_produces_turn_delta() { + let mut tracker = UsageTracker::default(); + // Turn 1 — establish baseline. + tracker.begin_turn("sess-t2"); + tracker.record("sess-t2", &payload_with_total(100, 20, Some(120))); + let _ = tracker.take(); + + // Turn 2 — delta is computable. + tracker.begin_turn("sess-t2"); + tracker.record("sess-t2", &payload_with_total(200, 50, Some(250))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!(usage.turn_total_tokens, Some(130)); // 250 - 120 + assert_eq!(usage.cumulative_total_tokens, Some(250)); + } + + #[test] + fn cumulative_total_decrease_leaves_turn_total_null_without_affecting_reliability() { + // Cumulative total decreases (e.g. counter reset) → turn total null, + // but delta_reliable and input/output are NOT affected (field-local). + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-t3"); + tracker.record("sess-t3", &payload_with_total(500, 100, Some(600))); + let _ = tracker.take(); + + tracker.begin_turn("sess-t3"); + // Cumulative total decreased: 600 → 50. + tracker.record("sess-t3", &payload_with_total(600, 150, Some(50))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "input/output decrease would flip reliability; total decrease must not" + ); + assert_eq!(usage.turn_input_tokens, Some(100)); + assert_eq!(usage.turn_output_tokens, Some(50)); + assert!( + usage.turn_total_tokens.is_none(), + "cumulative total decrease → turn total null (field-local)" + ); + assert_eq!( + usage.cumulative_total_tokens, + Some(50), + "cumulative total from payload still passes through" + ); + } + + #[test] + fn cumulative_total_absent_on_current_turn_leaves_turn_total_null() { + // Goose-shaped payload: no accumulatedTotalTokens field at all. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-t4"); + tracker.record("sess-t4", &payload_with_total(100, 20, Some(120))); + let _ = tracker.take(); + + // Second turn: goose omits the total field entirely. + tracker.begin_turn("sess-t4"); + tracker.record("sess-t4", &payload_with_total(200, 50, None)); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable, "input/output delta unaffected"); + assert_eq!(usage.turn_input_tokens, Some(100)); + assert_eq!(usage.turn_output_tokens, Some(30)); + assert!( + usage.turn_total_tokens.is_none(), + "absent field → null turn total" + ); + assert!( + usage.cumulative_total_tokens.is_none(), + "absent cumulative total passes through as None" + ); + } + + #[test] + fn goose_shaped_payload_without_accumulated_total_deserializes_correctly() { + // goose payloads lack accumulatedTotalTokens; the field must default + // to None without a deserialization error (ignore-if-absent contract). + let json = r#"{ + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 1000, + "accumulatedOutputTokens": 200, + "accumulatedCost": 0.01 + }"#; + let variant: GooseSessionUpdateVariant = + serde_json::from_str(json).expect("must deserialize without accumulatedTotalTokens"); + let payload = match variant { + GooseSessionUpdateVariant::UsageUpdate(p) => p, + _ => panic!("expected UsageUpdate"), + }; + assert!( + payload.accumulated_total_tokens.is_none(), + "absent accumulatedTotalTokens must default to None" + ); + + // And it must flow through the tracker correctly. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-goose-nototal"); + tracker.record("sess-goose-nototal", &payload); + let usage = tracker.take().expect("pending"); + assert!( + usage.cumulative_total_tokens.is_none(), + "goose-shaped payload must produce None cumulative_total_tokens" + ); + } + + #[test] + fn cumulative_total_absent_on_baseline_leaves_turn_total_null_on_second_turn() { + // Baseline was set without a total (e.g. first goose turn); second + // turn reports a total. No baseline to diff against → turn total None. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-t5"); + tracker.record("sess-t5", &payload_with_total(100, 20, None)); // no total + let _ = tracker.take(); + + tracker.begin_turn("sess-t5"); + tracker.record("sess-t5", &payload_with_total(200, 50, Some(250))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable, "input/output delta unaffected"); + assert!( + usage.turn_total_tokens.is_none(), + "absent baseline total → turn total null even when current has a total" + ); + assert_eq!(usage.cumulative_total_tokens, Some(250)); + } } diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index ed04daca2c..ce4d2db4b3 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -14,7 +14,7 @@ use crate::mcp::ResultBudget; use crate::types::{ AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult, - ToolResultContent, + ToolResultContent, TurnTotalState, }; use crate::wire::{self, WireSender}; @@ -65,6 +65,17 @@ pub struct RunCtx<'a> { /// Consumers price this slice at the provider's cached rate; without it /// every round of a growing conversation is billed at full price. pub turn_cached_input_tokens: &'a mut Option, + /// Tri-state total-token accumulator for this turn. + /// + /// - `Unseen`: no usage-bearing response observed yet this turn (initial state). + /// - `Exact(n)`: every usage-bearing response so far reported a genuine + /// provider total; `n` is their sum. + /// - `Unknown`: at least one usage-bearing response lacked a provider total; + /// this turn can never produce a reliable total. + /// + /// Reset to `Unseen` at turn start in `run()`. Callers must not derive a + /// total by summing input+output — that is the UI display approximation only. + pub turn_total_state: &'a mut TurnTotalState, } impl RunCtx<'_> { @@ -84,6 +95,7 @@ impl RunCtx<'_> { *self.turn_input_tokens = None; *self.turn_output_tokens = None; *self.turn_cached_input_tokens = None; + *self.turn_total_state = TurnTotalState::Unseen; let mut round = 0u32; // Per-prompt `_Stop` objection count. Bounded per prompt (not per @@ -192,6 +204,20 @@ impl RunCtx<'_> { .saturating_add(cached), ); } + // Fold the provider-reported total into the turn tri-state, but only + // when this response was usage-bearing (had input or output tokens). + // A response with no usage at all is not evidence of a missing total + // and must not poison the accumulator. + // + // Shape assumption: documented OpenAI-compatible responses that carry + // `total_tokens` always co-report at least one of `prompt_tokens` / + // `completion_tokens`. A response that supplies only `total_tokens` + // with neither category is therefore not a supported shape and would + // be silently ignored here. If that shape is ever encountered, extend + // this gate rather than representing absent categories as zero. + if response.input_tokens.is_some() || response.output_tokens.is_some() { + *self.turn_total_state = self.turn_total_state.fold(response.total_tokens); + } if !response.reasoning.is_empty() { wire::send( diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 6745dd0f92..9a45bf4c98 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -105,6 +105,14 @@ struct Session { /// it so a consumer can price the cached slice at the provider's discounted /// rate instead of assuming every input token cost full price. accumulated_cached_input_tokens: u64, + /// Session-cumulative total-token state across all turns. + /// + /// Mirrors the per-turn `TurnTotalState` tri-state: starts `Unseen`, + /// becomes `Exact(n)` as turns with genuine provider totals complete, + /// transitions permanently to `Unknown` when any turn lacks a total or + /// when the cumulative would otherwise decrease. Only emitted in the + /// `usage_update` notification when `Exact`. + accumulated_total_state: crate::types::TurnTotalState, } fn die(msg: String) -> ! { @@ -432,6 +440,7 @@ async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSen accumulated_input_tokens: 0, accumulated_output_tokens: 0, accumulated_cached_input_tokens: 0, + accumulated_total_state: crate::types::TurnTotalState::Unseen, }, ); drop(sessions); @@ -679,6 +688,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender let mut turn_input_tokens: Option = None; let mut turn_output_tokens: Option = None; let mut turn_cached_input_tokens: Option = None; + let mut turn_total_state = crate::types::TurnTotalState::Unseen; let mut ctx = RunCtx { cfg: &app.cfg, effective_model: effective_model_str, @@ -698,6 +708,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender turn_input_tokens: &mut turn_input_tokens, turn_output_tokens: &mut turn_output_tokens, turn_cached_input_tokens: &mut turn_cached_input_tokens, + turn_total_state: &mut turn_total_state, }; let result = ctx.run(p.prompt).await; if let Some(s) = app.sessions.lock().await.get_mut(&sid) { @@ -733,10 +744,18 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender s.accumulated_cached_input_tokens = s .accumulated_cached_input_tokens .saturating_add(turn_cached_input_tokens.unwrap_or(0)); + // Fold the per-turn total state into the session cumulative. + // Unknown poisons the session permanently; Exact adds to running sum; + // Unseen (turn emitted no usage) leaves the cumulative unchanged. + // Uses TurnTotalState::merge_session, which applies the same + // checked-add / overflow-poisons contract as the per-response fold. + s.accumulated_total_state = + s.accumulated_total_state.merge_session(turn_total_state); Some(( s.accumulated_input_tokens, s.accumulated_output_tokens, s.accumulated_cached_input_tokens, + s.accumulated_total_state, )) } else { // Session is gone — the accumulated baseline no longer exists, so @@ -744,29 +763,32 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender None } }; - if let Some((accumulated_in, accumulated_out, accumulated_cached)) = accumulated { - wire::send( - &wire_tx, - goose_session_update( - &sid, - json!({ - "sessionUpdate": "usage_update", - // used: total tokens as a context-usage proxy; - // contextLimit: 0 (buzz-agent has no context limit tracking). - "used": accumulated_in.saturating_add(accumulated_out), - "contextLimit": 0u64, - "accumulatedInputTokens": accumulated_in, - "accumulatedOutputTokens": accumulated_out, - // A subset of accumulatedInputTokens, not an addition to - // it. Extends goose's usage_update shape; a consumer that - // does not know the field ignores it and prices exactly as - // it did before. - "accumulatedCachedInputTokens": accumulated_cached, - "model": effective_model_str, - }), - ), - ) - .await; + if let Some((accumulated_in, accumulated_out, accumulated_cached, accumulated_total)) = + accumulated + { + // Build the usage_update payload. `accumulatedTotalTokens` is only + // included when the cumulative is exactly known — never when Unseen + // (no total ever observed) or Unknown (at least one turn lacked a + // total). A goose consumer that doesn't recognise the field ignores it. + let mut update = serde_json::json!({ + "sessionUpdate": "usage_update", + // used: total tokens as a context-usage proxy; + // contextLimit: 0 (buzz-agent has no context limit tracking). + "used": accumulated_in.saturating_add(accumulated_out), + "contextLimit": 0u64, + "accumulatedInputTokens": accumulated_in, + "accumulatedOutputTokens": accumulated_out, + // A subset of accumulatedInputTokens, not an addition to + // it. Extends goose's usage_update shape; a consumer that + // does not know the field ignores it and prices exactly as + // it did before. + "accumulatedCachedInputTokens": accumulated_cached, + "model": effective_model_str, + }); + if let crate::types::TurnTotalState::Exact(total) = accumulated_total { + update["accumulatedTotalTokens"] = serde_json::json!(total); + } + wire::send(&wire_tx, goose_session_update(&sid, update)).await; } } match result { diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 22d3f8b73e..98b881eb45 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -1195,6 +1195,9 @@ fn parse_responses(v: Value) -> Result { &["cache_read_input_tokens"], &[("input_tokens_details", "cached_tokens")], ); + // Responses API reports a genuine provider total. Read it directly — + // never derived, so it stays None when the provider omits it. + let total_tokens = sum_usage(&v, &["total_tokens"]); Ok(LlmResponse { text, tool_calls, @@ -1202,6 +1205,7 @@ fn parse_responses(v: Value) -> Result { input_tokens, cached_input_tokens, output_tokens, + total_tokens, reasoning, }) } @@ -1434,6 +1438,9 @@ fn parse_anthropic(v: Value) -> Result { input_tokens, cached_input_tokens, output_tokens, + // Anthropic reports only category counts; NIP-AM forbids deriving a + // total from them. Always None for this provider. + total_tokens: None, reasoning, }) } @@ -1498,6 +1505,9 @@ fn parse_openai(v: Value) -> Result { let input_tokens = openai_chat_input_tokens(&v); let output_tokens = sum_usage(&v, &["completion_tokens"]); let cached_input_tokens = openai_chat_cached_tokens(&v); + // OpenAI Chat Completions reports a genuine provider total. Read it + // directly — never derived, so it stays None when the provider omits it. + let total_tokens = sum_usage(&v, &["total_tokens"]); Ok(LlmResponse { text, tool_calls, @@ -1505,6 +1515,7 @@ fn parse_openai(v: Value) -> Result { input_tokens, cached_input_tokens, output_tokens, + total_tokens, reasoning, }) } @@ -4072,6 +4083,85 @@ mod tests { assert_eq!(parse_openai(v).unwrap().input_tokens, None); } + // ── total_tokens parsing ─────────────────────────────────────────────── + + #[test] + fn parse_openai_chat_total_tokens_present_is_read() { + // Chat Completions: `usage.total_tokens` is a genuine provider total. + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "ok"}}], + "usage": {"prompt_tokens": 100, "completion_tokens": 25, "total_tokens": 125} + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.total_tokens, Some(125)); + assert_eq!(r.input_tokens, Some(100)); + assert_eq!(r.output_tokens, Some(25)); + } + + #[test] + fn parse_openai_chat_total_tokens_absent_is_none() { + // Chat Completions without `total_tokens` → None, not a derived sum. + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "ok"}}], + "usage": {"prompt_tokens": 100, "completion_tokens": 25} + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.total_tokens, None); + } + + #[test] + fn parse_responses_total_tokens_present_is_read() { + // Responses API: `usage.total_tokens` is a genuine provider total. + let v = serde_json::json!({ + "output": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}], + "status": "completed", + "usage": {"input_tokens": 80, "output_tokens": 20, "total_tokens": 100} + }); + let r = parse_responses(v).unwrap(); + assert_eq!(r.total_tokens, Some(100)); + assert_eq!(r.input_tokens, Some(80)); + assert_eq!(r.output_tokens, Some(20)); + } + + #[test] + fn parse_responses_total_tokens_absent_is_none() { + // Responses API without `total_tokens` → None. + let v = serde_json::json!({ + "output": [{"type": "message", "content": [{"type": "output_text", "text": "hi"}]}], + "status": "completed", + "usage": {"input_tokens": 80, "output_tokens": 20} + }); + let r = parse_responses(v).unwrap(); + assert_eq!(r.total_tokens, None); + } + + #[test] + fn parse_anthropic_total_tokens_always_none() { + // Anthropic reports only category counts; NIP-AM forbids deriving a total. + // total_tokens must always be None regardless of what the response contains — + // including if a future Anthropic API version unexpectedly adds total_tokens. + let v = serde_json::json!({ + "content": [{"type": "text", "text": "hi"}], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 100, + "cache_read_input_tokens": 50, + "cache_creation_input_tokens": 0, + "output_tokens": 30, + // Unexpected field: parse_anthropic must ignore this and return None. + "total_tokens": 180 + } + }); + let r = parse_anthropic(v).unwrap(); + assert!( + r.total_tokens.is_none(), + "Anthropic must never supply a total_tokens value" + ); + // Verify other fields still parse correctly. + assert_eq!(r.input_tokens, Some(150)); // inclusive sum with cache + assert_eq!(r.output_tokens, Some(30)); + } + #[test] fn parse_openai_reads_nested_cached_tokens() { // The shape vanilla OpenAI actually returns, captured from a live diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index a3d48a7cf1..31172def6d 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -172,6 +172,13 @@ pub struct LlmResponse { /// response carried no usage. Used to accumulate per-turn output counts /// for NIP-AM metric publishing. pub output_tokens: Option, + /// Provider-reported total tokens for this request, or `None` when the + /// provider does not report a genuine total. Present for OpenAI-shaped + /// responses (`usage.total_tokens`). Always `None` for Anthropic, which + /// reports only category counts; NIP-AM forbids summing categories into a + /// total. Callers must not derive this by summing `input_tokens + + /// output_tokens` — that is what the UI display approximation is for. + pub total_tokens: Option, /// Reasoning/thinking content emitted by the model before its answer, if /// any. Non-empty when the provider returns extended-thinking tokens: /// @@ -199,6 +206,94 @@ pub struct ToolDef { pub input_schema: Value, } +/// Tri-state accumulator for provider-reported total tokens within one ACP turn. +/// +/// Tracks whether every usage-bearing LLM response in the turn supplied a genuine +/// provider total. Used to accumulate a reliable per-turn total and contribute to +/// the session-cumulative total. +/// +/// - `Unseen`: no usage-bearing response observed yet (initial state for each turn). +/// - `Exact(n)`: every response so far reported a total; `n` is their sum. +/// - `Unknown`: at least one response lacked a total — permanently poisoned for +/// this turn. The session-cumulative also transitions to Unknown when any turn +/// lands Unknown, and stays there until a new session resets it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TurnTotalState { + #[default] + Unseen, + Exact(u64), + Unknown, +} + +impl TurnTotalState { + /// Add two exact token counts with overflow protection. + /// + /// Returns `Exact(acc + n)` on success or `Unknown` on overflow. + /// This is the single implementation of the checked-add / overflow-poisons + /// contract; both `fold()` and `merge_session()` call this helper so a + /// change to overflow semantics needs to be made in exactly one place. + fn checked_exact_sum(acc: u64, n: u64) -> TurnTotalState { + match acc.checked_add(n) { + Some(sum) => TurnTotalState::Exact(sum), + None => TurnTotalState::Unknown, + } + } + + /// Fold one provider-reported total into the current state. + /// + /// `total`: `Some(n)` when the provider included a genuine total on this + /// response; `None` when it was absent (e.g. Anthropic, or an OpenAI + /// response that omits usage). Absence of a total on any usage-bearing + /// response poisons the whole turn. + /// + /// Overflow is handled by `checked_exact_sum`: a saturated value would + /// not be a genuine provider-reported total, so overflow → `Unknown`. + pub fn fold(self, total: Option) -> TurnTotalState { + match (self, total) { + // Already poisoned — stays Unknown regardless. + (TurnTotalState::Unknown, _) => TurnTotalState::Unknown, + // No total from this response — poison the accumulator. + (_, None) => TurnTotalState::Unknown, + // First response with a total. + (TurnTotalState::Unseen, Some(n)) => TurnTotalState::Exact(n), + // Subsequent response — delegate to the shared checked-sum helper. + (TurnTotalState::Exact(acc), Some(n)) => Self::checked_exact_sum(acc, n), + } + } + + /// Merge a completed turn's total state into the session-cumulative state. + /// + /// This is the turn→session boundary accumulation: + /// - An `Unseen` turn (no usage-bearing responses) leaves the cumulative unchanged. + /// - Any `Unknown` side poisons the session permanently. + /// - Two `Exact` values are summed via `checked_exact_sum`; overflow → `Unknown`. + /// + /// The checked-add logic lives in `checked_exact_sum`; both this function and + /// `fold()` call that helper so overflow semantics are defined once. + pub fn merge_session(self, turn: TurnTotalState) -> TurnTotalState { + match (self, turn) { + // Either side poisoned → session is poisoned. + (TurnTotalState::Unknown, _) | (_, TurnTotalState::Unknown) => TurnTotalState::Unknown, + // Turn had no usage-bearing responses → no change to cumulative. + (acc, TurnTotalState::Unseen) => acc, + // First exact turn — adopt its value. + (TurnTotalState::Unseen, TurnTotalState::Exact(n)) => TurnTotalState::Exact(n), + // Add to running exact sum — delegate to the shared checked-sum helper. + (TurnTotalState::Exact(acc), TurnTotalState::Exact(n)) => { + Self::checked_exact_sum(acc, n) + } + } + } + + /// Consume the exact value if present; `None` for `Unseen` or `Unknown`. + pub fn exact_value(self) -> Option { + match self { + TurnTotalState::Exact(n) => Some(n), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum StopReason { EndTurn, @@ -413,3 +508,138 @@ mod tests { assert_eq!(item.estimated_bytes(), item.context_pressure_bytes()); } } + +#[cfg(test)] +mod turn_total_state_tests { + use super::TurnTotalState; + + // ── TurnTotalState::fold ─────────────────────────────────────────────── + + #[test] + fn fold_first_response_with_total_becomes_exact() { + let state = TurnTotalState::Unseen; + assert_eq!(state.fold(Some(100)), TurnTotalState::Exact(100)); + } + + #[test] + fn fold_first_response_without_total_becomes_unknown() { + // Missing total on any usage-bearing response poisons the turn. + let state = TurnTotalState::Unseen; + assert_eq!(state.fold(None), TurnTotalState::Unknown); + } + + #[test] + fn multiple_provider_rounds_all_with_totals_sum_correctly() { + // Multiple rounds all reporting a genuine total → Exact with their sum. + let state = TurnTotalState::Unseen; + let state = state.fold(Some(100)); + let state = state.fold(Some(50)); + let state = state.fold(Some(75)); + assert_eq!(state, TurnTotalState::Exact(225)); + } + + #[test] + fn mixed_present_and_missing_totals_within_one_turn_poisons_accumulator() { + // First round has a total, second does not → Unknown (permanently poisoned). + let state = TurnTotalState::Unseen; + let state = state.fold(Some(100)); // Exact(100) + let state = state.fold(None); // Missing → Unknown + assert_eq!(state, TurnTotalState::Unknown); + // Further rounds with totals don't un-poison. + let state = state.fold(Some(50)); + assert_eq!(state, TurnTotalState::Unknown); + } + + #[test] + fn unknown_stays_unknown_regardless_of_subsequent_totals() { + // Once poisoned, no subsequent total can recover the state. + let state = TurnTotalState::Unknown; + assert_eq!(state.fold(Some(999)), TurnTotalState::Unknown); + assert_eq!(state.fold(None), TurnTotalState::Unknown); + } + + #[test] + fn exact_value_returns_some_only_for_exact_variant() { + assert_eq!(TurnTotalState::Unseen.exact_value(), None); + assert_eq!(TurnTotalState::Unknown.exact_value(), None); + assert_eq!(TurnTotalState::Exact(42).exact_value(), Some(42)); + } + + #[test] + fn default_is_unseen() { + let state: TurnTotalState = Default::default(); + assert_eq!(state, TurnTotalState::Unseen); + } + + // ── overflow: fold ───────────────────────────────────────────────────── + + #[test] + fn fold_overflow_poisons_turn_not_saturates() { + // u64::MAX + 1 would saturate; checked_add must poison instead. + let state = TurnTotalState::Exact(u64::MAX); + assert_eq!( + state.fold(Some(1)), + TurnTotalState::Unknown, + "overflow in fold() must produce Unknown, not Exact(u64::MAX)" + ); + } + + // ── TurnTotalState::merge_session ────────────────────────────────────── + + #[test] + fn merge_session_unseen_turn_leaves_cumulative_unchanged() { + // An Unseen turn (no usage-bearing responses) must not alter the cumulative. + assert_eq!( + TurnTotalState::Exact(100).merge_session(TurnTotalState::Unseen), + TurnTotalState::Exact(100), + ); + assert_eq!( + TurnTotalState::Unseen.merge_session(TurnTotalState::Unseen), + TurnTotalState::Unseen, + ); + } + + #[test] + fn merge_session_exact_turn_adds_to_exact_cumulative() { + assert_eq!( + TurnTotalState::Exact(100).merge_session(TurnTotalState::Exact(50)), + TurnTotalState::Exact(150), + ); + } + + #[test] + fn merge_session_first_exact_turn_from_unseen_adopts_value() { + assert_eq!( + TurnTotalState::Unseen.merge_session(TurnTotalState::Exact(200)), + TurnTotalState::Exact(200), + ); + } + + #[test] + fn merge_session_unknown_turn_poisons_cumulative_permanently() { + assert_eq!( + TurnTotalState::Exact(100).merge_session(TurnTotalState::Unknown), + TurnTotalState::Unknown, + ); + // Poisoned session stays poisoned even with Unseen turn. + assert_eq!( + TurnTotalState::Unknown.merge_session(TurnTotalState::Unseen), + TurnTotalState::Unknown, + ); + // Poisoned session stays poisoned even with another Exact turn. + assert_eq!( + TurnTotalState::Unknown.merge_session(TurnTotalState::Exact(999)), + TurnTotalState::Unknown, + ); + } + + #[test] + fn merge_session_overflow_poisons_not_saturates() { + // Overflow at the session boundary must also produce Unknown. + assert_eq!( + TurnTotalState::Exact(u64::MAX).merge_session(TurnTotalState::Exact(1)), + TurnTotalState::Unknown, + "overflow in merge_session() must produce Unknown, not Exact(u64::MAX)" + ); + } +} diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index f2a86ac4b3..f782a9d476 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -771,6 +771,26 @@ fn openai_text_with_usage(content: &str, input_tokens: u64, output_tokens: u64) }) } +/// An OpenAI chat completion response WITH i/o usage but WITHOUT `total_tokens`. +/// Simulates a provider that omits the genuine total from its usage block. +/// buzz-agent must treat this turn's total as Unknown and poison the cumulative. +fn openai_text_with_usage_no_total(content: &str, input_tokens: u64, output_tokens: u64) -> Value { + json!({ + "id": "cc-nt", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop", + }], + "usage": { + "prompt_tokens": input_tokens, + "completion_tokens": output_tokens, + // total_tokens deliberately absent — simulates Anthropic or any + // provider that does not report a genuine total. + }, + }) +} + /// Returns true when `v` is a `_goose/unstable/session/update` usage_update /// notification. fn is_usage_update(v: &Value) -> bool { @@ -1123,3 +1143,164 @@ async fn steer_rejected_on_empty_prompt() { assert!(saw_reject, "empty steer prompt was not rejected"); h.shutdown().await; } + +// ─── Session-boundary total accumulation ──────────────────────────────────── + +/// Once a usage-bearing turn lacks a provider total, the session cumulative +/// becomes Unknown and `accumulatedTotalTokens` must be absent from subsequent +/// `usage_update` notifications — even if later turns supply a total. +/// +/// Sequence: turn 1 has total, turn 2 lacks total → session poisoned, turn 3 +/// has total → still poisoned. Only turn 1 must carry `accumulatedTotalTokens`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn session_total_poisoned_by_missing_total_and_stays_poisoned() { + let url = spawn_fake_llm(vec![ + openai_text_with_usage("t1", 10, 5), // total present → Exact(15) + openai_text_with_usage_no_total("t2", 20, 8), // total absent → Unknown + openai_text_with_usage("t3", 15, 6), // total present → still Unknown + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + + // ── Turn 1: total present ─────────────────────────────────────────────── + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"t1"}]}), + ) + .await; + let (frames1, _) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await; + let usage1 = frames1 + .iter() + .find(|v| is_usage_update(v)) + .expect("usage_update for turn 1"); + assert_eq!( + usage1["params"]["update"]["accumulatedTotalTokens"], + json!(15u64), + "turn 1 has genuine total; accumulatedTotalTokens must be 15" + ); + + // ── Turn 2: total absent — session is now poisoned ────────────────────── + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"t2"}]}), + ) + .await; + let (frames2, _) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await; + let usage2 = frames2 + .iter() + .find(|v| is_usage_update(v)) + .expect("usage_update for turn 2"); + assert!( + usage2["params"]["update"]["accumulatedTotalTokens"].is_null() + || usage2["params"]["update"] + .get("accumulatedTotalTokens") + .is_none(), + "turn 2 lacked total; accumulatedTotalTokens must be absent/null; got: {usage2:#?}" + ); + + // ── Turn 3: total present, but session is still poisoned ───────────────── + let p3 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"t3"}]}), + ) + .await; + let (frames3, _) = recv_until_with_drain(&mut h, |v| v["id"] == p3).await; + let usage3 = frames3 + .iter() + .find(|v| is_usage_update(v)) + .expect("usage_update for turn 3"); + assert!( + usage3["params"]["update"]["accumulatedTotalTokens"].is_null() + || usage3["params"]["update"].get("accumulatedTotalTokens").is_none(), + "session is poisoned; accumulatedTotalTokens must remain absent even after a total-bearing turn; got: {usage3:#?}" + ); + + // i/o counters are unaffected by total poisoning. + assert_eq!( + usage3["params"]["update"]["accumulatedInputTokens"], + json!(45u64), + "poisoned total must not discard input accumulation" + ); + assert_eq!( + usage3["params"]["update"]["accumulatedOutputTokens"], + json!(19u64), + "poisoned total must not discard output accumulation" + ); + + h.shutdown().await; +} + +/// A new session starts fresh and can accumulate an exact total independently +/// of any previous session. This verifies `accumulated_total_state` is reset +/// to `Unseen` on `session/new`, not inherited from a prior session. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn new_session_resets_total_accumulation() { + // Session A: two turns both with totals → Exact should accumulate. + // Session B (new session/new call): starts fresh. + let url = spawn_fake_llm(vec![ + // Session A, turn 1 + openai_text_with_usage("s1t1", 10, 5), + // Session A, turn 2 + openai_text_with_usage("s1t2", 20, 8), + // Session B, turn 1 + openai_text_with_usage("s2t1", 30, 10), + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid_a = init_session(&mut h).await; + + // Session A, turn 1 + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid_a, "prompt": [{"type":"text","text":"s1t1"}]}), + ) + .await; + let (frames1, _) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await; + let u1 = frames1.iter().find(|v| is_usage_update(v)).expect("usage1"); + assert_eq!( + u1["params"]["update"]["accumulatedTotalTokens"], + json!(15u64), + "session A turn 1 accumulated total" + ); + + // Session A, turn 2 — cumulative total is 15+28=43 + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid_a, "prompt": [{"type":"text","text":"s1t2"}]}), + ) + .await; + let (frames2, _) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await; + let u2 = frames2.iter().find(|v| is_usage_update(v)).expect("usage2"); + assert_eq!( + u2["params"]["update"]["accumulatedTotalTokens"], + json!(43u64), + "session A turn 2 cumulative total must be 15+28=43" + ); + + // Start a new session — must reset accumulated_total_state to Unseen. + let sid_b = init_session(&mut h).await; + assert_ne!(sid_a, sid_b, "sessions must have distinct IDs"); + + // Session B, turn 1 — total 30+10=40. Must NOT start from 43. + let p3 = h + .send( + "session/prompt", + json!({"sessionId": sid_b, "prompt": [{"type":"text","text":"s2t1"}]}), + ) + .await; + let (frames3, _) = recv_until_with_drain(&mut h, |v| v["id"] == p3).await; + let u3 = frames3.iter().find(|v| is_usage_update(v)).expect("usage3"); + assert_eq!( + u3["params"]["update"]["accumulatedTotalTokens"], + json!(40u64), + "new session must start fresh — accumulated total must be 40, not 83" + ); + + h.shutdown().await; +} From ab55fee81896d2b03edf5d2ca5012b715be2b93d Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Wed, 29 Jul 2026 19:08:35 -0400 Subject: [PATCH 45/99] feat: add first-class OpenRouter provider support (#1975) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary First-class `Provider::OpenRouter` support joining the existing anthropic/openai/databricks providers. Reuses the Chat Completions path with targeted mutations for OpenRouter's routing contract. **Core (`crates/buzz-agent`):** - `Provider::OpenRouter` enum variant with `OPENROUTER_API_KEY`, `BUZZ_AGENT_MODEL` → `OPENROUTER_MODEL` fallback, `OPENROUTER_BASE_URL` env convention - Body mutator: `reasoning: {effort}` when effort is configured, and `max_completion_tokens` translated to OpenRouter's `max_tokens` spelling; no `provider.require_parameters` filter (it routes only to endpoints advertising every parameter in the body, which hard-404s a valid model id); summaries get neither. `openai_body` is always called with `effort=None` on the OpenRouter path — the `reasoning` object is added by the mutator directly, so `reasoning_effort` is structurally absent. - Attribution headers: `HTTP-Referer: https://github.com/block/buzz`, `X-OpenRouter-Title: Buzz` - Error-inside-200 check in shared `parse_openai` (`finish_reason == "error"`) - 401 auth handling: static API keys (`refresh_now` returns the same token) fail terminal immediately with one wire request; PKCE/minting sources get one retry with the fresh token. - Status+`error_type` retry matrix (4-arm collapsed form): 429 (honor `Retry-After`), 502 (retry), 503/`provider_overloaded` (honor `Retry-After`), everything else including untyped 503 (bounded retries → actionable routing message). 499 included matching shared `post()` (#2175) for turn-timeout stall surfacing. Terminal failures wrapped in `terminal_llm_error` for duration+attempt-count context. - `anthropic/*` `cache_control` injection (model-gated, mixed-content safe) - Provider-agnostic `reasoning_details` opaque round-trip on `HistoryItem::Assistant` for tool-call continuations — captured verbatim in `parse_openai_with_reasoning_details`, replayed verbatim in `openai_body`, byte-accounting charged. `provider_extra` passthrough from `make_tool_call` composes independently. **Desktop:** - Readiness arms checking `OPENROUTER_API_KEY` + `OPENROUTER_MODEL` - Model discovery via `{OPENROUTER_BASE_URL}/models` filtered on `supported_parameters` contains `tools` - Picker entry, credential config, effort table 3-file sync **`desktop/src/features/agents/AGENTS.md`: no rules changed** — the scoped rule requiring an explicit note is satisfied here. Implements the gate-cleared plan from `PLANS/OPENROUTER_PROVIDER_PLAN.md` (rev 3). Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- crates/buzz-agent/README.md | 30 +- crates/buzz-agent/src/agent.rs | 76 + crates/buzz-agent/src/config.rs | 60 +- crates/buzz-agent/src/handoff.rs | 6 +- crates/buzz-agent/src/llm.rs | 1963 ++++++++++++++++- crates/buzz-agent/src/types.rs | 18 +- .../src-tauri/src/commands/agent_models.rs | 126 +- .../commands/agent_models_discovery_config.rs | 112 + .../src/commands/agent_models_openrouter.rs | 112 + .../src/commands/agent_models_tests.rs | 291 +++ .../src-tauri/src/managed_agents/readiness.rs | 229 +- .../readiness_goose_file_config_tests.rs | 190 ++ .../features/agents/ui/agentConfigOptions.tsx | 9 +- .../src/features/agents/ui/buzzAgentConfig.ts | 3 + .../agents/ui/effortTable.fixture.json | 7 + 15 files changed, 2943 insertions(+), 289 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_models_discovery_config.rs create mode 100644 desktop/src-tauri/src/commands/agent_models_openrouter.rs create mode 100644 desktop/src-tauri/src/managed_agents/readiness_goose_file_config_tests.rs diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index a2504db451..f138e4a4f1 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -21,9 +21,9 @@ HTTPS │ ▼ - Anthropic Messages API - or any OpenAI-compat - (vLLM, llama.cpp, OpenRouter, + Anthropic Messages API, + OpenRouter, or any OpenAI-compat + (vLLM, llama.cpp, Databricks, Block Gateway, Ollama, …) ``` @@ -50,6 +50,12 @@ OPENAI_COMPAT_MODEL=gpt-5 \ OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \ ./target/release/buzz-agent +# Or OpenRouter +BUZZ_AGENT_PROVIDER=openrouter \ +OPENROUTER_API_KEY=sk-or-v1-... \ +OPENROUTER_MODEL=anthropic/claude-sonnet-4.5 \ + ./target/release/buzz-agent + # Or Databricks model serving via OAuth 2.0 PKCE BUZZ_AGENT_PROVIDER=databricks \ DATABRICKS_HOST=https://dbc-...cloud.databricks.com \ @@ -129,15 +135,18 @@ Everything is environment variables. No flags, no config files. (We are a subpro | Variable | Default | Notes | |---|---|---| -| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | +| `BUZZ_AGENT_PROVIDER` | — | Required. `anthropic`, `openai`, `openrouter`, `databricks`, or `databricks_v2`. No implicit fallback — the agent errors at startup when this is unset. | | `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. | | `ANTHROPIC_MODEL` | — | Required when provider=anthropic. | | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | | | `ANTHROPIC_API_VERSION` | `2023-06-01` | | | `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. | | `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. | -| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. | +| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, Ollama, etc. | | `OPENAI_COMPAT_API` | `auto` | `auto` \| `chat` \| `responses`. `auto` picks Responses for `*.openai.com`, Chat Completions everywhere else. | +| `OPENROUTER_API_KEY` | — | Required when provider=openrouter. | +| `OPENROUTER_MODEL` | — | Required when provider=openrouter. Use OpenRouter's `vendor/model` id, e.g. `anthropic/claude-sonnet-4.5`. | +| `OPENROUTER_BASE_URL` | `https://openrouter.ai/api/v1` | | | `DATABRICKS_HOST` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_MODEL` | — | Required when provider=databricks or provider=databricks_v2. | | `DATABRICKS_TOKEN` | — | Optional static bearer escape hatch. If unset, Databricks uses browser OAuth + refresh cache. | @@ -167,17 +176,24 @@ Everything is environment variables. No flags, no config files. (We are a subpro | vLLM | `openai` | `POST {base}/chat/completions` | any tool-calling model | | llama.cpp | `openai` | `POST {base}/chat/completions` | any tool-calling GGUF | | Ollama | `openai` | `POST {base}/chat/completions` | llama3.1, qwen2.5-coder | -| OpenRouter | `openai` | `POST {base}/chat/completions` | anything they route | | Block Gateway | `openai` | `POST {base}/chat/completions` | gpt-5, claude | +| OpenRouter | `openrouter` | `POST {base}/chat/completions` | anything they route (extended-thinking replay, provider-agnostic tool calling) | | Databricks | `databricks` | `POST {host}/serving-endpoints/{model}/invocations` | goose-claude-4-6-sonnet | | Databricks AI Gateway v2 | `databricks_v2` | `POST {host}/ai-gateway/{provider}/v1/...` | databricks-gpt-5-5, databricks-claude-opus-4-7 | -If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, or `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. +If `BUZZ_AGENT_PROVIDER=anthropic` is selected without `ANTHROPIC_API_KEY`, `BUZZ_AGENT_PROVIDER=openai` is selected without `OPENAI_COMPAT_API_KEY`, or `BUZZ_AGENT_PROVIDER=openrouter` is selected without `OPENROUTER_API_KEY`, the agent returns an error — there is no implicit fallback to another provider. `provider=openai` speaks two HTTP dialects: the [Responses API](https://platform.openai.com/docs/api-reference/responses) (`/v1/responses`, required for GPT-5 / o-series tool-calling on OpenAI's own service) and the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) (`/chat/completions`, the broadly-supported OpenAI-compatible wire format). By default (`OPENAI_COMPAT_API=auto`) the agent picks **Responses** when `OPENAI_COMPAT_BASE_URL` points at an `*.openai.com` host and **Chat Completions** everywhere else. Pin the choice explicitly with `OPENAI_COMPAT_API=chat` or `OPENAI_COMPAT_API=responses` for providers that diverge from the default (e.g. a Responses-compatible self-hosted gateway). +`provider=openrouter` is first-class, not routed through `provider=openai`: it speaks OpenAI's Chat Completions wire format but with OpenRouter-specific extensions layered on top — + +- `reasoning.effort` is set on the request when reasoning effort is configured. The request deliberately carries no `provider.require_parameters` filter: that filter routes only to endpoints advertising every parameter in the body, and 83 of 274 tools-capable OpenRouter models do not advertise `reasoning`, so it turns an effort setting into a hard 404 on a valid model id. A model that cannot reason answers without reasoning instead. +- The response's `reasoning_details` array (opaque extended-thinking payload) is captured and replayed byte-for-byte on the next turn's assistant message, so multi-turn tool use keeps the model's chain-of-thought. +- `anthropic/*` models get Anthropic-style `cache_control` breakpoints injected on the system message and the last two user messages. +- Retryable statuses (429 and typed `provider_overloaded` 503) honor the documented `Retry-After` header (clamped to a small ceiling — see `RETRY_AFTER_CAP_SECS` in `llm.rs` — since the sleep happens outside `BUZZ_AGENT_LLM_TIMEOUT_SECS`); 502 and untyped 503 retry with jittered backoff instead. `401` is treated as an expired/invalid key and refreshed once, while `402` (no credits) and `403` (guardrail/moderation/permission) fail immediately without retry. + `Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box`, no async-trait. Adding a provider is a `match` arm and one `body`/`parse` pair in `llm.rs`. ## MCP Servers diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index ce4d2db4b3..48d4ea3b02 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -256,6 +256,7 @@ impl RunCtx<'_> { self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: Vec::new(), + reasoning_details: response.reasoning_details.clone(), }); let stop = map_stop(response.stop); // Only gate genuine end_turn — don't override max_tokens/refusal. @@ -292,6 +293,7 @@ impl RunCtx<'_> { self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: calls.clone(), + reasoning_details: response.reasoning_details, }); if let Some(stop) = self.execute_calls(&calls).await { @@ -726,6 +728,7 @@ pub(crate) fn push_hook_outputs_as_tool_results( // preserve. provider_extra: Default::default(), }], + reasoning_details: None, }); history.push(HistoryItem::ToolResult(ToolResult { provider_id, @@ -790,3 +793,76 @@ fn map_stop(p: ProviderStop) -> StopReason { ProviderStop::Refusal => StopReason::Refusal, } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// A9 regression: `reasoning_details` contributes real bytes to + /// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a + /// history item carrying a large opaque reasoning array must actually + /// drive `truncate_history` eviction — not be silently invisible to the + /// sizing gate that decides what survives a turn. + #[test] + fn truncate_history_evicts_oldest_turn_with_reasoning_details() { + let big_reasoning = json!([{ "type": "reasoning.text", "text": "x".repeat(400) }]); + let mut history = vec![ + HistoryItem::User("first question".into()), + HistoryItem::Assistant { + text: "first answer".into(), + tool_calls: vec![], + reasoning_details: Some(big_reasoning), + }, + HistoryItem::User("second question".into()), + HistoryItem::Assistant { + text: "second answer".into(), + tool_calls: vec![], + reasoning_details: None, + }, + ]; + + let total_before: usize = history.iter().map(HistoryItem::estimated_bytes).sum(); + // Budget below the total but above the second (smaller) turn alone, + // so only the oldest user+assistant pair — the one carrying + // reasoning_details — must be dropped. + let max_bytes = total_before - 100; + assert!( + max_bytes > 0, + "test fixture must leave room to evict only one turn" + ); + + truncate_history(&mut history, max_bytes); + + assert_eq!( + history.len(), + 2, + "the oldest user+assistant turn (with reasoning_details) must be evicted" + ); + assert!(matches!(&history[0], HistoryItem::User(s) if s == "second question")); + assert!( + matches!(&history[1], HistoryItem::Assistant { text, .. } if text == "second answer") + ); + let total_after: usize = history.iter().map(HistoryItem::estimated_bytes).sum(); + assert!(total_after <= max_bytes); + } + + #[test] + fn truncate_history_noop_when_under_budget() { + let mut history = vec![ + HistoryItem::User("hi".into()), + HistoryItem::Assistant { + text: "hello".into(), + tool_calls: vec![], + reasoning_details: None, + }, + ]; + let original_len = history.len(); + truncate_history(&mut history, 1_000_000); + assert_eq!( + history.len(), + original_len, + "under budget must not evict anything" + ); + } +} diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 037b67b3cb..a0e64f1a9d 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -671,6 +671,8 @@ pub enum Provider { /// Databricks AI Gateway v2. Routes by model family through the gateway's /// OpenAI Responses, Anthropic Messages, or MLflow Chat Completions paths. DatabricksV2, + /// OpenRouter multi-provider gateway. Routes to `{base_url}/chat/completions` with bearer auth. Wire format is OpenAI-chat-compatible. + OpenRouter, } /// Which OpenAI-family HTTP API to call. Set via `OPENAI_COMPAT_API` @@ -740,10 +742,11 @@ pub struct Config { pub thinking_effort: Option, /// Emit Anthropic `cache_control` breakpoints on the stable prefix /// (tools + system prompt) and the rolling conversation tail. Default on; - /// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Only consulted on Anthropic - /// Messages routes (first-party Anthropic and the DatabricksV2 Claude - /// route) — the Databricks gateway does not auto-cache, so without this the - /// surfaced `cache_read_input_tokens` is structurally always 0. + /// disable with `BUZZ_AGENT_PROMPT_CACHING=0`. Consulted on every route that + /// speaks the Anthropic caching dialect: first-party Anthropic, the + /// DatabricksV2 Claude route, and OpenRouter's `anthropic/*` models. The + /// Databricks gateway does not auto-cache, so without this the surfaced + /// `cache_read_input_tokens` is structurally always 0. pub prompt_caching: bool, } @@ -755,6 +758,7 @@ impl Config { env("BUZZ_AGENT_PROVIDER").as_deref(), env("ANTHROPIC_API_KEY").as_deref(), env("OPENAI_COMPAT_API_KEY").as_deref(), + env("OPENROUTER_API_KEY").as_deref(), )?; // Universal model override — takes priority over provider-specific model @@ -797,6 +801,16 @@ impl Config { databricks_host.ok_or_else(|| "config: DATABRICKS_HOST required".to_string())?, OpenAiApi::Chat, // only read by OpenAI/legacy Databricks dispatch ), + Provider::OpenRouter => ( + req("OPENROUTER_API_KEY")?, + resolve_model( + buzz_agent_model.as_deref(), + env("OPENROUTER_MODEL").as_deref(), + ) + .ok_or_else(|| "config: OPENROUTER_MODEL required".to_string())?, + env_or("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"), + OpenAiApi::Chat, // OpenRouter uses Chat Completions only + ), }; let system_prompt = match (env("BUZZ_AGENT_SYSTEM_PROMPT"), env("BUZZ_AGENT_SYSTEM_PROMPT_FILE")) { (Some(_), Some(_)) => return Err( @@ -1002,6 +1016,7 @@ fn resolve_provider( requested: Option<&str>, anthropic_key: Option<&str>, openai_key: Option<&str>, + openrouter_key: Option<&str>, ) -> Result { match requested.map(str::trim).filter(|s| !s.is_empty()) { Some(raw) => { @@ -1017,6 +1032,8 @@ fn resolve_provider( ), "databricks" => Ok(Provider::Databricks), "databricks_v2" | "databricks-v2" => Ok(Provider::DatabricksV2), + "openrouter" if present_nonempty(openrouter_key) => Ok(Provider::OpenRouter), + "openrouter" => Err("config: OPENROUTER_API_KEY required".into()), _ => Err(format!( "config: BUZZ_AGENT_PROVIDER={raw} not supported" )), @@ -1234,11 +1251,11 @@ mod tests { #[test] fn resolve_provider_keeps_requested_provider_when_token_present() { assert_eq!( - resolve_provider(Some("anthropic"), Some("sk-ant"), None,).unwrap(), + resolve_provider(Some("anthropic"), Some("sk-ant"), None, None).unwrap(), Provider::Anthropic ); assert_eq!( - resolve_provider(Some("openai"), None, Some("sk-openai"),).unwrap(), + resolve_provider(Some("openai"), None, Some("sk-openai"), None).unwrap(), Provider::OpenAi ); } @@ -1246,17 +1263,17 @@ mod tests { #[test] fn resolve_provider_errors_when_requested_provider_key_missing() { // No fallback — missing key returns an error regardless of Databricks availability. - let err = resolve_provider(Some("anthropic"), None, None).unwrap_err(); + let err = resolve_provider(Some("anthropic"), None, None, None).unwrap_err(); assert!(err.contains("ANTHROPIC_API_KEY required"), "{err}"); - let err = resolve_provider(Some("openai-compat"), None, Some(" ")).unwrap_err(); + let err = resolve_provider(Some("openai-compat"), None, Some(" "), None).unwrap_err(); assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}"); } #[test] fn resolve_provider_errors_when_provider_env_absent() { // No implicit inference — absent BUZZ_AGENT_PROVIDER is an error. - let err = resolve_provider(None, None, None).unwrap_err(); + let err = resolve_provider(None, None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}"); } @@ -1266,19 +1283,19 @@ mod tests { // When BUZZ_AGENT_PROVIDER=databricks, resolve_provider succeeds regardless // of DATABRICKS_HOST/MODEL (those are validated later in from_env()). assert_eq!( - resolve_provider(Some("databricks"), None, None).unwrap(), + resolve_provider(Some("databricks"), None, None, None).unwrap(), Provider::Databricks ); // Missing key for other providers still errors — no Databricks fallback. - let err = resolve_provider(Some("openai"), None, None).unwrap_err(); + let err = resolve_provider(Some("openai"), None, None, None).unwrap_err(); assert!(err.contains("OPENAI_COMPAT_API_KEY required"), "{err}"); - let err = resolve_provider(None, None, None).unwrap_err(); + let err = resolve_provider(None, None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER is required"), "{err}"); } #[test] fn resolve_provider_unsupported_error_preserves_user_casing() { - let err = resolve_provider(Some("OpenAIish"), None, None).unwrap_err(); + let err = resolve_provider(Some("OpenAIish"), None, None, None).unwrap_err(); assert!(err.contains("BUZZ_AGENT_PROVIDER=OpenAIish")); } @@ -2666,6 +2683,9 @@ mod tests { if p == "databricks" { return openai_result(&m); } + if p == "openrouter" { + return (ALL_7.to_vec(), Some("medium")); + } // openai-compat, unknown, empty → all-7, default medium. (ALL_7.to_vec(), Some("medium")) } @@ -2717,4 +2737,18 @@ mod tests { ); } } + + #[test] + fn resolve_provider_openrouter_with_key() { + assert_eq!( + resolve_provider(Some("openrouter"), None, None, Some("sk-or-123")).unwrap(), + Provider::OpenRouter + ); + } + + #[test] + fn resolve_provider_openrouter_missing_key() { + let err = resolve_provider(Some("openrouter"), None, None, None).unwrap_err(); + assert!(err.contains("OPENROUTER_API_KEY")); + } } diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs index 9c27c6606d..3b0feefecf 100644 --- a/crates/buzz-agent/src/handoff.rs +++ b/crates/buzz-agent/src/handoff.rs @@ -259,7 +259,11 @@ fn push_history_snippet(out: &mut String, item: &HistoryItem) { out.push_str(s); out.push('\n'); } - HistoryItem::Assistant { text, tool_calls } => { + HistoryItem::Assistant { + text, + tool_calls, + reasoning_details: _, + } => { out.push_str("[assistant] "); if !text.is_empty() { out.push_str(text); diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 98b881eb45..f595a165e5 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -146,6 +146,18 @@ impl Llm { .await?; parse_anthropic(v) } + Provider::OpenRouter => { + let mut body = + openai_body(cfg, system_prompt, history, tools, effective_model, None); + apply_openrouter_mutations( + &mut body, + cfg.thinking_effort, + effective_model, + cfg.prompt_caching, + ); + let v = self.post_openrouter(cfg, &body).await?; + parse_openai_with_reasoning_details(v) + } Provider::OpenAi | Provider::Databricks => { self.openai_request( cfg, @@ -248,6 +260,16 @@ impl Llm { }); Ok(parse_anthropic(self.post_anthropic(cfg, &body).await?)?.text) } + Provider::OpenRouter => { + let body = openrouter_summary_body( + effective_model, + system_prompt, + user_prompt, + max_output_tokens, + ); + let v = self.post_openrouter(cfg, &body).await?; + Ok(parse_openai(v)?.text) + } Provider::OpenAi | Provider::Databricks => { let r = self .openai_request( @@ -652,6 +674,32 @@ impl Llm { } } + async fn post_openrouter(&self, cfg: &Config, body: &Value) -> Result { + let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')); + let mut bearer = self.auth.bearer().await?; + let mut refreshed = false; + loop { + match openrouter_post(&self.http, &url, body, &bearer).await { + Err(AgentError::LlmAuth(_)) if !refreshed => { + refreshed = true; + let new_bearer = self.auth.refresh_now(&bearer).await?; + // A static key refreshes to itself — a byte-identical retry + // would be a guaranteed duplicate request against a key the + // server just rejected. Fail terminal immediately; the retry + // is only meaningful when the source can actually mint a + // distinct token (e.g., a PKCE OAuth source). + if new_bearer == bearer { + return Err(AgentError::LlmAuth( + "401: static key rejected — update key in agent settings".into(), + )); + } + bearer = new_bearer; + } + result => return result, + } + } + } + /// If `err` names `/v1/responses` / "use the Responses API", latch a /// sticky upgrade so subsequent OpenAI calls hit Responses. Logged once. fn try_upgrade(&self, err: &AgentError) -> bool { @@ -695,7 +743,11 @@ fn anthropic_body( messages.push(json!({ "role": "user", "content": [{ "type": "text", "text": text }] })); } - HistoryItem::Assistant { text, tool_calls } => { + HistoryItem::Assistant { + text, + tool_calls, + reasoning_details: _, + } => { flush(&mut messages, &mut pending); let mut content: Vec = Vec::new(); if !text.is_empty() { @@ -839,11 +891,18 @@ fn openai_body( flush_images(&mut messages, &mut pending_images); messages.push(json!({ "role": "user", "content": text })); } - HistoryItem::Assistant { text, tool_calls } => { + HistoryItem::Assistant { + text, + tool_calls, + reasoning_details, + } => { flush_images(&mut messages, &mut pending_images); let mut msg = serde_json::Map::new(); msg.insert("role".into(), json!("assistant")); msg.insert("content".into(), json!(text.as_str())); + if let Some(details) = reasoning_details { + msg.insert("reasoning_details".into(), details.clone()); + } if !tool_calls.is_empty() { let calls: Vec = tool_calls .iter() @@ -951,7 +1010,11 @@ fn responses_body( "role": "user", "content": [{ "type": "input_text", "text": text }], })), - HistoryItem::Assistant { text, tool_calls } => { + HistoryItem::Assistant { + text, + tool_calls, + reasoning_details: _, + } => { if !text.is_empty() { input.push(json!({ "role": "assistant", @@ -1207,6 +1270,7 @@ fn parse_responses(v: Value) -> Result { output_tokens, total_tokens, reasoning, + reasoning_details: None, }) } @@ -1442,10 +1506,44 @@ fn parse_anthropic(v: Value) -> Result { // total from them. Always None for this provider. total_tokens: None, reasoning, + reasoning_details: None, }) } fn parse_openai(v: Value) -> Result { + // A5: error-inside-200 check — choice-level `finish_reason == "error"` + if let Some(choice) = v + .get("choices") + .and_then(Value::as_array) + .and_then(|a| a.first()) + { + if choice.get("finish_reason").and_then(Value::as_str) == Some("error") { + let err = choice.get("error").cloned().unwrap_or(Value::Null); + // OpenRouter's `error.code` is numeric; other OpenAI-compat hosts + // may send a string. Accept either rather than discarding the + // typed code as "unknown". + let code = err + .get("code") + .and_then(|c| { + c.as_str() + .map(str::to_string) + .or_else(|| c.as_i64().map(|n| n.to_string())) + }) + .unwrap_or_else(|| "unknown".into()); + let message = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("provider error in 200 response"); + let error_type = err + .get("metadata") + .and_then(|m| m.get("error_type")) + .and_then(Value::as_str); + return Err(AgentError::Llm(match error_type { + Some(et) => format!("provider error ({code}, {et}): {message}"), + None => format!("provider error ({code}): {message}"), + })); + } + } let choice = v .get("choices") .and_then(Value::as_array) @@ -1517,9 +1615,24 @@ fn parse_openai(v: Value) -> Result { output_tokens, total_tokens, reasoning, + reasoning_details: None, }) } +fn parse_openai_with_reasoning_details(v: Value) -> Result { + let reasoning_details = v + .get("choices") + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("reasoning_details")) + .filter(|rd| rd.is_array()) + .cloned(); + let mut response = parse_openai(v)?; + response.reasoning_details = reasoning_details; + Ok(response) +} + fn make_tool_call( id: String, name: String, @@ -1809,7 +1922,7 @@ where /// flow; subsequent requests use the cache + refresh transparently. pub(crate) fn build_token_source(cfg: &Config) -> Result, AgentError> { match cfg.provider { - Provider::Anthropic | Provider::OpenAi => { + Provider::Anthropic | Provider::OpenAi | Provider::OpenRouter => { Ok(Arc::new(StaticTokenSource::new(cfg.api_key.clone()))) } Provider::Databricks | Provider::DatabricksV2 => { @@ -1835,6 +1948,29 @@ pub(crate) fn build_token_source(cfg: &Config) -> Result, A } } +/// Build the request body for `Llm::summarize` on `Provider::OpenRouter`. +/// Extracted so tests can assert on the actual wire shape instead of a +/// hand-rolled literal — summaries never carry `reasoning` (see +/// `apply_openrouter_mutations`, which the summary path never calls). +/// It spells the token limit `max_tokens` directly for the same reason: the +/// mutation that renames it is never applied here. +fn openrouter_summary_body( + effective_model: &str, + system_prompt: &str, + user_prompt: &str, + max_output_tokens: u32, +) -> Value { + json!({ + "model": effective_model, + "stream": false, + "max_tokens": max_output_tokens, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt }, + ], + }) +} + /// Return a clone of `body` with any top-level `"model"` field removed. /// Used for Databricks model-serving, which encodes the model in the URL /// path and rejects the field in the body. @@ -1849,12 +1985,353 @@ fn strip_model(body: &Value) -> Value { } } +#[derive(Debug)] +enum OpenRouterErrorClass { + Retryable(Option), + Unknown, +} + +/// Ceiling applied to the server-supplied `Retry-After` header before we +/// sleep on it. OpenRouter can advertise waits up to an hour, but +/// `openrouter_post`'s per-attempt sleep happens *outside* +/// `Client::timeout` (`cfg.llm_timeout`, default 240s) — an unclamped hint +/// could keep a single turn alive for up to two full-duration sleeps across +/// `MAX_RETRIES`. Clamping (never rejecting) keeps us honoring the server's +/// backoff signal while bounding worst-case turn latency to a value smaller +/// than the connect/response timeout. +const RETRY_AFTER_CAP_SECS: u64 = 60; + +fn parse_retry_after_header(headers: &reqwest::header::HeaderMap) -> Option { + let val = headers.get(reqwest::header::RETRY_AFTER)?.to_str().ok()?; + let secs: u64 = val.trim().parse().ok()?; + (secs > 0).then(|| std::time::Duration::from_secs(secs.min(RETRY_AFTER_CAP_SECS))) +} + +fn classify_openrouter_error( + status: u16, + body: &str, + header_retry_after: Option, +) -> OpenRouterErrorClass { + let parsed: Option = serde_json::from_str(body).ok(); + let error_type = parsed + .as_ref() + .and_then(|v| v.get("error")) + .and_then(|e| e.get("metadata")) + .and_then(|m| m.get("error_type")) + .and_then(Value::as_str); + // OpenRouter's documented retry hint is the HTTP `Retry-After` header + // (see https://openrouter.ai/docs/api_reference/errors-and-debugging); + // no current doc specifies a body-level retry field, so we don't parse one. + let retry_after = header_retry_after; + + match (status, error_type) { + (429, _) => OpenRouterErrorClass::Retryable(retry_after), + (502, _) => OpenRouterErrorClass::Retryable(None), + (503, Some("provider_overloaded")) => OpenRouterErrorClass::Retryable(retry_after), + _ => OpenRouterErrorClass::Unknown, + } +} + +/// The one place the parameter-routing failure is worded. OpenRouter reports it +/// two ways — a 404 `No endpoints found ...` (what a `require_parameters`-style +/// or unsupported-parameter body actually returns) and an untyped 503 — and both +/// mean the same thing to the user: the model id is fine, the request shape is +/// not serveable by any endpoint behind it. +fn openrouter_parameter_routing_error(error_body: &str) -> AgentError { + AgentError::Llm(format!( + "no OpenRouter endpoint supports the requested parameters — \ + check model, effort, and tool requirements: {error_body}" + )) +} + +async fn openrouter_post( + http: &Client, + url: &str, + body: &Value, + bearer: &str, +) -> Result { + let body_bytes = + serde_json::to_vec(body).map_err(|e| AgentError::Llm(format!("serialize: {e}")))?; + let call_start = std::time::Instant::now(); + for attempt in 0..MAX_RETRIES { + let resp = match http + .post(url) + .header("content-type", "application/json") + .header("HTTP-Referer", "https://github.com/block/buzz") + .header("X-OpenRouter-Title", "Buzz") + .bearer_auth(bearer) + .body(body_bytes.clone()) + .send() + .await + { + Ok(r) => r, + Err(e) => { + if attempt + 1 < MAX_RETRIES && is_retryable_transport_error(&e) { + tracing::warn!( + attempt = attempt + 1, + max_attempts = MAX_RETRIES, + error = %e, + "llm: openrouter transport error, retrying" + ); + backoff_with_jitter(attempt).await; + continue; + } + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("transport: {e}"), + )); + } + }; + let status = resp.status(); + // Unlike the generic `post` path, OpenRouter's static-key auth makes + // 401 and 403 distinguishable: 401 is an invalid/expired key (worth + // one refresh-and-retry), while OpenRouter documents 403 as a + // guardrail/moderation/permission rejection the same key will always + // reproduce. Refreshing a static key returns the identical key, so + // classifying 403 as `LlmAuth` would just waste a duplicate request + // and surface Desktop's unrelated "access denied" copy. + if status == 401 { + return Err(AgentError::LlmAuth(read_error_body(resp).await)); + } + if status == 403 { + return Err(AgentError::Llm(format!( + "{status}: {}", + read_error_body(resp).await + ))); + } + if status == 402 { + return Err(AgentError::Llm( + "OpenRouter credits exhausted — check https://openrouter.ai/credits".into(), + )); + } + if status == 404 { + // OpenRouter overloads 404: a genuinely unknown/unavailable model id + // and a valid model whose parameter set no endpoint can serve both + // land here. Discriminate on the full parameter-routing phrase rather + // than a prefix — "No endpoints found for "-style bodies are + // about the model, and reporting a parameter problem as + // `LlmModelNotFound` (or vice versa) sends the user to the wrong fix. + let error_body = read_error_body(resp).await; + if error_body.contains("No endpoints found that can handle the requested parameters") { + return Err(openrouter_parameter_routing_error(&error_body)); + } + return Err(AgentError::LlmModelNotFound(format!( + "{status}: {error_body}" + ))); + } + // A6: status+error_type retry matrix + // 499 (Client Closed Request) is included: OpenRouter may emit it when a + // turn times out mid-stream. Will added 499 to the shared `post()` path in + // #2175 for the same reason; OpenRouter must match to avoid silently opting + // out of that stall-surfacing recovery. + if status.is_server_error() || status == 429 || status.as_u16() == 499 { + let header_retry_after = parse_retry_after_header(resp.headers()); + let error_body = read_error_body(resp).await; + let should_retry = if attempt + 1 < MAX_RETRIES { + match classify_openrouter_error(status.as_u16(), &error_body, header_retry_after) { + OpenRouterErrorClass::Retryable(delay) => { + if let Some(d) = delay { + tokio::time::sleep(d).await; + } else { + backoff_with_jitter(attempt).await; + } + true + } + OpenRouterErrorClass::Unknown => { + backoff_with_jitter(attempt).await; + true + } + } + } else { + false + }; + if should_retry { + continue; + } + // Terminal: classify for the user + return if status == 429 { + Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("rate limited: {error_body}"), + )) + } else { + let parsed: Option = serde_json::from_str(&error_body).ok(); + let has_error_type = parsed + .as_ref() + .and_then(|v| v.get("error")) + .and_then(|e| e.get("metadata")) + .and_then(|m| m.get("error_type")) + .and_then(Value::as_str) + .is_some(); + Err(if !has_error_type && status.as_u16() == 503 { + openrouter_parameter_routing_error(&error_body) + } else { + terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("exhausted retries: {status}: {error_body}"), + ) + }) + }; + } + if !status.is_success() { + return Err(AgentError::Llm(format!( + "{status}: {}", + read_error_body(resp).await + ))); + } + if let Some(len) = resp.content_length() { + if len as usize > MAX_LLM_RESPONSE_BYTES { + return Err(AgentError::Llm(format!( + "response too large: {len} > {MAX_LLM_RESPONSE_BYTES}" + ))); + } + } + let mut buf: Vec = Vec::new(); + let mut stream = resp; + loop { + match stream.chunk().await { + Ok(Some(chunk)) => { + if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES { + return Err(AgentError::Llm(format!( + "response exceeded {MAX_LLM_RESPONSE_BYTES} bytes" + ))); + } + buf.extend_from_slice(&chunk); + } + Ok(None) => break, + Err(e) => { + return Err(terminal_llm_error( + call_start.elapsed(), + attempt + 1, + &format!("body read: {e}"), + )) + } + } + } + return serde_json::from_slice(&buf).map_err(|e| AgentError::Llm(format!("json: {e}"))); + } + Err(terminal_llm_error( + call_start.elapsed(), + MAX_RETRIES, + "exhausted retries", + )) +} + +fn apply_openrouter_mutations( + body: &mut Value, + effort: Option, + effective_model: &str, + prompt_caching: bool, +) { + if let Some(obj) = body.as_object_mut() { + // OpenRouter's Chat Completions API spells the output cap `max_tokens`; + // `max_completion_tokens` is OpenAI-native and only 53 of 274 + // tools-capable OpenRouter models advertise it. Sending the OpenAI + // spelling risks the cap being dropped on the floor by the endpoint we + // route to, so translate it here rather than at the shared `openai_body` + // (which OpenAI and Databricks also use, where the OpenAI spelling is + // correct). + if let Some(max_tokens) = obj.remove("max_completion_tokens") { + obj.insert("max_tokens".into(), max_tokens); + } + + // A2/A3: Add OpenRouter reasoning object when effort is configured. + // Deliberately NOT paired with `provider.require_parameters`: that filter + // routes only to endpoints advertising every parameter in the body, and + // 83 of 274 tools-capable models do not advertise `reasoning`, so it turns + // an opt-in effort setting into a hard 404 ("No endpoints found that can + // handle the requested parameters") on a model id that is perfectly + // valid. OpenRouter already best-effort routes on `tools`/`max_tokens` + // without the filter, so we accept a request served without reasoning + // over a request that cannot be served at all. + if let Some(e) = effort { + obj.insert( + "reasoning".into(), + json!({ "effort": e.openai_effort_str() }), + ); + } + + // A7: Anthropic cache_control injection for anthropic/* models. Gated on + // `prompt_caching` (`BUZZ_AGENT_PROMPT_CACHING`) for the same reason as + // the native Anthropic route: these are Anthropic-dialect breakpoints on + // an Anthropic model, so the documented kill switch must reach them too. + if prompt_caching && effective_model.starts_with("anthropic/") { + apply_anthropic_cache_control(obj); + } + } +} + +fn apply_anthropic_cache_control(body: &mut serde_json::Map) { + if let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) { + // Cache the system message + if let Some(system_msg) = messages + .iter_mut() + .find(|m| m.get("role").and_then(Value::as_str) == Some("system")) + { + if let Some(content) = system_msg.get("content").and_then(Value::as_str) { + let content_str = content.to_string(); + if let Some(obj) = system_msg.as_object_mut() { + obj.insert( + "content".into(), + json!([{ + "type": "text", + "text": content_str, + "cache_control": { "type": "ephemeral" } + }]), + ); + } + } + } + + // Cache last 2 user messages (skip image-only ones — A7 mixed-content regression) + let mut user_count = 0; + for msg in messages.iter_mut().rev() { + if msg.get("role").and_then(Value::as_str) != Some("user") { + continue; + } + // Only cache string content (plain text user messages), not array content + // (image batches from tool results). This prevents corrupting image-only + // user messages by converting them to text cache breakpoints. + if let Some(content) = msg.get("content").and_then(Value::as_str) { + let content_str = content.to_string(); + if let Some(obj) = msg.as_object_mut() { + obj.insert( + "content".into(), + json!([{ + "type": "text", + "text": content_str, + "cache_control": { "type": "ephemeral" } + }]), + ); + } + user_count += 1; + } + if user_count >= 2 { + break; + } + } + } + // Cache the last tool definition + if let Some(tools) = body.get_mut("tools").and_then(Value::as_array_mut) { + if let Some(last_tool) = tools.last_mut() { + if let Some(function) = last_tool.get_mut("function").and_then(Value::as_object_mut) { + function.insert("cache_control".into(), json!({ "type": "ephemeral" })); + } + } + } +} + #[cfg(test)] mod tests { use super::*; use crate::config::{Config, HookServers, OpenAiApi, Provider}; use crate::types::{HistoryItem, ToolCall, ToolResult, ToolResultContent}; + use std::collections::VecDeque; use std::time::Duration; + use tokio::sync::Mutex; use tracing_subscriber::layer::SubscriberExt; fn cfg(provider: Provider) -> Config { @@ -2492,6 +2969,7 @@ mod tests { arguments: serde_json::json!({"source":"x.png"}), provider_extra: Default::default(), }], + reasoning_details: None, }, HistoryItem::ToolResult(ToolResult { provider_id: "toolu_1".into(), @@ -2542,6 +3020,7 @@ mod tests { arguments: serde_json::json!({"command": "ls"}), provider_extra: Default::default(), }], + reasoning_details: None, }, HistoryItem::ToolResult(ToolResult { provider_id: "call_abc".into(), @@ -2641,6 +3120,7 @@ mod tests { arguments: serde_json::json!({}), provider_extra: Default::default(), }], + reasoning_details: None, }, ]; let body = responses_body(&cfg_responses(), "system", &history, &[], "model", None); @@ -2927,6 +3407,7 @@ mod tests { provider_extra: Default::default(), }, ], + reasoning_details: None, }, HistoryItem::ToolResult(ToolResult { provider_id: "toolu_a".into(), @@ -2988,6 +3469,7 @@ mod tests { HistoryItem::Assistant { text: "hi".into(), tool_calls: vec![], + reasoning_details: None, }, HistoryItem::User("more".into()), ], @@ -4003,6 +4485,7 @@ mod tests { let history = vec![HistoryItem::Assistant { text: r.text.clone(), tool_calls: r.tool_calls.clone(), + reasoning_details: None, }]; let body = openai_body( &cfg(Provider::DatabricksV2), @@ -4597,4 +5080,1476 @@ mod tests { }); assert_eq!(parse_responses(v).unwrap().output_tokens, None); } + + // ---- A3: OpenRouter body-shape tests ---- + + fn tools_vec() -> Vec { + vec![ToolDef { + name: "dev__shell".into(), + description: "run a shell command".into(), + input_schema: serde_json::json!({ + "type": "object", + "properties": {"command": {"type": "string"}}, + }), + }] + } + + #[test] + fn openrouter_body_tools_with_effort() { + let mut c = cfg(Provider::OpenRouter); + c.thinking_effort = Some(ThinkingEffort::High); + let mut body = openai_body( + &c, + "system", + &[HistoryItem::User("hi".into())], + &tools_vec(), + "anthropic/claude-opus-4-7", + None, + ); + apply_openrouter_mutations( + &mut body, + c.thinking_effort, + "anthropic/claude-opus-4-7", + true, + ); + assert_eq!(body["reasoning"]["effort"], "high"); + // `openai_body` is always called with `effort=None` on the OpenRouter + // path (line 151): OpenRouter uses its own `reasoning` object, not the + // OpenAI-style `reasoning_effort` field. Verify the field is structurally + // absent — not merely removed by a no-op cleanup. + assert!( + body.get("reasoning_effort").is_none(), + "reasoning_effort must never appear on the OpenRouter body path: \ + openai_body is called with effort=None, so the field is never emitted" + ); + assert!( + body.get("provider").is_none(), + "provider.require_parameters hard-404s models that do not advertise \ + every parameter we send" + ); + assert!(!body["tools"].as_array().unwrap().is_empty()); + assert_eq!( + body["max_tokens"], 1024, + "token limit must carry openai_body's value under OpenRouter's spelling" + ); + assert!( + body.get("max_completion_tokens").is_none(), + "max_completion_tokens is the OpenAI-native spelling; OpenRouter reads max_tokens" + ); + } + + #[test] + fn openrouter_body_tools_no_effort() { + let c = cfg(Provider::OpenRouter); + let mut body = openai_body( + &c, + "system", + &[HistoryItem::User("hi".into())], + &tools_vec(), + "anthropic/claude-opus-4-7", + None, + ); + apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true); + assert!( + body.get("reasoning").is_none(), + "reasoning must be absent when effort is None" + ); + assert!( + body.get("reasoning_effort").is_none(), + "reasoning_effort must never appear on the OpenRouter body path" + ); + assert!( + body.get("provider").is_none(), + "tools alone must not add a provider routing filter" + ); + } + + #[test] + fn openrouter_body_empty_tools_with_effort() { + let mut c = cfg(Provider::OpenRouter); + c.thinking_effort = Some(ThinkingEffort::Medium); + let mut body = openai_body( + &c, + "system", + &[HistoryItem::User("hi".into())], + &[], + "anthropic/claude-opus-4-7", + None, + ); + apply_openrouter_mutations( + &mut body, + c.thinking_effort, + "anthropic/claude-opus-4-7", + true, + ); + assert_eq!(body["reasoning"]["effort"], "medium"); + assert!( + body.get("provider").is_none(), + "83 of 274 tools-capable models do not advertise `reasoning`; filtering on \ + it would 404 them instead of answering without reasoning" + ); + } + + #[test] + fn openrouter_body_empty_tools_no_effort() { + let c = cfg(Provider::OpenRouter); + let mut body = openai_body( + &c, + "system", + &[HistoryItem::User("hi".into())], + &[], + "anthropic/claude-opus-4-7", + None, + ); + apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true); + assert!(body.get("reasoning").is_none()); + assert!( + body.get("provider").is_none(), + "no body shape adds a provider routing filter" + ); + } + + /// `BUZZ_AGENT_PROMPT_CACHING=0` must reach the OpenRouter `anthropic/*` + /// route too, not just the native Anthropic Messages routes. The switch and + /// this route landed in separate changes, so nothing but this test stops the + /// gate from being dropped and the kill switch silently becoming a no-op on + /// a route that emits Anthropic-dialect breakpoints. + #[test] + fn openrouter_body_caching_disabled_emits_no_cache_control() { + let c = cfg(Provider::OpenRouter); + let mut body = openai_body( + &c, + "system", + &[HistoryItem::User("hi".into())], + &tools_vec(), + "anthropic/claude-opus-4-7", + None, + ); + apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", false); + assert!( + !body.to_string().contains("cache_control"), + "BUZZ_AGENT_PROMPT_CACHING=0 must suppress every breakpoint: {body}" + ); + // The switch is scoped to caching — the routing-contract mutations that + // make the request serveable at all must still be applied. + assert_eq!( + body.get("max_tokens").and_then(Value::as_u64), + Some(u64::from(c.max_output_tokens)), + "the max_tokens rename is not part of the caching gate" + ); + } + + /// The paired positive case: with caching on, the same body does carry + /// breakpoints. Without this twin, a mutation that hard-disabled caching + /// outright would still leave the test above green. + #[test] + fn openrouter_body_caching_enabled_emits_cache_control() { + let c = cfg(Provider::OpenRouter); + let mut body = openai_body( + &c, + "system", + &[HistoryItem::User("hi".into())], + &tools_vec(), + "anthropic/claude-opus-4-7", + None, + ); + apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true); + assert!( + body.to_string().contains("cache_control"), + "caching on must still emit breakpoints: {body}" + ); + } + + /// The rename moves an existing value; it never invents a token limit for a + /// body that did not carry one. + #[test] + fn openrouter_body_without_token_limit_gains_none() { + let mut body = json!({ "model": "vendor/model", "messages": [] }); + apply_openrouter_mutations(&mut body, None, "vendor/model", true); + assert!(body.get("max_tokens").is_none()); + assert!(body.get("max_completion_tokens").is_none()); + } + + #[test] + fn openrouter_summary_carries_neither_reasoning_nor_provider() { + let body = openrouter_summary_body( + "anthropic/claude-opus-4-7", + "summarize", + "text to summarize", + 1024, + ); + assert_eq!(body["model"], "anthropic/claude-opus-4-7"); + assert_eq!(body["messages"][0]["role"], "system"); + assert_eq!(body["messages"][1]["content"], "text to summarize"); + assert_eq!(body["max_tokens"], 1024); + assert!( + body.get("max_completion_tokens").is_none(), + "summary body must use OpenRouter's token-limit spelling" + ); + assert!( + body.get("reasoning").is_none(), + "summary body must not carry reasoning" + ); + assert!( + body.get("provider").is_none(), + "summary body must not carry provider" + ); + } + + /// The token-limit rename belongs to `apply_openrouter_mutations`, not to + /// `openai_body` — which OpenAI and Databricks also use, and where + /// `max_completion_tokens` is the correct spelling. + #[test] + fn openai_body_keeps_max_completion_tokens_when_unmutated() { + let body = openai_body( + &cfg(Provider::OpenAi), + "system", + &[HistoryItem::User("hi".into())], + &[], + "model", + None, + ); + assert_eq!(body["max_completion_tokens"], 1024); + assert!(body.get("max_tokens").is_none()); + } + + // ---- A5: error-inside-200 ---- + + #[test] + fn parse_openai_error_inside_200_returns_error() { + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "error", + "error": { + "code": 503, + "message": "No endpoints found that support tool use" + } + }] + }); + let err = parse_openai(v).unwrap_err(); + match &err { + AgentError::Llm(s) => { + assert!(s.contains("provider error (503)"), "got: {s}"); + assert!(s.contains("No endpoints found"), "got: {s}"); + } + _ => panic!("expected AgentError::Llm, got: {err:?}"), + } + } + + #[test] + fn parse_openai_error_inside_200_accepts_string_code() { + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "error", + "error": { + "code": "insufficient_quota", + "message": "quota exceeded" + } + }] + }); + let err = parse_openai(v).unwrap_err(); + match &err { + AgentError::Llm(s) => assert!( + s.contains("provider error (insufficient_quota)"), + "got: {s}" + ), + _ => panic!("expected AgentError::Llm, got: {err:?}"), + } + } + + #[test] + fn parse_openai_error_inside_200_surfaces_error_type() { + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "error", + "error": { + "code": 429, + "message": "Rate limit exceeded", + "metadata": { "error_type": "rate_limit_exceeded" } + } + }] + }); + let err = parse_openai(v).unwrap_err(); + match &err { + AgentError::Llm(s) => { + assert!(s.contains("429"), "got: {s}"); + assert!(s.contains("rate_limit_exceeded"), "got: {s}"); + } + _ => panic!("expected AgentError::Llm, got: {err:?}"), + } + } + + #[test] + fn parse_openai_normal_stop_not_affected_by_error_check() { + let v = serde_json::json!({ + "choices": [{"finish_reason": "stop", "message": {"content": "hello"}}], + "usage": {"prompt_tokens": 10, "completion_tokens": 5} + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.text, "hello"); + assert_eq!(r.stop, ProviderStop::EndTurn); + } + + #[test] + fn parse_openai_tool_calls_not_affected_by_error_check() { + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "tool_calls", + "message": { + "content": "", + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "test", "arguments": "{}"} + }] + } + }] + }); + let r = parse_openai(v).unwrap(); + assert_eq!(r.stop, ProviderStop::ToolUse); + assert_eq!(r.tool_calls.len(), 1); + } + + // ---- A6: OpenRouter retry classification ---- + + #[test] + fn classify_429_rate_limit_body_retry_after_is_ignored() { + // M1: no documented body-level retry field, so a `retry_after` key + // inside `error.metadata` is inert — only the HTTP header (passed + // separately) can produce a delay. + let body = + r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded","retry_after":2.5}}}"#; + match classify_openrouter_error(429, body, None) { + OpenRouterErrorClass::Retryable(None) => {} + other => panic!("expected Retryable(None), got: {other:?}"), + } + } + + #[test] + fn classify_429_rate_limit_without_retry_after() { + let body = r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded"}}}"#; + match classify_openrouter_error(429, body, None) { + OpenRouterErrorClass::Retryable(None) => {} + other => panic!("expected Retryable(None), got: {other:?}"), + } + } + + #[test] + fn classify_429_prefers_http_header_over_body() { + // Body-level retry hints are no longer parsed (M1: undocumented field); + // the HTTP header is the only source, and it's honored when present. + let body = r#"{"error":{"metadata":{"error_type":"rate_limit_exceeded"}}}"#; + let header = Some(Duration::from_secs(3)); + match classify_openrouter_error(429, body, header) { + OpenRouterErrorClass::Retryable(Some(d)) => { + assert_eq!(d, Duration::from_secs(3), "HTTP header must be honored"); + } + other => panic!("expected Retryable with header delay, got: {other:?}"), + } + } + + #[test] + fn classify_502_provider_unavailable() { + let body = r#"{"error":{"metadata":{"error_type":"provider_unavailable"}}}"#; + match classify_openrouter_error(502, body, None) { + OpenRouterErrorClass::Retryable(None) => {} + other => panic!("expected Retryable(None) for 502, got: {other:?}"), + } + } + + #[test] + fn classify_503_provider_overloaded_with_retry_after() { + // No documented body-level retry field (M1); the header is the only + // source `classify_openrouter_error` consults. + let body = r#"{"error":{"metadata":{"error_type":"provider_overloaded"}}}"#; + let header = Some(Duration::from_secs(5)); + match classify_openrouter_error(503, body, header) { + OpenRouterErrorClass::Retryable(Some(d)) => { + assert_eq!(d, Duration::from_secs(5)); + } + other => panic!("expected Retryable with delay, got: {other:?}"), + } + } + + #[test] + fn classify_503_untyped_is_unknown() { + let body = r#"{"error":{"message":"No endpoints found"}}"#; + match classify_openrouter_error(503, body, None) { + OpenRouterErrorClass::Unknown => {} + other => panic!("expected Unknown for untyped 503, got: {other:?}"), + } + } + + #[test] + fn classify_500_untyped_is_unknown() { + match classify_openrouter_error(500, r#"{"error":{"message":"internal"}}"#, None) { + OpenRouterErrorClass::Unknown => {} + other => panic!("expected Unknown for 500, got: {other:?}"), + } + } + + #[test] + fn parse_retry_after_header_valid() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::RETRY_AFTER, "5".parse().unwrap()); + assert_eq!( + parse_retry_after_header(&headers), + Some(Duration::from_secs(5)) + ); + } + + #[test] + fn parse_retry_after_header_zero_rejected() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::RETRY_AFTER, "0".parse().unwrap()); + assert_eq!(parse_retry_after_header(&headers), None); + } + + #[test] + fn parse_retry_after_header_over_cap_clamped() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert(reqwest::header::RETRY_AFTER, "3601".parse().unwrap()); + assert_eq!( + parse_retry_after_header(&headers), + Some(Duration::from_secs(RETRY_AFTER_CAP_SECS)), + "over-cap hints clamp to the ceiling rather than being dropped" + ); + } + + #[test] + fn parse_retry_after_header_at_cap_unclamped() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::RETRY_AFTER, + RETRY_AFTER_CAP_SECS.to_string().parse().unwrap(), + ); + assert_eq!( + parse_retry_after_header(&headers), + Some(Duration::from_secs(RETRY_AFTER_CAP_SECS)) + ); + } + + #[test] + fn parse_retry_after_header_missing() { + let headers = reqwest::header::HeaderMap::new(); + assert_eq!(parse_retry_after_header(&headers), None); + } + + #[test] + fn parse_retry_after_header_non_numeric_ignored() { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::RETRY_AFTER, + "Wed, 21 Oct 2026 07:28:00 GMT".parse().unwrap(), + ); + assert_eq!(parse_retry_after_header(&headers), None); + } + + // ---- A7: Anthropic cache_control with mixed content ---- + + #[test] + fn anthropic_cache_control_mixed_text_tool_image_history() { + let history = vec![ + HistoryItem::User("first question".into()), + HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "toolu_1".into(), + name: "dev__view_image".into(), + arguments: serde_json::json!({"source": "x.png"}), + provider_extra: Default::default(), + }], + reasoning_details: None, + }, + HistoryItem::ToolResult(ToolResult { + provider_id: "toolu_1".into(), + content: vec![ + ToolResultContent::Text("10×10 image".into()), + ToolResultContent::Image { + data: "aW1n".into(), + mime_type: "image/png".into(), + }, + ], + is_error: false, + }), + HistoryItem::User("second question about the image".into()), + HistoryItem::User("third question".into()), + ]; + let mut body = openai_body( + &cfg(Provider::OpenRouter), + "system", + &history, + &tools_vec(), + "anthropic/claude-opus-4-7", + None, + ); + apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true); + let messages = body["messages"].as_array().unwrap(); + + // System message should have cache_control + let system = &messages[0]; + assert_eq!(system["content"][0]["cache_control"]["type"], "ephemeral"); + + // Image-batch user messages (containing image_url blocks) must NOT have cache_control + let image_user_msgs: Vec<_> = messages + .iter() + .filter(|m| { + m.get("role").and_then(Value::as_str) == Some("user") + && m.get("content") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .any(|b| b.get("type").and_then(Value::as_str) == Some("image_url")) + }) + .unwrap_or(false) + }) + .collect(); + assert!( + !image_user_msgs.is_empty(), + "should have image user messages" + ); + for img_msg in &image_user_msgs { + let content = img_msg["content"].as_array().unwrap(); + for block in content { + assert!( + block.get("cache_control").is_none(), + "image-only user message must not receive cache_control" + ); + } + } + + // Exactly 2 text user messages should have cache_control (skipping image-only ones) + let cached_text_count = messages + .iter() + .filter(|m| { + m.get("role").and_then(Value::as_str) == Some("user") + && m.get("content") + .and_then(Value::as_array) + .map(|a| { + a.iter().any(|b| { + b.get("type").and_then(Value::as_str) == Some("text") + && b.get("cache_control").is_some() + }) + }) + .unwrap_or(false) + }) + .count(); + assert_eq!( + cached_text_count, 2, + "exactly 2 text user messages should have cache_control" + ); + + // Last tool def should have cache_control + let tools = body["tools"].as_array().unwrap(); + let last_tool = tools.last().unwrap(); + assert_eq!(last_tool["function"]["cache_control"]["type"], "ephemeral"); + } + + #[test] + fn anthropic_cache_control_image_only_user_does_not_consume_slot() { + // An image-only user message between two text user messages must not + // consume a cache breakpoint slot — both text messages should get cached. + let history = vec![ + HistoryItem::User("text message one".into()), + HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "toolu_1".into(), + name: "dev__view_image".into(), + arguments: serde_json::json!({"source": "x.png"}), + provider_extra: Default::default(), + }], + reasoning_details: None, + }, + HistoryItem::ToolResult(ToolResult { + provider_id: "toolu_1".into(), + content: vec![ToolResultContent::Image { + data: "aW1n".into(), + mime_type: "image/png".into(), + }], + is_error: false, + }), + HistoryItem::User("text message two".into()), + HistoryItem::User("text message three".into()), + ]; + let mut body = openai_body( + &cfg(Provider::OpenRouter), + "system", + &history, + &[], + "anthropic/claude-opus-4-7", + None, + ); + apply_openrouter_mutations(&mut body, None, "anthropic/claude-opus-4-7", true); + let messages = body["messages"].as_array().unwrap(); + + // Count text user messages that got cache_control + let cached_text_count = messages + .iter() + .filter(|m| { + m.get("role").and_then(Value::as_str) == Some("user") + && m.get("content") + .and_then(Value::as_array) + .map(|a| a.iter().any(|b| b.get("cache_control").is_some())) + .unwrap_or(false) + }) + .count(); + assert_eq!( + cached_text_count, 2, + "image-only user messages must not consume a cache breakpoint slot" + ); + } + + // ---- A9: reasoning_details round-trip ---- + + #[test] + fn parse_openai_with_reasoning_details_captures_array() { + let details = serde_json::json!([ + {"type": "thinking", "content": "Let me consider..."}, + {"type": "thinking", "content": "The answer is 42."} + ]); + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "tool_calls", + "message": { + "content": "", + "reasoning_details": details, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "test", "arguments": "{}"} + }] + } + }], + "usage": {"prompt_tokens": 10, "completion_tokens": 5} + }); + let r = parse_openai_with_reasoning_details(v).unwrap(); + assert_eq!(r.reasoning_details, Some(details)); + } + + #[test] + fn parse_openai_with_reasoning_details_none_when_absent() { + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "stop", + "message": {"content": "hello"} + }] + }); + let r = parse_openai_with_reasoning_details(v).unwrap(); + assert!( + r.reasoning_details.is_none(), + "reasoning_details must be None when not in response" + ); + } + + /// M2: a malformed `reasoning_details` shape (null or a bare object, + /// rather than the documented array) must be omitted at the parse + /// boundary, not stored and later replayed into the next request. + #[test] + fn parse_openai_with_reasoning_details_omits_non_array_shapes() { + let null_shape = serde_json::json!({ + "choices": [{ + "finish_reason": "stop", + "message": {"content": "hello", "reasoning_details": null} + }] + }); + let r = parse_openai_with_reasoning_details(null_shape).unwrap(); + assert!( + r.reasoning_details.is_none(), + "null reasoning_details must be omitted, not stored as Some(Null)" + ); + + let object_shape = serde_json::json!({ + "choices": [{ + "finish_reason": "stop", + "message": { + "content": "hello", + "reasoning_details": {"type": "thinking", "content": "not an array"} + } + }] + }); + let r = parse_openai_with_reasoning_details(object_shape).unwrap(); + assert!( + r.reasoning_details.is_none(), + "a bare-object reasoning_details must be omitted, not stored as Some(object)" + ); + } + + #[test] + fn parse_openai_plain_never_captures_reasoning_details() { + let v = serde_json::json!({ + "choices": [{ + "finish_reason": "stop", + "message": { + "content": "hello", + "reasoning_details": [{"type": "thinking", "content": "hmm"}] + } + }] + }); + let r = parse_openai(v).unwrap(); + assert!( + r.reasoning_details.is_none(), + "plain parse_openai must never capture reasoning_details (OpenAI/Databricks regression)" + ); + } + + #[test] + fn reasoning_details_two_request_round_trip() { + let details = serde_json::json!([ + {"type": "thinking", "content": "Step 1: analyze the request."}, + {"type": "thinking", "content": "Step 2: call the tool."} + ]); + // Request 1: model returns a tool call with reasoning_details + let response1 = serde_json::json!({ + "choices": [{ + "finish_reason": "tool_calls", + "message": { + "content": "", + "reasoning_details": details, + "tool_calls": [{ + "id": "call_abc", + "type": "function", + "function": {"name": "dev__shell", "arguments": "{\"command\":\"ls\"}"} + }] + } + }], + "usage": {"prompt_tokens": 50, "completion_tokens": 20} + }); + let r1 = parse_openai_with_reasoning_details(response1).unwrap(); + assert_eq!(r1.reasoning_details, Some(details.clone())); + + // Build history as the agent would: assistant turn with reasoning_details, + // followed by a tool result. + let history = vec![ + HistoryItem::User("run ls".into()), + HistoryItem::Assistant { + text: String::new(), + tool_calls: r1.tool_calls, + reasoning_details: r1.reasoning_details, + }, + HistoryItem::ToolResult(ToolResult { + provider_id: "call_abc".into(), + content: vec![ToolResultContent::Text("file.txt".into())], + is_error: false, + }), + ]; + + // Request 2: build the body for the continuation + let body = openai_body( + &cfg(Provider::OpenRouter), + "system", + &history, + &[], + "anthropic/claude-opus-4-7", + None, + ); + let messages = body["messages"].as_array().unwrap(); + + // The assistant message must carry the identical reasoning_details array + let assistant_msg = messages + .iter() + .find(|m| m.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("assistant message must exist"); + assert_eq!( + assistant_msg["reasoning_details"], details, + "reasoning_details must be replayed byte-for-byte on the assistant message" + ); + + // The assistant message must appear BEFORE the tool result + let assistant_idx = messages + .iter() + .position(|m| m.get("role").and_then(Value::as_str) == Some("assistant")) + .unwrap(); + let tool_idx = messages + .iter() + .position(|m| m.get("role").and_then(Value::as_str) == Some("tool")) + .unwrap(); + assert!( + assistant_idx < tool_idx, + "assistant with reasoning_details must precede tool result" + ); + } + + #[test] + fn reasoning_details_none_emits_no_field_in_body() { + let history = vec![ + HistoryItem::User("hello".into()), + HistoryItem::Assistant { + text: "hi back".into(), + tool_calls: Vec::new(), + reasoning_details: None, + }, + ]; + let body = openai_body( + &cfg(Provider::OpenRouter), + "system", + &history, + &[], + "anthropic/claude-opus-4-7", + None, + ); + let messages = body["messages"].as_array().unwrap(); + let assistant_msg = messages + .iter() + .find(|m| m.get("role").and_then(Value::as_str) == Some("assistant")) + .expect("assistant message must exist"); + assert!( + assistant_msg.get("reasoning_details").is_none(), + "assistant with None reasoning_details must not emit the field" + ); + } + + #[test] + fn reasoning_details_charged_to_estimated_bytes() { + let details = serde_json::json!([ + {"type": "thinking", "content": "A long chain of reasoning tokens here."} + ]); + let with = HistoryItem::Assistant { + text: "text".into(), + tool_calls: Vec::new(), + reasoning_details: Some(details.clone()), + }; + let without = HistoryItem::Assistant { + text: "text".into(), + tool_calls: Vec::new(), + reasoning_details: None, + }; + assert!( + with.estimated_bytes() > without.estimated_bytes(), + "reasoning_details must contribute to estimated_bytes" + ); + assert!( + with.context_pressure_bytes() > without.context_pressure_bytes(), + "reasoning_details must contribute to context_pressure_bytes" + ); + let details_size = serde_json::to_vec(&details).unwrap().len(); + assert_eq!( + with.estimated_bytes() - without.estimated_bytes(), + details_size, + "reasoning_details contribution must equal its serialized size" + ); + } + + #[test] + fn reasoning_details_not_replayed_in_anthropic_body() { + let history = vec![ + HistoryItem::User("hi".into()), + HistoryItem::Assistant { + text: "ok".into(), + tool_calls: Vec::new(), + reasoning_details: Some( + serde_json::json!([{"type": "thinking", "content": "hmm"}]), + ), + }, + ]; + let body = anthropic_body( + &cfg(Provider::Anthropic), + "system", + &history, + &[], + "claude-opus-4-7", + None, + ); + let messages = body["messages"].as_array().unwrap(); + let assistant = messages + .iter() + .find(|m| m.get("role").and_then(Value::as_str) == Some("assistant")) + .unwrap(); + assert!( + assistant.get("reasoning_details").is_none(), + "anthropic_body must not replay reasoning_details" + ); + } + + #[test] + fn reasoning_details_not_replayed_in_responses_body() { + let history = vec![ + HistoryItem::User("hi".into()), + HistoryItem::Assistant { + text: "ok".into(), + tool_calls: Vec::new(), + reasoning_details: Some( + serde_json::json!([{"type": "thinking", "content": "hmm"}]), + ), + }, + ]; + let body = responses_body(&cfg_responses(), "system", &history, &[], "model", None); + let body_str = serde_json::to_string(&body).unwrap(); + assert!( + !body_str.contains("reasoning_details"), + "responses_body must not replay reasoning_details" + ); + } + + // ---- T4: openrouter_post transport-level regressions ---- + // + // These stub an HTTP server directly and drive `openrouter_post` (not the + // classifier in isolation), proving the retry/attempt-accounting and + // header behavior the classifier-only tests above cannot see. + + /// One canned response: status, body, and any extra headers (e.g. + /// `Retry-After`) to send back for a single request. + struct CannedResponse { + status: u16, + body: String, + extra_headers: Vec<(String, String)>, + } + + impl CannedResponse { + fn new(status: u16, body: &str) -> Self { + Self { + status, + body: body.into(), + extra_headers: Vec::new(), + } + } + + fn with_header(mut self, name: &str, value: &str) -> Self { + self.extra_headers.push((name.into(), value.into())); + self + } + } + + fn status_line(status: u16) -> &'static str { + match status { + 200 => "200 OK", + 401 => "401 Unauthorized", + 402 => "402 Payment Required", + 403 => "403 Forbidden", + 404 => "404 Not Found", + 429 => "429 Too Many Requests", + 499 => "499 Client Closed Request", + 500 => "500 Internal Server Error", + 502 => "502 Bad Gateway", + 503 => "503 Service Unavailable", + _ => panic!("unsupported status {status} in test stub"), + } + } + + /// Spawns a stub HTTP server that pops one `CannedResponse` per request + /// (repeating the last one once the queue is exhausted, so an + /// over-budget attempt count is visible rather than hanging), and + /// captures each request's raw header block for header-attribution + /// assertions. Returns (url, captured_header_blocks, attempt_counter). + async fn spawn_openrouter_stub( + responses: Vec, + ) -> ( + String, + Arc>>, + Arc, + ) { + use std::sync::atomic::{AtomicU32, Ordering}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let queue = Arc::new(Mutex::new(VecDeque::from(responses))); + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let attempts = Arc::new(AtomicU32::new(0)); + let captured_clone = captured.clone(); + let attempts_clone = attempts.clone(); + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let queue = queue.clone(); + let captured = captured_clone.clone(); + let attempts = attempts_clone.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + if buf.len() > 1_000_000 { + return; + } + } + let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; + let header_str = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + // Drain any remaining body per Content-Length so `Connection: + // close` doesn't race the client's write. + let content_length: usize = header_str + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|v| v.trim().parse().ok()) + }) + .unwrap_or(0); + let mut body_len = buf.len() - header_end; + while body_len < content_length { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => break, + Ok(n) => body_len += n, + } + } + captured.lock().await.push(header_str); + attempts.fetch_add(1, Ordering::SeqCst); + + let mut q = queue.lock().await; + let canned = if q.len() > 1 { + q.pop_front().unwrap() + } else { + // Repeat the final canned response so a test bug that + // over-retries produces a visible extra attempt + // instead of a hung connection. + let last = q.front().unwrap(); + CannedResponse { + status: last.status, + body: last.body.clone(), + extra_headers: last.extra_headers.clone(), + } + }; + drop(q); + + let mut resp = format!( + "HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\n", + status_line(canned.status), + canned.body.len() + ); + for (name, value) in &canned.extra_headers { + resp.push_str(&format!("{name}: {value}\r\n")); + } + resp.push_str("Connection: close\r\n\r\n"); + resp.push_str(&canned.body); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + (url, captured, attempts) + } + + /// A 403 (guardrail/moderation/permission rejection, per OpenRouter docs) + /// must NOT be classified as `LlmAuth`: refreshing a static key returns + /// the identical key, so retrying would just waste a duplicate request. + /// Exactly one attempt, plain `AgentError::Llm` with the body preserved. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_403_single_attempt_not_auth_error() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 403, + r#"{"error":{"message":"model flagged by moderation"}}"#, + )]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::Llm(s) if s.contains("403") && s.contains("model flagged by moderation")), + "403 must surface as AgentError::Llm with status+body, not LlmAuth: got {err:?}" + ); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + 1, + "403 must not be retried (a refreshed static key is identical)" + ); + } + + /// A 402 short-circuits on the first attempt: no retry, one request, + /// the actionable credits-exhausted message. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_402_single_attempt_short_circuit() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 402, + r#"{"error":{"message":"payment required"}}"#, + )]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::Llm(s) if s.contains("credits exhausted")), + "got {err:?}" + ); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + 1, + "402 must not be retried" + ); + } + + /// A 404 whose body is OpenRouter's parameter-routing rejection is NOT a + /// missing model: the id is valid and no endpoint behind it can serve the + /// request shape. It must surface the actionable routing message rather than + /// `LlmModelNotFound`, which sends the user hunting a model-name typo. + /// Body text is the one OpenRouter actually returned in the live probe run. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_404_no_endpoints_found_is_parameter_routing_error() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 404, + r#"{"error":{"message":"No endpoints found that can handle the requested parameters. To learn more about provider routing, visit: https://openrouter.ai/docs/guides/routing/provider-selection","code":404}}"#, + )]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")), + "parameter-routing 404 must not be reported as a missing model: got {err:?}" + ); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + 1, + "404 must not be retried" + ); + } + + /// Every other 404 still maps to `LlmModelNotFound`, including one that + /// shares the `No endpoints found` prefix but is about the model rather than + /// the parameters — the discriminator is narrow enough that a genuinely + /// unavailable model keeps its own error kind (Desktop renders + /// model-not-found differently from a generic LLM failure). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_404_unknown_model_stays_model_not_found() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 404, + r#"{"error":{"message":"No endpoints found for vendor/nonexistent-model.","code":404}}"#, + )]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::LlmModelNotFound(s) if s.contains("404") && s.contains("vendor/nonexistent-model")), + "a model-level 404 must stay LlmModelNotFound: got {err:?}" + ); + } + + /// A 429 with `Retry-After: 1` sleeps for that duration before the retry + /// succeeds — proving the header value is actually honored, not just + /// classified. + #[tokio::test(flavor = "current_thread")] + async fn openrouter_post_429_honors_retry_after_header() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![ + CannedResponse::new(429, r#"{"error":{"message":"rate limited"}}"#) + .with_header("Retry-After", "1"), + CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#), + ]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + let before = std::time::Instant::now(); + let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .expect("second attempt succeeds"); + assert_eq!(out["choices"][0]["message"]["content"], "ok"); + assert!( + before.elapsed() >= Duration::from_secs(1), + "must sleep at least the Retry-After hint" + ); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + /// A `Retry-After` far beyond `RETRY_AFTER_CAP_SECS` must not stall the + /// retry loop for anywhere near its advertised duration — proving the + /// cap is enforced end-to-end in `openrouter_post`'s actual sleep, not + /// merely in the isolated `parse_retry_after_header` unit tests above. + /// Runs on a paused clock so a real 999999s wait would hang the test + /// instead of silently passing. + #[tokio::test(start_paused = true)] + async fn openrouter_post_429_retry_sleep_capped_despite_huge_retry_after() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![ + CannedResponse::new(429, r#"{"error":{"message":"rate limited"}}"#) + .with_header("Retry-After", "999999"), + CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#), + ]) + .await; + let http = Client::builder().build().unwrap(); + let before = tokio::time::Instant::now(); + let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .expect("second attempt succeeds"); + assert_eq!(out["choices"][0]["message"]["content"], "ok"); + assert!( + before.elapsed() <= Duration::from_secs(RETRY_AFTER_CAP_SECS + 5), + "retry sleep must be clamped to RETRY_AFTER_CAP_SECS ({RETRY_AFTER_CAP_SECS}s), \ + not the header's 999999s: elapsed {:?}", + before.elapsed() + ); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + /// An untyped 503 (no `error.metadata.error_type`) exhausts all + /// `MAX_RETRIES` attempts, then returns the actionable routing message — + /// proving attempt accounting terminates rather than retrying forever. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_untyped_503_exhausts_retries_into_actionable_message() { + let canned = CannedResponse::new(503, r#"{"error":{"message":"no capacity"}}"#); + let (url, _captured, attempts) = spawn_openrouter_stub(vec![canned]).await; + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::Llm(s) if s.contains("no OpenRouter endpoint supports")), + "got {err:?}" + ); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + MAX_RETRIES, + "must exhaust exactly MAX_RETRIES attempts, no more" + ); + } + + /// Attribution headers (`HTTP-Referer`, `X-OpenRouter-Title`) are on the + /// actual wire request, not merely asserted against a body fixture. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_sends_attribution_headers() { + let (url, captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 200, + r#"{"choices":[{"message":{"content":"ok"}}]}"#, + )]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .expect("200 succeeds"); + let headers = captured.lock().await; + let header_str = headers + .first() + .expect("one request captured") + .to_lowercase(); + assert!( + header_str.contains("http-referer: https://github.com/block/buzz"), + "got: {header_str}" + ); + assert!( + header_str.contains("x-openrouter-title: buzz"), + "got: {header_str}" + ); + } + + /// A 499 response is retried and the call succeeds on the second attempt, + /// mirroring the shared `post()` path (#2175). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_retries_499_then_succeeds() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![ + CannedResponse::new(499, ""), + CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#), + ]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .expect("retry after 499 should succeed"); + assert_eq!(out["choices"][0]["message"]["content"], "ok"); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + 2, + "exactly one 499 retry" + ); + } + + /// A 502 without `provider_unavailable` still retries (the classifier's + /// unconditional-retry branch), then succeeds on attempt 2 — proving the + /// generic 502 path isn't accidentally routed to `Unknown`/terminal. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_502_retries_then_succeeds() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![ + CannedResponse::new(502, r#"{"error":{"message":"bad gateway"}}"#), + CannedResponse::new(200, r#"{"choices":[{"message":{"content":"ok"}}]}"#), + ]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + let out = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .expect("retry succeeds"); + assert_eq!(out["choices"][0]["message"]["content"], "ok"); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + /// A 200 response whose body is truncated mid-stream (connection closed + /// before the full Content-Length is delivered) must surface the error + /// through `terminal_llm_error`, not a bare `AgentError::Llm("read: …")` — + /// the caller needs cumulative duration and attempt-count context to diagnose + /// an upstream that silently drops connections. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_truncated_200_body_wraps_in_terminal_error() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + // Spawn a stub that sends a 200 with Content-Length > actual body, + // then closes the connection — reqwest sees a truncated stream. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + if let Ok((mut sock, _)) = listener.accept().await { + let mut buf = [0u8; 4096]; + // Read until end-of-headers, ignore body + loop { + match sock.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + } + } + // Claim 1 MB of body, send only 10 bytes, then close. + let _ = sock + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\ + Content-Length: 1048576\r\nConnection: close\r\n\r\n\ + {truncated", + ) + .await; + let _ = sock.shutdown().await; + } + }); + + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::Llm(s) if s.contains("body read")), + "truncated body must surface as AgentError::Llm with 'body read': got {err:?}" + ); + assert!( + matches!(&err, AgentError::Llm(s) if s.contains("cumulative")), + "body-read error must include terminal_llm_error's cumulative context: got {err:?}" + ); + } + + /// A `TokenSource` whose `refresh_now` always returns the identical token — + /// models a static API key whose bytes never change on refresh. + struct StaticAuth { + token: String, + } + + #[async_trait::async_trait] + impl TokenSource for StaticAuth { + async fn bearer(&self) -> Result { + Ok(self.token.clone()) + } + async fn refresh_now(&self, _rejected: &str) -> Result { + Ok(self.token.clone()) // static: same bytes every time + } + } + + /// A `TokenSource` that returns a stale token from `bearer()` and a + /// distinct fresh token from `refresh_now()`, modelling a PKCE OAuth source. + struct MintingAuth { + stale: String, + fresh: String, + refreshes: std::sync::atomic::AtomicU32, + } + + #[async_trait::async_trait] + impl TokenSource for MintingAuth { + async fn bearer(&self) -> Result { + Ok(self.stale.clone()) + } + async fn refresh_now(&self, _rejected: &str) -> Result { + self.refreshes + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(self.fresh.clone()) + } + } + + /// A static key 401: `refresh_now` returns the same bytes — the second + /// wire request would be byte-identical, so the retry must be skipped. + /// Exactly one wire request reaches the server. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn post_openrouter_static_key_401_single_attempt_no_retry() { + use std::sync::atomic::Ordering; + + let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 401, + r#"{"error":{"message":"invalid api key"}}"#, + )]) + .await; + let auth = Arc::new(StaticAuth { + token: "static-key".into(), + }); + let llm = llm_with(auth); + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + + let err = llm.post_openrouter(&c, &json!({})).await.unwrap_err(); + assert!( + matches!(&err, AgentError::LlmAuth(s) if s.contains("static key rejected")), + "static 401 must surface as LlmAuth with 'static key rejected': got {err:?}" + ); + assert_eq!( + attempts.load(Ordering::SeqCst), + 1, + "exactly one wire request — no duplicate retry for a static key" + ); + } + + /// A minting-source 401: `refresh_now` produces a distinct fresh token, so + /// the retry is legitimate. The stub accepts the fresh token's second + /// request with 200 and exactly two wire requests reach the server. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn post_openrouter_minting_source_401_retries_with_fresh_token() { + use std::sync::atomic::Ordering; + + // Stub: always 401 for bearer "stale", 200 for anything else. + // We repurpose `spawn_auth_stub` here: it rejects `Bearer stale`, + // accepts `Bearer fresh`. + let always_401 = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let base = spawn_auth_stub(always_401, 401).await; + + let auth = Arc::new(MintingAuth { + stale: "stale".into(), + fresh: "fresh".into(), + refreshes: std::sync::atomic::AtomicU32::new(0), + }); + let llm = llm_with(auth.clone()); + let mut c = cfg(Provider::OpenRouter); + c.base_url = base; + + let result = llm.post_openrouter(&c, &json!({})).await; + // `spawn_auth_stub` returns `{"ok":true}` on success. + assert!( + result.is_ok(), + "minting-source retry should succeed: {result:?}" + ); + assert_eq!( + auth.refreshes.load(Ordering::SeqCst), + 1, + "exactly one refresh" + ); + } } diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 31172def6d..343a75bf72 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -62,6 +62,7 @@ pub enum HistoryItem { Assistant { text: String, tool_calls: Vec, + reasoning_details: Option, }, ToolResult(ToolResult), } @@ -83,7 +84,11 @@ impl HistoryItem { fn size_with(&self, content_size: fn(&ToolResultContent) -> usize) -> usize { match self { Self::User(s) => s.len(), - Self::Assistant { text, tool_calls } => { + Self::Assistant { + text, + tool_calls, + reasoning_details, + } => { text.len() + tool_calls .iter() @@ -102,6 +107,11 @@ impl HistoryItem { .unwrap_or(0) }) .sum::() + + reasoning_details + .as_ref() + .and_then(|v| serde_json::to_vec(v).ok()) + .map(|b| b.len()) + .unwrap_or(0) } Self::ToolResult(r) => { r.provider_id.len() + r.content.iter().map(content_size).sum::() @@ -188,6 +198,10 @@ pub struct LlmResponse { /// /// Empty string when the provider returned no reasoning content. pub reasoning: String, + /// Raw `reasoning_details` array from an OpenRouter response, if present. + /// Replayed on subsequent turns so the model can continue its chain-of-thought. + /// `None` for all non-OpenRouter providers. + pub reasoning_details: Option, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -481,6 +495,7 @@ mod tests { arguments: Value::Null, provider_extra: extra, }], + reasoning_details: None, }; let without_extra = HistoryItem::Assistant { text: String::new(), @@ -490,6 +505,7 @@ mod tests { arguments: Value::Null, provider_extra: Map::new(), }], + reasoning_details: None, }; assert!(with_extra.estimated_bytes() > without_extra.estimated_bytes() + 500); assert_eq!( diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index ca1fe9bdf6..7ce03b140b 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -99,6 +99,17 @@ pub async fn get_agent_models( // so a build-provided provider still gets live discovery. let effective_provider = effective_discovery_provider(saved_provider.as_deref(), provider_env_var, &merged_env); + if let Some(models) = discover_openrouter_models( + &state.http_client, + &effective_provider, + &merged_env, + persisted_model.clone(), + ) + .await? + { + return Ok(models); + } + if let Some(models) = discover_openai_compatible_models( &state.http_client, &effective_provider, @@ -154,69 +165,11 @@ fn model_discovery_error(pubkey: &str, error: &str) -> String { ) } -/// Everything `get_agent_models` needs from the record + context, resolved in -/// one pure step so the linked-agent regression test can bind the exact values -/// the command consumes. -#[derive(Debug, PartialEq, Eq)] -struct AgentModelDiscoveryConfig { - /// Effective harness command (descriptor-resolved), for `resolve_command`. - command: String, - /// Effective harness args (descriptor-resolved). - args: Vec, - /// Model from the authoritative resolver spawn uses — linked instances - /// read their definition, never stale `record.model` bytes. - model: Option, - /// Provider from the same authoritative resolver — never stale - /// `record.provider` bytes for linked instances. - provider: Option, - /// The runtime's provider env var (e.g. `GOOSE_PROVIDER`), so discovery - /// can recover the provider from the env when the resolver yields none. - /// `None` for runtimes that do not take a provider, or an unknown command. - provider_env_var: Option<&'static str>, - /// The descriptor's fully layered env (definition/persona/global/agent). - env: BTreeMap, -} - -/// Resolve the model-discovery config for a saved agent — the descriptor-backed -/// successor to the old `saved_agent_model_discovery_config`. -/// -/// Command/args/env come from `resolve_effective_harness_descriptor` (the same -/// resolver as `spawn_agent_child`); model/provider come from -/// `resolve_effective_model_provider` (#1968's definition-authoritative -/// contract) — linked instances read their definition, never a stale -/// materialized `record.model`/`record.provider`, so discovery cannot query a -/// provider this agent will not actually launch with. Definition-less -/// instances keep their own record values, matching spawn's -/// `resolve_definition_less` arm. When the resolver yields no provider, -/// `effective_discovery_provider` recovers the provider the agent will -/// actually launch with from the runtime's own provider env var, read out of -/// the descriptor env (which already layers definition/persona/global values -/// the same way spawn does). -/// -/// Returns `Err("DANGLING_HARNESS_ID:")` from the descriptor resolver when -/// the harness id no longer exists; the caller routes it through -/// `model_discovery_error`. -fn agent_model_discovery_config( - record: &crate::managed_agents::ManagedAgentRecord, - personas: &[crate::managed_agents::AgentDefinition], - global: &crate::managed_agents::GlobalAgentConfig, -) -> Result { - let descriptor = - crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)?; - let (model, provider) = - crate::managed_agents::resolve_effective_model_provider(record, personas, global); - let provider_env_var = - known_acp_runtime(&descriptor.command).and_then(|meta| meta.provider_env_var); - - Ok(AgentModelDiscoveryConfig { - command: descriptor.command, - args: descriptor.args, - model, - provider, - provider_env_var, - env: descriptor.env, - }) -} +#[path = "agent_models_discovery_config.rs"] +mod discovery_config; +use discovery_config::{ + agent_model_discovery_config, draft_agent_model_discovery_env, AgentModelDiscoveryConfig, +}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -269,31 +222,12 @@ pub async fn discover_agent_models( .unwrap_or_else(|| agent_command.to_string()); let runtime_meta = known_acp_runtime(agent_command); - let mut derived_env = BTreeMap::new(); - if let Some(meta) = runtime_meta { - let provider = input - .provider - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - if !meta.provider_locked { - if let (Some(env_key), Some(provider)) = (meta.provider_env_var, provider) { - derived_env.insert(env_key.to_string(), provider.to_string()); - } - } - } - // Layer definition_env below user env_vars so user overrides always win. - // Reserved keys are stripped, matching the same filter applied at spawn. - let mut filtered_definition_env = BTreeMap::new(); - for (key, value) in &input.definition_env { - if !crate::managed_agents::is_reserved_env_key(key) { - filtered_definition_env.insert(key.clone(), value.clone()); - } - } - // Merge: derived (metadata) → definition env → user env_vars. - let merged_with_def = - crate::managed_agents::merged_user_env(&derived_env, &filtered_definition_env); - let merged_env = crate::managed_agents::merged_user_env(&merged_with_def, &input.env_vars); + let merged_env = draft_agent_model_discovery_env( + agent_command, + input.provider.as_deref(), + &input.definition_env, + &input.env_vars, + ); let merged_env = discovery_env_with_baked_floor(merged_env); // Recover a build-provided provider when the form has none, so the create // dialog discovers live models instead of falling through to the subprocess. @@ -348,6 +282,13 @@ pub async fn discover_agent_models( return Err("Buzz shared compute is not available in this build".to_string()); } + if let Some(models) = + discover_openrouter_models(&state.http_client, &effective_provider, &merged_env, None) + .await? + { + return Ok(models); + } + if let Some(models) = discover_openai_compatible_models( &state.http_client, &effective_provider, @@ -388,6 +329,15 @@ struct OpenAiModelListItem { created: Option, } +#[path = "agent_models_openrouter.rs"] +mod openrouter; +use openrouter::discover_openrouter_models; +#[cfg(test)] +use openrouter::{ + filter_openrouter_models, is_openrouter_provider, openrouter_models_url, + OpenRouterModelListItem, OpenRouterModelListResponse, +}; + fn is_openai_compatible_provider(provider: Option<&str>) -> bool { matches!( provider diff --git a/desktop/src-tauri/src/commands/agent_models_discovery_config.rs b/desktop/src-tauri/src/commands/agent_models_discovery_config.rs new file mode 100644 index 0000000000..e43f09495b --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_discovery_config.rs @@ -0,0 +1,112 @@ +//! Model-discovery configuration resolution for the agent-models commands. +//! +//! Two entry points, one per call shape: [`agent_model_discovery_config`] +//! resolves a *saved* agent through the same descriptor/model resolvers spawn +//! uses, and [`draft_agent_model_discovery_env`] derives the env for an unsaved +//! form. Both are pure so the regression tests can bind the exact values the +//! commands consume. +//! +//! Included from `agent_models.rs` via `#[path]`, so `super::*` resolves +//! against that module (the `agent_models_tests.rs` convention). + +use std::collections::BTreeMap; + +use crate::managed_agents::known_acp_runtime; + +/// Everything `get_agent_models` needs from the record + context, resolved in +/// one pure step so the linked-agent regression test can bind the exact values +/// the command consumes. +#[derive(Debug, PartialEq, Eq)] +pub(super) struct AgentModelDiscoveryConfig { + /// Effective harness command (descriptor-resolved), for `resolve_command`. + pub(super) command: String, + /// Effective harness args (descriptor-resolved). + pub(super) args: Vec, + /// Model from the authoritative resolver spawn uses — linked instances + /// read their definition, never stale `record.model` bytes. + pub(super) model: Option, + /// Provider from the same authoritative resolver — never stale + /// `record.provider` bytes for linked instances. + pub(super) provider: Option, + /// The runtime's provider env var (e.g. `GOOSE_PROVIDER`), so discovery + /// can recover the provider from the env when the resolver yields none. + /// `None` for runtimes that do not take a provider, or an unknown command. + pub(super) provider_env_var: Option<&'static str>, + /// The descriptor's fully layered env (definition/persona/global/agent). + pub(super) env: BTreeMap, +} + +/// Resolve the model-discovery config for a saved agent — the descriptor-backed +/// successor to the old `saved_agent_model_discovery_config`. +/// +/// Command/args/env come from `resolve_effective_harness_descriptor` (the same +/// resolver as `spawn_agent_child`); model/provider come from +/// `resolve_effective_model_provider` (#1968's definition-authoritative +/// contract) — linked instances read their definition, never a stale +/// materialized `record.model`/`record.provider`, so discovery cannot query a +/// provider this agent will not actually launch with. Definition-less +/// instances keep their own record values, matching spawn's +/// `resolve_definition_less` arm. When the resolver yields no provider, +/// `effective_discovery_provider` recovers the provider the agent will +/// actually launch with from the runtime's own provider env var, read out of +/// the descriptor env (which already layers definition/persona/global values +/// the same way spawn does). +/// +/// Returns `Err("DANGLING_HARNESS_ID:")` from the descriptor resolver when +/// the harness id no longer exists; the caller routes it through +/// `model_discovery_error`. +pub(super) fn agent_model_discovery_config( + record: &crate::managed_agents::ManagedAgentRecord, + personas: &[crate::managed_agents::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> Result { + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, personas, global)?; + let (model, provider) = + crate::managed_agents::resolve_effective_model_provider(record, personas, global); + let provider_env_var = + known_acp_runtime(&descriptor.command).and_then(|meta| meta.provider_env_var); + + Ok(AgentModelDiscoveryConfig { + command: descriptor.command, + args: descriptor.args, + model, + provider, + provider_env_var, + env: descriptor.env, + }) +} + +/// Derive the discovery env for an unsaved ("draft") agent configuration. +/// +/// Mirrors the layering `agent_model_discovery_config` takes from the harness +/// descriptor, but sources the provider from form input: runtime-derived +/// provider env var → definition env → user env vars, so user overrides always +/// win. Extracted so the draft path has the same tested seam as the saved one. +pub(super) fn draft_agent_model_discovery_env( + agent_command: &str, + provider: Option<&str>, + definition_env: &BTreeMap, + env_vars: &BTreeMap, +) -> BTreeMap { + let mut derived_env = BTreeMap::new(); + if let Some(meta) = known_acp_runtime(agent_command) { + let provider = provider.map(str::trim).filter(|value| !value.is_empty()); + if !meta.provider_locked { + if let (Some(env_key), Some(provider)) = (meta.provider_env_var, provider) { + derived_env.insert(env_key.to_string(), provider.to_string()); + } + } + } + // Reserved keys are stripped from definition env, matching the same filter + // applied at spawn. + let mut filtered_definition_env = BTreeMap::new(); + for (key, value) in definition_env { + if !crate::managed_agents::is_reserved_env_key(key) { + filtered_definition_env.insert(key.clone(), value.clone()); + } + } + let merged_with_def = + crate::managed_agents::merged_user_env(&derived_env, &filtered_definition_env); + crate::managed_agents::merged_user_env(&merged_with_def, env_vars) +} diff --git a/desktop/src-tauri/src/commands/agent_models_openrouter.rs b/desktop/src-tauri/src/commands/agent_models_openrouter.rs new file mode 100644 index 0000000000..be6dd2cf26 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_models_openrouter.rs @@ -0,0 +1,112 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::managed_agents::{AgentModelInfo, AgentModelsResponse}; + +#[cfg(test)] +use super::env_value; +use super::{env_or_process_value, redaction_env_with_value, DiscoveryProvider}; + +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(Clone))] +pub(super) struct OpenRouterModelListResponse { + pub data: Vec, +} + +#[derive(Debug, Deserialize)] +#[cfg_attr(test, derive(Clone))] +pub(super) struct OpenRouterModelListItem { + pub id: String, + #[serde(default)] + pub supported_parameters: Vec, +} + +pub(super) fn is_openrouter_provider(provider: Option<&str>) -> bool { + matches!( + provider + .map(str::trim) + .map(str::to_ascii_lowercase) + .as_deref(), + Some("openrouter") + ) +} + +#[cfg(test)] +pub(super) fn openrouter_models_url(env: &BTreeMap) -> String { + let base_url = env_value(env, "OPENROUTER_BASE_URL") + .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()); + format!("{}/models", base_url.trim_end_matches('/')) +} + +fn openrouter_models_url_for_discovery(env: &BTreeMap) -> String { + let base_url = env_or_process_value(env, "OPENROUTER_BASE_URL") + .unwrap_or_else(|| "https://openrouter.ai/api/v1".to_string()); + format!("{}/models", base_url.trim_end_matches('/')) +} + +pub(super) async fn discover_openrouter_models( + client: &reqwest::Client, + provider: &DiscoveryProvider, + env: &BTreeMap, + selected_model: Option, +) -> Result, String> { + if !is_openrouter_provider(provider.as_deref()) { + return Ok(None); + } + + let api_key = match provider.required_env(env, "OPENROUTER_API_KEY")? { + Some(api_key) => api_key, + None => return Ok(None), + }; + let redaction_env = redaction_env_with_value(env, "OPENROUTER_API_KEY", &api_key); + let url = openrouter_models_url_for_discovery(env); + let response = client + .get(&url) + .bearer_auth(&api_key) + .send() + .await + .map_err(|error| format!("OpenRouter model discovery request failed: {error}"))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let body = crate::managed_agents::redact_env_values_in(&body, &redaction_env); + return Err(format!("OpenRouter model discovery HTTP {status}: {body}")); + } + + let response = response + .json::() + .await + .map_err(|error| format!("OpenRouter model discovery response parse failed: {error}"))?; + + filter_openrouter_models(response, selected_model) +} + +pub(super) fn filter_openrouter_models( + response: OpenRouterModelListResponse, + selected_model: Option, +) -> Result, String> { + let models: Vec = response + .data + .into_iter() + .filter(|m| m.supported_parameters.iter().any(|p| p == "tools")) + .map(|m| AgentModelInfo { + id: m.id.clone(), + name: Some(m.id), + description: None, + }) + .collect(); + + if models.is_empty() { + return Err("OpenRouter model discovery returned no tools-capable models".to_string()); + } + + Ok(Some(AgentModelsResponse { + agent_name: "openrouter".to_string(), + agent_version: "models-api".to_string(), + models, + agent_default_model: None, + selected_model, + supports_switching: true, + })) +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index b65f240900..14c981d730 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -590,3 +590,294 @@ fn model_discovery_error_converts_dangling_sentinel_to_sentence() { let plain = model_discovery_error("agent-pk", "plain failure"); assert_eq!(plain, "cannot discover models for agent-pk: plain failure"); } + +// --------------------------------------------------------------------------- +// OpenRouter provider +// --------------------------------------------------------------------------- + +#[test] +fn is_openrouter_provider_matches() { + assert!(is_openrouter_provider(Some("openrouter"))); + assert!(is_openrouter_provider(Some(" OpenRouter "))); + assert!(!is_openrouter_provider(Some("openai"))); + assert!(!is_openrouter_provider(Some("anthropic"))); + assert!(!is_openrouter_provider(None)); +} + +#[test] +fn openrouter_models_url_uses_default_base_url() { + assert_eq!( + openrouter_models_url(&BTreeMap::new()), + "https://openrouter.ai/api/v1/models" + ); +} + +#[test] +fn openrouter_models_url_respects_custom_base_url() { + let env = BTreeMap::from([( + "OPENROUTER_BASE_URL".to_string(), + "https://eu.openrouter.ai/api/v1".to_string(), + )]); + assert_eq!( + openrouter_models_url(&env), + "https://eu.openrouter.ai/api/v1/models" + ); +} + +#[test] +fn openrouter_models_url_strips_trailing_slash() { + let env = BTreeMap::from([( + "OPENROUTER_BASE_URL".to_string(), + "https://proxy.example.com/api/v1/".to_string(), + )]); + assert_eq!( + openrouter_models_url(&env), + "https://proxy.example.com/api/v1/models" + ); +} + +#[test] +fn openrouter_filter_keeps_tools_capable_models() { + let response = OpenRouterModelListResponse { + data: vec![ + OpenRouterModelListItem { + id: "anthropic/claude-opus-4-7".to_string(), + supported_parameters: vec!["tools".to_string(), "reasoning".to_string()], + }, + OpenRouterModelListItem { + id: "openai/gpt-5.5-pro".to_string(), + supported_parameters: vec!["tools".to_string()], + }, + OpenRouterModelListItem { + id: "meta-llama/llama-no-tools".to_string(), + supported_parameters: vec!["temperature".to_string()], + }, + ], + }; + let result = filter_openrouter_models(response, None).unwrap().unwrap(); + let ids: Vec<_> = result.models.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(ids, vec!["anthropic/claude-opus-4-7", "openai/gpt-5.5-pro"]); +} + +#[test] +fn openrouter_filter_excludes_absent_supported_parameters() { + let response: OpenRouterModelListResponse = + serde_json::from_str(r#"{"data": [{"id": "model-no-params"}]}"#).unwrap(); + assert!( + response.data[0].supported_parameters.is_empty(), + "absent supported_parameters must default to empty vec" + ); + let result = filter_openrouter_models(response, None); + assert!( + result.is_err(), + "models with no supported_parameters must be excluded" + ); + assert!( + result.unwrap_err().contains("no tools-capable models"), + "error must indicate no tools-capable models" + ); +} + +#[test] +fn openrouter_filter_excludes_empty_supported_parameters() { + let response = OpenRouterModelListResponse { + data: vec![OpenRouterModelListItem { + id: "model-empty-params".to_string(), + supported_parameters: Vec::new(), + }], + }; + let result = filter_openrouter_models(response, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("no tools-capable models")); +} + +#[test] +fn openrouter_filter_empty_result_returns_error() { + let response = OpenRouterModelListResponse { data: Vec::new() }; + let result = filter_openrouter_models(response, None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("no tools-capable models")); +} + +#[test] +fn openrouter_filter_preserves_selected_model() { + let response = OpenRouterModelListResponse { + data: vec![OpenRouterModelListItem { + id: "openai/gpt-5.5-pro".to_string(), + supported_parameters: vec!["tools".to_string()], + }], + }; + let result = filter_openrouter_models(response, Some("openai/gpt-5.5-pro".to_string())) + .unwrap() + .unwrap(); + assert_eq!(result.selected_model.as_deref(), Some("openai/gpt-5.5-pro")); +} + +#[test] +fn openrouter_credential_redaction_env_records_key() { + let env = BTreeMap::from([( + "OPENROUTER_API_KEY".to_string(), + "sk-or-v1-secret-key-12345".to_string(), + )]); + let redaction = + redaction_env_with_value(&env, "OPENROUTER_API_KEY", "sk-or-v1-secret-key-12345"); + assert_eq!( + redaction.get("OPENROUTER_API_KEY").map(String::as_str), + Some("sk-or-v1-secret-key-12345"), + "redaction env must record the API key for error body redaction" + ); +} + +#[test] +fn openrouter_saved_agent_model_discovery_resolves_provider() { + let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "buzz-agent", + "agent_command_override": "buzz-agent", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": "anthropic/claude-sonnet-4", + "provider": "openrouter", + "env_vars": { + "OPENROUTER_API_KEY": "sk-or-test-key", + "BUZZ_PRIVATE_KEY": "must-not-leak" + }, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("sample openrouter managed agent record"); + + let discovery = agent_model_discovery_config( + &record, + &[], + &crate::managed_agents::GlobalAgentConfig::default(), + ) + .expect("discovery config should resolve for an openrouter record"); + assert_eq!(discovery.provider.as_deref(), Some("openrouter")); + assert_eq!( + discovery.model.as_deref(), + Some("anthropic/claude-sonnet-4") + ); + assert_eq!( + discovery.env.get("OPENROUTER_API_KEY").map(String::as_str), + Some("sk-or-test-key") + ); + assert!(!discovery.env.contains_key("BUZZ_PRIVATE_KEY")); +} + +/// B5/T4: unsaved-agent ("draft") discovery mirrors the saved-agent path — +/// `draft_agent_model_discovery_env` must derive the provider env var from +/// form input the same way `agent_model_discovery_config` derives it from a +/// persisted record's harness descriptor, and preserve caller-supplied env +/// (including the OpenRouter API key) unmodified. +#[test] +fn openrouter_draft_agent_model_discovery_derives_provider_env() { + let env_vars = BTreeMap::from([( + "OPENROUTER_API_KEY".to_string(), + "sk-or-draft-key".to_string(), + )]); + + let merged = draft_agent_model_discovery_env( + "buzz-agent", + Some("openrouter"), + &BTreeMap::new(), + &env_vars, + ); + + assert_eq!( + merged.get("BUZZ_AGENT_PROVIDER").map(String::as_str), + Some("openrouter"), + "provider env var must be derived from form input for a known ACP runtime" + ); + assert_eq!( + merged.get("OPENROUTER_API_KEY").map(String::as_str), + Some("sk-or-draft-key"), + "caller-supplied env vars must survive the merge" + ); +} + +#[test] +fn draft_agent_model_discovery_env_omits_provider_when_absent() { + let merged = + draft_agent_model_discovery_env("buzz-agent", None, &BTreeMap::new(), &BTreeMap::new()); + assert!( + !merged.contains_key("BUZZ_AGENT_PROVIDER"), + "no provider must be derived when the caller supplies none" + ); +} + +/// The three-tier precedence this merge exists to preserve: main's inline +/// `derived → definition_env → env_vars` layering was folded into +/// `draft_agent_model_discovery_env`, so pin the order at every collision +/// boundary rather than trusting the two single-tier tests above. +/// +/// `SHARED` collides across all three tiers, so the user value proves the +/// full chain; the pairwise keys prove each adjacent boundary independently +/// (a merge that dropped only the middle tier would still satisfy `SHARED`). +/// `BUZZ_PRIVATE_KEY` proves a reserved key cannot ride in on a harness +/// definition, which is the tier a user never types. +#[test] +fn draft_agent_model_discovery_env_layers_all_three_tiers_in_order() { + // Tier 2 (middle): harness definition env — overlays the runtime-derived + // floor, loses to user env. + let definition_env = BTreeMap::from([ + ("SHARED".to_string(), "from-definition".to_string()), + // Collides with tier 1: `buzz-agent`'s own provider env var, which the + // `provider` argument derives below. + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ("USER_OVER_DEF".to_string(), "from-definition".to_string()), + ("DEFINITION_ONLY".to_string(), "from-definition".to_string()), + // Reserved: must never reach the child, even from a definition. + ("BUZZ_PRIVATE_KEY".to_string(), "must-not-leak".to_string()), + ]); + // Tier 3 (top): user-entered env — wins over everything. + let env_vars = BTreeMap::from([ + ("SHARED".to_string(), "from-user".to_string()), + ("USER_OVER_DEF".to_string(), "from-user".to_string()), + ("USER_ONLY".to_string(), "from-user".to_string()), + ]); + + // Tier 1 (floor): `Some("openrouter")` derives BUZZ_AGENT_PROVIDER. + let merged = draft_agent_model_discovery_env( + "buzz-agent", + Some("openrouter"), + &definition_env, + &env_vars, + ); + + let expected: &[(&str, Option<&str>)] = &[ + // Collides in all three tiers — the top tier wins. + ("SHARED", Some("from-user")), + // Tier 2 over tier 1: the definition's value survives, proving the + // derived provider is the floor and not layered on top. + ("BUZZ_AGENT_PROVIDER", Some("openai")), + // Tier 3 over tier 2. + ("USER_OVER_DEF", Some("from-user")), + // Single-tier keys pass through untouched. + ("DEFINITION_ONLY", Some("from-definition")), + ("USER_ONLY", Some("from-user")), + // Reserved keys never survive the definition tier. Doubly enforced — + // the explicit `is_reserved_env_key` filter here and `merged_user_env`'s + // own `retain` — so this pins the contract, not either mechanism. + ("BUZZ_PRIVATE_KEY", None), + ]; + for (key, want) in expected { + assert_eq!( + merged.get(*key).map(String::as_str), + *want, + "env key `{key}` must resolve to {want:?} after three-tier layering" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c053d933c5..fa8eb36fa1 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -481,6 +481,7 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { } Some("anthropic") => Some("ANTHROPIC_MODEL"), Some("openai") | Some("openai-compat") => Some("OPENAI_COMPAT_MODEL"), + Some("openrouter") => Some("OPENROUTER_MODEL"), _ => None, }; let model_present = effective @@ -523,6 +524,12 @@ fn buzz_agent_requirements(effective: &EffectiveAgentEnv) -> Vec { key: "DATABRICKS_HOST".to_string(), }); } + Some("openrouter") + if env_key_missing("OPENROUTER_API_KEY") => { + missing.push(Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string(), + }); + } _ => { // Unknown provider or no provider yet — only the NormalizedField // requirement above captures this gap. @@ -630,6 +637,13 @@ fn goose_requirements( key: "DATABRICKS_HOST".to_string(), }); } + Some("openrouter") + if env_key_missing("OPENROUTER_API_KEY") && !file_key_present("OPENROUTER_API_KEY") => + { + missing.push(Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string(), + }); + } _ => {} } @@ -1668,195 +1682,62 @@ mod tests { field: "model".to_string() })); } -} - -// ── goose file-config–aware requirement tests ───────────────────────────── -// -// These tests call `goose_requirements` directly, injecting a synthetic -// `RuntimeFileConfig` so there is no disk I/O and tests are deterministic. - -#[cfg(test)] -mod goose_file_config_tests { - use std::collections::BTreeMap; - - use super::*; - use crate::managed_agents::config_bridge::RuntimeFileConfig; - - fn empty_env() -> EffectiveAgentEnv { - EffectiveAgentEnv { - env: BTreeMap::new(), - config_file_path: Some("~/.config/goose/config.yaml"), - effective_command: "goose".to_string(), - } - } - fn env_with(pairs: &[(&str, &str)]) -> EffectiveAgentEnv { - EffectiveAgentEnv { - env: pairs - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(), - config_file_path: Some("~/.config/goose/config.yaml"), - effective_command: "goose".to_string(), - } - } - - fn databricks_file_config() -> RuntimeFileConfig { - let mut extra = BTreeMap::new(); - extra.insert( - "DATABRICKS_HOST".to_string(), - "https://dbc.example.com".to_string(), - ); - RuntimeFileConfig { - provider: Some("databricks_v2".to_string()), - model: Some("goose-claude-4-6-opus".to_string()), - extra, - ..Default::default() - } - } + // ── OpenRouter readiness ───────────────────────────────────────────── #[test] - fn goose_file_config_silences_databricks_host_requirement() { - // File has provider, model, and DATABRICKS_HOST — all requirements silenced. - let env = empty_env(); - let cfg = databricks_file_config(); - let result = goose_requirements(&env, Some(&cfg)); - assert!( - result.is_empty(), - "all requirements should be silenced by goose file config; \ - got: {:?}", - result - ); - } - - #[test] - fn goose_env_empty_file_absent_still_not_ready() { - // No env, no file config → provider and model both required. - let env = empty_env(); - let result = goose_requirements(&env, None); - assert!( - result.contains(&Requirement::NormalizedField { - field: "provider".to_string() - }), - "provider must be required when absent from both env and file" - ); - assert!( - result.contains(&Requirement::NormalizedField { - field: "model".to_string() - }), - "model must be required when absent from both env and file" - ); - } - - #[test] - fn goose_file_config_silences_provider_and_model_but_not_anthropic_key() { - // File has provider=anthropic and model, but ANTHROPIC_API_KEY is not - // in the file's `extra` map — it must still be required. - let cfg = RuntimeFileConfig { - provider: Some("anthropic".to_string()), - model: Some("claude-opus-4-5".to_string()), - extra: BTreeMap::new(), - ..Default::default() - }; - let env = empty_env(); - let result = goose_requirements(&env, Some(&cfg)); - // Provider and model silenced. - assert!( - !result.contains(&Requirement::NormalizedField { - field: "provider".to_string() - }), - "provider silenced by file config" - ); - assert!( - !result.contains(&Requirement::NormalizedField { - field: "model".to_string() - }), - "model silenced by file config" + fn buzz_agent_openrouter_with_all_fields_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), ); - // ANTHROPIC_API_KEY not in file extra → still required. + let result = agent_readiness(&env); assert!( - result.contains(&Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string() - }), - "ANTHROPIC_API_KEY must remain required when not in file extra" + result.is_ready(), + "openrouter with all fields should be ready" ); } #[test] - fn goose_env_provider_wins_over_file_provider_for_cred_check() { - // Env has GOOSE_PROVIDER=anthropic (different from file's databricks_v2). - // The env provider must win for credential checking. - let env = env_with(&[ - ("GOOSE_PROVIDER", "anthropic"), - ("GOOSE_MODEL", "claude-opus-4-5"), - ]); - let cfg = databricks_file_config(); // has provider=databricks_v2 - let result = goose_requirements(&env, Some(&cfg)); - // anthropic requires ANTHROPIC_API_KEY, not DATABRICKS_HOST. - assert!( - result.contains(&Requirement::EnvKey { - key: "ANTHROPIC_API_KEY".to_string() - }), - "env provider=anthropic must require ANTHROPIC_API_KEY" - ); - assert!( - !result.contains(&Requirement::EnvKey { - key: "DATABRICKS_HOST".to_string() - }), - "env provider=anthropic must NOT require DATABRICKS_HOST" + fn buzz_agent_openrouter_missing_key_returns_not_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("BUZZ_AGENT_MODEL", "anthropic/claude-sonnet-4"), + ]), ); + let result = agent_readiness(&env); + assert!(!result.is_ready()); + assert!(result.requirements().contains(&Requirement::EnvKey { + key: "OPENROUTER_API_KEY".to_string() + })); } #[test] - fn goose_flat_databricks_host_in_file_config_silences_requirement() { - // Will's typical goose config: flat DATABRICKS_HOST at the top level, - // no active_provider — provider inferred as "databricks". - // The parser must store extra["DATABRICKS_HOST"] = value (canonical key), - // and goose_requirements must then silence the DATABRICKS_HOST requirement. - let mut extra = BTreeMap::new(); - extra.insert( - "DATABRICKS_HOST".to_string(), - "https://block.cloud.databricks.com".to_string(), - ); - let cfg = RuntimeFileConfig { - provider: Some("databricks".to_string()), - model: Some("goose-claude-4-5".to_string()), - extra, - ..Default::default() - }; - let env = empty_env(); - let result = goose_requirements(&env, Some(&cfg)); - // All requirements silenced — provider (file), model (file), DATABRICKS_HOST (file). - assert!( - result.is_empty(), - "flat DATABRICKS_HOST in file config must silence all requirements; \ - got: {:?}", - result + fn buzz_agent_openrouter_with_provider_model_fallback_is_ready() { + let env = make_env( + "buzz-agent", + env_with(&[ + ("BUZZ_AGENT_PROVIDER", "openrouter"), + ("OPENROUTER_MODEL", "google/gemini-2.5-flash"), + ("OPENROUTER_API_KEY", "sk-or-test-key"), + ]), ); - } - - #[test] - fn goose_goose_provider_databricks_flat_host_silences_databricks_host() { - // GOOSE_PROVIDER=databricks (not active_provider) + flat DATABRICKS_HOST. - // The parser canonicalizes to extra["DATABRICKS_HOST"]; readiness must silence it. - let mut extra = BTreeMap::new(); - extra.insert( - "DATABRICKS_HOST".to_string(), - "https://dbc.example.com".to_string(), - ); - let cfg = RuntimeFileConfig { - provider: Some("databricks".to_string()), - model: Some("some-model".to_string()), - extra, - ..Default::default() - }; - let env = empty_env(); - let result = goose_requirements(&env, Some(&cfg)); + let result = agent_readiness(&env); assert!( - !result.contains(&Requirement::EnvKey { - key: "DATABRICKS_HOST".to_string() - }), - "DATABRICKS_HOST must be silenced when canonical key is in file extra" + result.is_ready(), + "OPENROUTER_MODEL fallback should satisfy model requirement" ); } } + +// Goose file-config-aware requirement tests live in a sibling file so this +// module stays under the desktop file-size ratchet. +#[cfg(test)] +#[path = "readiness_goose_file_config_tests.rs"] +mod goose_file_config_tests; diff --git a/desktop/src-tauri/src/managed_agents/readiness_goose_file_config_tests.rs b/desktop/src-tauri/src/managed_agents/readiness_goose_file_config_tests.rs new file mode 100644 index 0000000000..46d0e4c7a7 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/readiness_goose_file_config_tests.rs @@ -0,0 +1,190 @@ +//! Goose file-config-aware requirement tests. +//! +//! These tests call `goose_requirements` directly, injecting a synthetic +//! `RuntimeFileConfig` so there is no disk I/O and tests are deterministic. +//! +//! Included from `readiness.rs` via `#[path]`; `super::*` therefore resolves +//! against that module, matching the `storage_tests.rs` convention. + +use std::collections::BTreeMap; + +use super::*; +use crate::managed_agents::config_bridge::RuntimeFileConfig; + +fn empty_env() -> EffectiveAgentEnv { + EffectiveAgentEnv { + env: BTreeMap::new(), + config_file_path: Some("~/.config/goose/config.yaml"), + effective_command: "goose".to_string(), + } +} + +fn env_with(pairs: &[(&str, &str)]) -> EffectiveAgentEnv { + EffectiveAgentEnv { + env: pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + config_file_path: Some("~/.config/goose/config.yaml"), + effective_command: "goose".to_string(), + } +} + +fn databricks_file_config() -> RuntimeFileConfig { + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://dbc.example.com".to_string(), + ); + RuntimeFileConfig { + provider: Some("databricks_v2".to_string()), + model: Some("goose-claude-4-6-opus".to_string()), + extra, + ..Default::default() + } +} + +#[test] +fn goose_file_config_silences_databricks_host_requirement() { + // File has provider, model, and DATABRICKS_HOST — all requirements silenced. + let env = empty_env(); + let cfg = databricks_file_config(); + let result = goose_requirements(&env, Some(&cfg)); + assert!( + result.is_empty(), + "all requirements should be silenced by goose file config; \ + got: {:?}", + result + ); +} + +#[test] +fn goose_env_empty_file_absent_still_not_ready() { + // No env, no file config → provider and model both required. + let env = empty_env(); + let result = goose_requirements(&env, None); + assert!( + result.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "provider must be required when absent from both env and file" + ); + assert!( + result.contains(&Requirement::NormalizedField { + field: "model".to_string() + }), + "model must be required when absent from both env and file" + ); +} + +#[test] +fn goose_file_config_silences_provider_and_model_but_not_anthropic_key() { + // File has provider=anthropic and model, but ANTHROPIC_API_KEY is not + // in the file's `extra` map — it must still be required. + let cfg = RuntimeFileConfig { + provider: Some("anthropic".to_string()), + model: Some("claude-opus-4-5".to_string()), + extra: BTreeMap::new(), + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + // Provider and model silenced. + assert!( + !result.contains(&Requirement::NormalizedField { + field: "provider".to_string() + }), + "provider silenced by file config" + ); + assert!( + !result.contains(&Requirement::NormalizedField { + field: "model".to_string() + }), + "model silenced by file config" + ); + // ANTHROPIC_API_KEY not in file extra → still required. + assert!( + result.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "ANTHROPIC_API_KEY must remain required when not in file extra" + ); +} + +#[test] +fn goose_env_provider_wins_over_file_provider_for_cred_check() { + // Env has GOOSE_PROVIDER=anthropic (different from file's databricks_v2). + // The env provider must win for credential checking. + let env = env_with(&[ + ("GOOSE_PROVIDER", "anthropic"), + ("GOOSE_MODEL", "claude-opus-4-5"), + ]); + let cfg = databricks_file_config(); // has provider=databricks_v2 + let result = goose_requirements(&env, Some(&cfg)); + // anthropic requires ANTHROPIC_API_KEY, not DATABRICKS_HOST. + assert!( + result.contains(&Requirement::EnvKey { + key: "ANTHROPIC_API_KEY".to_string() + }), + "env provider=anthropic must require ANTHROPIC_API_KEY" + ); + assert!( + !result.contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + }), + "env provider=anthropic must NOT require DATABRICKS_HOST" + ); +} + +#[test] +fn goose_flat_databricks_host_in_file_config_silences_requirement() { + // Will's typical goose config: flat DATABRICKS_HOST at the top level, + // no active_provider — provider inferred as "databricks". + // The parser must store extra["DATABRICKS_HOST"] = value (canonical key), + // and goose_requirements must then silence the DATABRICKS_HOST requirement. + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://block.cloud.databricks.com".to_string(), + ); + let cfg = RuntimeFileConfig { + provider: Some("databricks".to_string()), + model: Some("goose-claude-4-5".to_string()), + extra, + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + // All requirements silenced — provider (file), model (file), DATABRICKS_HOST (file). + assert!( + result.is_empty(), + "flat DATABRICKS_HOST in file config must silence all requirements; \ + got: {:?}", + result + ); +} + +#[test] +fn goose_goose_provider_databricks_flat_host_silences_databricks_host() { + // GOOSE_PROVIDER=databricks (not active_provider) + flat DATABRICKS_HOST. + // The parser canonicalizes to extra["DATABRICKS_HOST"]; readiness must silence it. + let mut extra = BTreeMap::new(); + extra.insert( + "DATABRICKS_HOST".to_string(), + "https://dbc.example.com".to_string(), + ); + let cfg = RuntimeFileConfig { + provider: Some("databricks".to_string()), + model: Some("some-model".to_string()), + extra, + ..Default::default() + }; + let env = empty_env(); + let result = goose_requirements(&env, Some(&cfg)); + assert!( + !result.contains(&Requirement::EnvKey { + key: "DATABRICKS_HOST".to_string() + }), + "DATABRICKS_HOST must be silenced when canonical key is in file extra" + ); +} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 1313d2cec4..d51c970f29 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -42,6 +42,7 @@ const KNOWN_LLM_PROVIDER_IDS = [ "databricks_v2", "openai", "openai-compat", + "openrouter", ] as const; type PersonaLlmProviderId = (typeof KNOWN_LLM_PROVIDER_IDS)[number]; @@ -109,6 +110,10 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< "databricks-v2": { requiredEnvKeys: ["DATABRICKS_HOST"], }, + openrouter: { + requiredEnvKeys: ["OPENROUTER_API_KEY"], + secretEnvVar: "OPENROUTER_API_KEY", + }, }; const DEFAULT_MODEL_OPTION: PersonaModelOption = { @@ -120,6 +125,7 @@ export const PERSONA_LLM_PROVIDER_OPTIONS: readonly PersonaModelOption[] = [ { id: "anthropic", label: "Anthropic" }, { id: "openai", label: "OpenAI" }, { id: "openai-compat", label: "OpenAI-compatible" }, + { id: "openrouter", label: "OpenRouter" }, { id: "relay-mesh", label: "Buzz shared compute" }, { id: "databricks", label: "Databricks" }, { id: "databricks_v2", label: "Databricks v2" }, @@ -279,7 +285,8 @@ export function providerRequiresExplicitModel( return ( trimmedProvider === "anthropic" || trimmedProvider === "openai" || - trimmedProvider === "openai-compat" + trimmedProvider === "openai-compat" || + trimmedProvider === "openrouter" ); } diff --git a/desktop/src/features/agents/ui/buzzAgentConfig.ts b/desktop/src/features/agents/ui/buzzAgentConfig.ts index a0271fec0f..be663c35cb 100644 --- a/desktop/src/features/agents/ui/buzzAgentConfig.ts +++ b/desktop/src/features/agents/ui/buzzAgentConfig.ts @@ -128,6 +128,9 @@ export function getProviderEffortConfig( // databricks v1 uses OpenAI Chat Completions wire format. return openaiConfig(m); } + if (provider === "openrouter") { + return { validValues: ALL_VALUES, defaultValue: "medium" }; + } // openai-compat, unknown, empty — all values, default medium. return { validValues: ALL_VALUES, defaultValue: "medium" }; } diff --git a/desktop/src/features/agents/ui/effortTable.fixture.json b/desktop/src/features/agents/ui/effortTable.fixture.json index defb1f86de..d097bc995f 100644 --- a/desktop/src/features/agents/ui/effortTable.fixture.json +++ b/desktop/src/features/agents/ui/effortTable.fixture.json @@ -209,6 +209,13 @@ "validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], "defaultValue": "medium" }, + { + "note": "openrouter: all-7 with medium default", + "provider": "openrouter", + "model": "", + "validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], + "defaultValue": "medium" + }, { "note": "empty provider: all-7 with medium default", "provider": "", From 7012d86d52fd188b27c7beedeaa132d9c1f61fa8 Mon Sep 17 00:00:00 2001 From: Kalvin C Date: Wed, 29 Jul 2026 17:00:41 -0700 Subject: [PATCH 46/99] feat: configure S3 URL addressing style (#3400) ## Summary - add one strict `BUZZ_S3_ADDRESSING_STYLE=path|virtual` setting shared by media and Git/CAS storage - preserve path-style defaults for bundled Compose/Helm MinIO while supporting Railway's virtual-hosted bucket contract - fail startup on invalid or non-Unicode values before dependency connection, and validate the Helm value with the same two choices - document operator mappings and why endpoint and bucket remain separate for routing and SigV4 signing ## Best-practice rationale AWS documents both URL forms and favors virtual-hosted addressing for S3, while compatibility endpoints such as the bundled MinIO deployment can require path style. `rust-s3` defaults to virtual/subdomain addressing and provides `with_path_style()` for the explicit compatibility case. Some providers buckets only support as virtual-hosted bucket styles. This PR therefore uses one explicit, provider-neutral switch rather than endpoint heuristics or fallback behavior, while retaining `path` as Buzz's backward-compatible default. Sources: - https://docs.aws.amazon.com/AmazonS3/latest/userguide/VirtualHosting.html - https://docs.rs/rust-s3/0.37.0/s3/bucket/struct.Bucket.html - https://docs.railway.com/storage-buckets#url-style - https://github.com/minio/minio/blob/master/docs/config/README.md#domain ## Validation - `cargo fmt --all` - `cargo check --workspace --all-targets` - targeted `buzz-media` and `buzz-relay` parsing/client-construction tests for defaults, strict errors, and both URL styles - Helm unittest: 45/45 passed - Compose config/render validation passed - local MinIO path-mode relay startup passed the Git A3 conformance probe and became ready - unreachable object storage failed startup and readiness never opened - push hooks completed the broader Rust and desktop suites successfully --------- Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> --- .env.example | 13 +++ Cargo.lock | 1 + crates/buzz-media/src/config.rs | 69 +++++++++++++- crates/buzz-media/src/lib.rs | 2 +- crates/buzz-media/src/storage.rs | 27 +++++- crates/buzz-media/src/upload.rs | 1 + crates/buzz-media/src/validation.rs | 1 + crates/buzz-media/tests/static_creds_minio.rs | 13 ++- crates/buzz-relay/src/api/git/cas_publish.rs | 25 ++---- crates/buzz-relay/src/api/git/hydrate.rs | 14 ++- crates/buzz-relay/src/api/git/store.rs | 70 ++++++++++++--- crates/buzz-relay/src/config.rs | 71 +++++++++++++++ crates/buzz-relay/src/state.rs | 1 + crates/buzz-test-client/Cargo.toml | 1 + crates/buzz-test-client/tests/e2e_git.rs | 90 +++++++++++++++---- deploy/charts/buzz/README.md | 42 +++++++++ deploy/charts/buzz/examples/argocd-app.yaml | 2 + .../buzz/examples/flux-helmrelease.yaml | 2 + deploy/charts/buzz/templates/_validate.tpl | 8 +- deploy/charts/buzz/templates/deployment.yaml | 4 + deploy/charts/buzz/tests/render_test.yaml | 38 ++++++++ deploy/charts/buzz/tests/validation_test.yaml | 11 +++ deploy/charts/buzz/values.schema.json | 9 ++ deploy/charts/buzz/values.yaml | 6 ++ deploy/compose/.env.example | 2 + deploy/compose/README.md | 5 ++ deploy/compose/compose.yml | 2 + .../src/commands/media_snapshot_png.rs | 1 + 28 files changed, 471 insertions(+), 60 deletions(-) diff --git a/.env.example b/.env.example index 3dc54856e7..b9bfcada0e 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,19 @@ RELAY_URL=ws://localhost:3000 # BUZZ_GIT_PACK_CACHE_MAX_BYTES=5368709120 # BUZZ_GIT_PACK_CACHE_MAX_CONCURRENT_POPULATIONS=2 +# ----------------------------------------------------------------------------- +# S3-Compatible Object Storage (media + Git/CAS) +# ----------------------------------------------------------------------------- +# The local MinIO container is reachable from host processes at localhost:9000. +# Path style keeps the bucket in the URL path and is required by this local DNS +# setup. Use `virtual` only when the provider requires bucket-as-subdomain URLs. +BUZZ_S3_ENDPOINT=http://localhost:9000 +BUZZ_S3_ACCESS_KEY=buzz_dev +BUZZ_S3_SECRET_KEY=buzz_dev_secret +BUZZ_S3_BUCKET=buzz-media +BUZZ_S3_REGION=us-east-1 +BUZZ_S3_ADDRESSING_STYLE=path + # ----------------------------------------------------------------------------- # Media Upload Admission # ----------------------------------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock index 3b60dc4579..c3ea86d6b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1239,6 +1239,7 @@ dependencies = [ "anyhow", "base64", "buzz-core", + "buzz-media", "buzz-sdk", "buzz-ws-client", "chrono", diff --git a/crates/buzz-media/src/config.rs b/crates/buzz-media/src/config.rs index 047c08475e..3c70e4afe1 100644 --- a/crates/buzz-media/src/config.rs +++ b/crates/buzz-media/src/config.rs @@ -1,5 +1,38 @@ //! Media storage configuration. +use std::str::FromStr; + +/// S3 URL addressing style shared by media and Git/CAS storage. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum S3AddressingStyle { + /// Put the bucket in the request path (`https://endpoint/bucket/key`). + /// + /// This preserves compatibility with the bundled MinIO deployments, whose + /// internal DNS only resolves the endpoint hostname. + #[default] + Path, + /// Put the bucket in the hostname (`https://bucket.endpoint/key`). + /// + /// This is the standard S3 form and is required by providers such as new + /// Railway Storage Buckets. + Virtual, +} + +impl FromStr for S3AddressingStyle { + type Err = String; + + fn from_str(value: &str) -> Result { + match value { + "path" => Ok(Self::Path), + "virtual" => Ok(Self::Virtual), + _ => Err(format!( + "BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual', got {value:?}" + )), + } + } +} + fn default_max_video_bytes() -> u64 { 524_288_000 // 500 MB } @@ -31,6 +64,9 @@ pub struct MediaConfig { /// the value is not meaningfully checked. #[serde(default = "default_s3_region")] pub s3_region: String, + /// S3 URL addressing style. Defaults to path style for MinIO compatibility. + #[serde(default)] + pub s3_addressing_style: S3AddressingStyle, /// Maximum upload size for images (bytes). Default: 50 MB. pub max_image_bytes: u64, /// Maximum upload size for animated GIFs (bytes). Default: 10 MB. @@ -123,7 +159,8 @@ impl MediaConfig { #[cfg(test)] mod tests { - use super::MediaConfig; + use super::{MediaConfig, S3AddressingStyle}; + use std::str::FromStr; fn valid_config() -> MediaConfig { MediaConfig { @@ -132,6 +169,7 @@ mod tests { s3_secret_key: "s".to_string(), s3_bucket: "buzz-media".to_string(), s3_region: "us-east-1".to_string(), + s3_addressing_style: S3AddressingStyle::Path, max_image_bytes: 1, max_gif_bytes: 1, max_video_bytes: 1, @@ -143,6 +181,35 @@ mod tests { } } + #[test] + fn addressing_style_parses_supported_values() { + assert_eq!( + S3AddressingStyle::from_str("path"), + Ok(S3AddressingStyle::Path) + ); + assert_eq!( + S3AddressingStyle::from_str("virtual"), + Ok(S3AddressingStyle::Virtual) + ); + } + + #[test] + fn addressing_style_defaults_to_path() { + assert_eq!(S3AddressingStyle::default(), S3AddressingStyle::Path); + } + + #[test] + fn addressing_style_rejects_unknown_or_ambiguous_values() { + for invalid in ["", "auto", "PATH", "virtual-hosted"] { + let error = + S3AddressingStyle::from_str(invalid).expect_err("must reject invalid style"); + assert!( + error.contains("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'"), + "unexpected error for {invalid:?}: {error}" + ); + } + } + #[test] fn upload_record_knobs_default_off_and_validate() { assert!(valid_config().validate().is_ok()); diff --git a/crates/buzz-media/src/lib.rs b/crates/buzz-media/src/lib.rs index ac05ea6d51..67896d4ef2 100644 --- a/crates/buzz-media/src/lib.rs +++ b/crates/buzz-media/src/lib.rs @@ -17,7 +17,7 @@ pub use bucket_index::{ classify_key, fold_bucket_listing, BucketAggregate, BucketSnapshot, CommunityStorage, KeyClass, Page, SweepError, }; -pub use config::MediaConfig; +pub use config::{MediaConfig, S3AddressingStyle}; pub use error::MediaError; pub use storage::{BlobHeadMeta, BlobMeta, ByteStream, MediaStorage}; pub use types::BlobDescriptor; diff --git a/crates/buzz-media/src/storage.rs b/crates/buzz-media/src/storage.rs index 0e9809af2f..cbf980201f 100644 --- a/crates/buzz-media/src/storage.rs +++ b/crates/buzz-media/src/storage.rs @@ -5,7 +5,7 @@ use std::pin::Pin; use buzz_core::tenant::{CommunityId, TenantContext}; -use crate::config::MediaConfig; +use crate::config::{MediaConfig, S3AddressingStyle}; use crate::error::MediaError; use bytes::Bytes; use s3::creds::Credentials; @@ -61,8 +61,11 @@ impl MediaStorage { } .map_err(|e| MediaError::StorageError(e.to_string()))?; let bucket = Bucket::new(&config.s3_bucket, region, creds) - .map_err(|e| MediaError::StorageError(e.to_string()))? - .with_path_style(); + .map_err(|e| MediaError::StorageError(e.to_string()))?; + let bucket = match config.s3_addressing_style { + S3AddressingStyle::Path => bucket.with_path_style(), + S3AddressingStyle::Virtual => bucket, + }; Ok(Self { bucket }) } @@ -285,6 +288,7 @@ mod tests { s3_secret_key: secret.to_string(), s3_bucket: "buzz-media".to_string(), s3_region: "us-west-2".to_string(), + s3_addressing_style: S3AddressingStyle::Path, max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, @@ -309,6 +313,23 @@ mod tests { } } + #[test] + fn client_constructor_applies_both_addressing_styles() { + let path = MediaStorage::new(&storage_config("buzz_dev", "buzz_dev_secret")) + .expect("path-style client"); + assert!(path.bucket.is_path_style()); + assert_eq!(path.bucket.url(), "http://localhost:9000/buzz-media"); + + let mut virtual_config = storage_config("buzz_dev", "buzz_dev_secret"); + virtual_config.s3_addressing_style = S3AddressingStyle::Virtual; + let virtual_hosted = MediaStorage::new(&virtual_config).expect("virtual-hosted client"); + assert!(virtual_hosted.bucket.is_subdomain_style()); + assert_eq!( + virtual_hosted.bucket.url(), + "http://buzz-media.localhost:9000" + ); + } + #[test] fn partial_static_keys_are_rejected() { let err = match MediaStorage::new(&storage_config("buzz_dev", "")) { diff --git a/crates/buzz-media/src/upload.rs b/crates/buzz-media/src/upload.rs index 478ac114ef..524b033280 100644 --- a/crates/buzz-media/src/upload.rs +++ b/crates/buzz-media/src/upload.rs @@ -570,6 +570,7 @@ mod tests { s3_secret_key: String::new(), s3_bucket: String::new(), s3_region: "us-east-1".to_string(), + s3_addressing_style: crate::config::S3AddressingStyle::Path, max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, diff --git a/crates/buzz-media/src/validation.rs b/crates/buzz-media/src/validation.rs index ee940dfb24..f1387fc9d6 100644 --- a/crates/buzz-media/src/validation.rs +++ b/crates/buzz-media/src/validation.rs @@ -949,6 +949,7 @@ mod tests { s3_secret_key: String::new(), s3_bucket: String::new(), s3_region: "us-east-1".to_string(), + s3_addressing_style: crate::config::S3AddressingStyle::Path, max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, diff --git a/crates/buzz-media/tests/static_creds_minio.rs b/crates/buzz-media/tests/static_creds_minio.rs index d7591238c2..4c8c10702c 100644 --- a/crates/buzz-media/tests/static_creds_minio.rs +++ b/crates/buzz-media/tests/static_creds_minio.rs @@ -1,5 +1,5 @@ -//! Live round-trip test for the **static-credentials** S3 path against a local -//! MinIO, guarded by `#[ignore]`. +//! Live round-trip test for the **static-credentials** S3 path against an +//! S3-compatible service. It is guarded by `#[ignore]`. //! //! This is the path local/dev and any static-key deployment uses //! (`s3_access_key`/`s3_secret_key` both non-empty -> `Credentials::new`). It @@ -15,7 +15,8 @@ //! ``` //! //! Overridable via `BUZZ_S3_ENDPOINT` / `BUZZ_S3_ACCESS_KEY` / -//! `BUZZ_S3_SECRET_KEY` / `BUZZ_S3_BUCKET`. +//! `BUZZ_S3_SECRET_KEY` / `BUZZ_S3_BUCKET` / `BUZZ_S3_REGION` / +//! `BUZZ_S3_ADDRESSING_STYLE`. The default remains `path` for MinIO. use buzz_media::config::MediaConfig; use buzz_media::storage::MediaStorage; @@ -29,7 +30,11 @@ fn minio_config() -> MediaConfig { s3_secret_key: std::env::var("BUZZ_S3_SECRET_KEY") .unwrap_or_else(|_| "buzz_dev_secret".to_string()), s3_bucket: std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string()), - s3_region: "us-east-1".to_string(), + s3_region: std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()), + s3_addressing_style: std::env::var("BUZZ_S3_ADDRESSING_STYLE") + .unwrap_or_else(|_| "path".to_string()) + .parse() + .expect("BUZZ_S3_ADDRESSING_STYLE must be path or virtual"), max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index 635dcf2a67..c213e2913e 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -1583,22 +1583,15 @@ mod tests { } fn live_store() -> GitStore { - let endpoint = std::env::var("BUZZ_GIT_S3_ENDPOINT") - .or_else(|_| std::env::var("BUZZ_S3_ENDPOINT")) - .unwrap_or_else(|_| "http://localhost:9000".into()); - let access_key = std::env::var("BUZZ_GIT_S3_ACCESS_KEY") - .or_else(|_| std::env::var("BUZZ_S3_ACCESS_KEY")) - .unwrap_or_else(|_| "buzz_dev".into()); - let secret_key = std::env::var("BUZZ_GIT_S3_SECRET_KEY") - .or_else(|_| std::env::var("BUZZ_S3_SECRET_KEY")) - .unwrap_or_else(|_| "buzz_dev_secret".into()); - let bucket = std::env::var("BUZZ_GIT_S3_BUCKET") - .or_else(|_| std::env::var("BUZZ_S3_BUCKET")) - .unwrap_or_else(|_| "buzz-media".into()); - let region = std::env::var("BUZZ_GIT_S3_REGION") - .or_else(|_| std::env::var("BUZZ_S3_REGION")) - .unwrap_or_else(|_| "us-east-1".into()); - GitStore::new(&endpoint, &access_key, &secret_key, &bucket, ®ion).expect("connect minio") + GitStore::new( + "http://localhost:9000", + "buzz_dev", + "buzz_dev_secret", + "buzz-media", + "us-east-1", + buzz_media::config::S3AddressingStyle::Path, + ) + .expect("connect local MinIO") } fn tenant() -> TenantContext { diff --git a/crates/buzz-relay/src/api/git/hydrate.rs b/crates/buzz-relay/src/api/git/hydrate.rs index 064d01923e..3ce809d18f 100644 --- a/crates/buzz-relay/src/api/git/hydrate.rs +++ b/crates/buzz-relay/src/api/git/hydrate.rs @@ -543,8 +543,15 @@ mod tests { #[tokio::test] async fn materialized_repo_is_created_under_configured_scratch_dir() { let scratch = TempDir::new().unwrap(); - let store = GitStore::new("http://localhost:9000", "x", "x", "x", "us-east-1") - .expect("construct store"); + let store = GitStore::new( + "http://localhost:9000", + "x", + "x", + "x", + "us-east-1", + buzz_media::config::S3AddressingStyle::Path, + ) + .expect("construct store"); let manifest = Manifest { version: 1, head: "refs/heads/main".into(), @@ -587,8 +594,9 @@ mod tests { "buzz_dev_secret", "buzz-git", "us-east-1", + buzz_media::config::S3AddressingStyle::Path, ) - .expect("connect minio") + .expect("connect local MinIO") } /// Build a tiny on-disk repo, return (pack bytes, head_oid). diff --git a/crates/buzz-relay/src/api/git/store.rs b/crates/buzz-relay/src/api/git/store.rs index 43d210e648..bdfca8dcf2 100644 --- a/crates/buzz-relay/src/api/git/store.rs +++ b/crates/buzz-relay/src/api/git/store.rs @@ -174,7 +174,9 @@ pub struct GitStore { impl GitStore { /// Build a client against an S3-compatible endpoint (e.g. MinIO). /// - /// Uses path-style addressing for MinIO compatibility; AWS S3 accepts both. + /// `addressing_style` is shared with media storage so both paths sign and + /// route requests consistently. Path style supports the bundled MinIO DNS; + /// virtual-hosted style supports standard S3 and providers such as Railway. /// /// Credential selection mirrors [`buzz_media::MediaStorage::new`]: /// - both `access_key` and `secret_key` non-empty → static credentials @@ -190,6 +192,7 @@ impl GitStore { secret_key: &str, bucket_name: &str, region: &str, + addressing_style: buzz_media::config::S3AddressingStyle, ) -> Result { let region = Region::Custom { region: region.into(), @@ -209,9 +212,11 @@ impl GitStore { } } .map_err(|e| StoreError::Backend(S3Error::Credentials(e)))?; - let bucket = Bucket::new(bucket_name, region, creds) - .map_err(StoreError::Backend)? - .with_path_style(); + let bucket = Bucket::new(bucket_name, region, creds).map_err(StoreError::Backend)?; + let bucket = match addressing_style { + buzz_media::config::S3AddressingStyle::Path => bucket.with_path_style(), + buzz_media::config::S3AddressingStyle::Virtual => bucket, + }; Ok(Self { bucket: Arc::from(bucket), }) @@ -950,6 +955,7 @@ mod tests { "buzz_dev_secret", "buzz-git", "us-west-2", + buzz_media::config::S3AddressingStyle::Path, ) .expect("static creds should build a git store"); match store.bucket.region { @@ -958,6 +964,34 @@ mod tests { } } + #[test] + fn constructor_applies_both_addressing_styles() { + for (style, expected_url, path_style) in [ + ( + buzz_media::config::S3AddressingStyle::Path, + "https://storage.example/buzz-git", + true, + ), + ( + buzz_media::config::S3AddressingStyle::Virtual, + "https://buzz-git.storage.example", + false, + ), + ] { + let store = GitStore::new( + "https://storage.example", + "buzz_dev", + "buzz_dev_secret", + "buzz-git", + "us-east-1", + style, + ) + .expect("construct git store"); + assert_eq!(store.bucket.url(), expected_url); + assert_eq!(store.bucket.is_path_style(), path_style); + } + } + #[test] fn partial_static_keys_are_rejected() { for (access, secret) in [("buzz_dev", ""), ("", "buzz_dev_secret")] { @@ -967,6 +1001,7 @@ mod tests { secret, "buzz-git", "us-east-1", + buzz_media::config::S3AddressingStyle::Path, ) { Ok(_) => { panic!("partial static creds must not silently use the credential chain") @@ -998,14 +1033,29 @@ mod probe { } fn store() -> GitStore { + // This is the dedicated backend conformance path, so all connection and + // signing inputs are overridable for a real provider such as Railway. + // The hydrate/CAS live tests use explicit local MinIO fixtures instead. + let endpoint = + std::env::var("BUZZ_S3_ENDPOINT").unwrap_or_else(|_| "http://localhost:9000".into()); + let access_key = std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".into()); + let secret_key = + std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".into()); + let bucket = std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-git".into()); + let region = std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".into()); + let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE") + .unwrap_or_else(|_| "path".into()) + .parse() + .expect("BUZZ_S3_ADDRESSING_STYLE must be path or virtual"); GitStore::new( - "http://localhost:9000", - "buzz_dev", - "buzz_dev_secret", - "buzz-git", - "us-east-1", + &endpoint, + &access_key, + &secret_key, + &bucket, + ®ion, + addressing_style, ) - .expect("connect minio") + .expect("connect S3-compatible storage") } fn sha256_hex(b: &[u8]) -> String { diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index a1691349d6..e494355736 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -630,6 +630,16 @@ impl Config { .and_then(|v| v.parse().ok()) .unwrap_or(9102); + let s3_addressing_style = match std::env::var("BUZZ_S3_ADDRESSING_STYLE") { + Ok(value) => value.parse().map_err(ConfigError::InvalidValue)?, + Err(std::env::VarError::NotPresent) => buzz_media::config::S3AddressingStyle::default(), + Err(std::env::VarError::NotUnicode(_)) => { + return Err(ConfigError::InvalidValue( + "BUZZ_S3_ADDRESSING_STYLE must be valid Unicode and one of 'path' or 'virtual'" + .to_string(), + )); + } + }; let media = buzz_media::MediaConfig { s3_endpoint: std::env::var("BUZZ_S3_ENDPOINT") .unwrap_or_else(|_| "http://localhost:9000".to_string()), @@ -641,6 +651,7 @@ impl Config { s3_region: std::env::var("BUZZ_S3_REGION") .or_else(|_| std::env::var("AWS_REGION")) .unwrap_or_else(|_| "us-east-1".to_string()), + s3_addressing_style, max_image_bytes: std::env::var("BUZZ_MAX_IMAGE_BYTES") .ok() .and_then(|v| v.parse().ok()) @@ -990,6 +1001,11 @@ mod tests { !config.require_media_get_auth, "require_media_get_auth should default to false for staged client rollout" ); + assert_eq!( + config.media.s3_addressing_style, + buzz_media::config::S3AddressingStyle::Path, + "S3 addressing must default to path style for bundled MinIO compatibility" + ); assert!( config.join_policy.is_none(), "join_policy should default to None so policy prompts and acceptance receipts are opt-in" @@ -1000,6 +1016,61 @@ mod tests { ); } + #[test] + fn s3_addressing_style_env_accepts_virtual_and_rejects_invalid_values() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_S3_ADDRESSING_STYLE"); + + std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", "virtual"); + let configured = Config::from_env() + .expect("virtual style config") + .media + .s3_addressing_style; + + std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", "auto"); + let invalid = Config::from_env(); + + if let Some(value) = previous { + std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", value); + } else { + std::env::remove_var("BUZZ_S3_ADDRESSING_STYLE"); + } + + assert_eq!(configured, buzz_media::config::S3AddressingStyle::Virtual); + assert!(matches!( + invalid, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'") + )); + } + + #[cfg(unix)] + #[test] + fn s3_addressing_style_env_rejects_non_unicode_values() { + use std::os::unix::ffi::OsStringExt; + + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_S3_ADDRESSING_STYLE"); + std::env::set_var( + "BUZZ_S3_ADDRESSING_STYLE", + std::ffi::OsString::from_vec(vec![0xff]), + ); + + let invalid = Config::from_env(); + + if let Some(value) = previous { + std::env::set_var("BUZZ_S3_ADDRESSING_STYLE", value); + } else { + std::env::remove_var("BUZZ_S3_ADDRESSING_STYLE"); + } + + assert!(matches!( + invalid, + Err(ConfigError::InvalidValue(ref message)) + if message.contains("must be valid Unicode") + )); + } + #[test] fn redis_pool_size_env_override_and_invalid_fallback() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 758c001b96..58a869a995 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -697,6 +697,7 @@ impl AppState { &config.media.s3_secret_key, &config.media.s3_bucket, &config.media.s3_region, + config.media.s3_addressing_style, ) .expect("media storage was already constructed with this S3 config"); let git_pack_cache = Arc::new( diff --git a/crates/buzz-test-client/Cargo.toml b/crates/buzz-test-client/Cargo.toml index 40a08f3d19..e495c16300 100644 --- a/crates/buzz-test-client/Cargo.toml +++ b/crates/buzz-test-client/Cargo.toml @@ -36,6 +36,7 @@ sha2 = { workspace = true } sqlx = { workspace = true } chrono = { workspace = true } s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] } +buzz-media = { workspace = true } buzz-sdk = { workspace = true } [[bin]] diff --git a/crates/buzz-test-client/tests/e2e_git.rs b/crates/buzz-test-client/tests/e2e_git.rs index 63281fd18f..f543e56229 100644 --- a/crates/buzz-test-client/tests/e2e_git.rs +++ b/crates/buzz-test-client/tests/e2e_git.rs @@ -21,6 +21,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; +use buzz_media::S3AddressingStyle; use nostr::{EventBuilder, Keys, Kind, Tag}; use s3::creds::Credentials; use s3::{Bucket, Region}; @@ -115,29 +116,55 @@ struct PointerSnapshot { } impl GitS3Probe { - fn from_env() -> Self { - let endpoint = std::env::var("BUZZ_GIT_S3_ENDPOINT") - .or_else(|_| std::env::var("BUZZ_S3_ENDPOINT")) - .unwrap_or_else(|_| "http://localhost:9000".to_string()); - let access_key = std::env::var("BUZZ_GIT_S3_ACCESS_KEY") - .or_else(|_| std::env::var("BUZZ_S3_ACCESS_KEY")) - .unwrap_or_else(|_| "buzz_dev".to_string()); - let secret_key = std::env::var("BUZZ_GIT_S3_SECRET_KEY") - .or_else(|_| std::env::var("BUZZ_S3_SECRET_KEY")) - .unwrap_or_else(|_| "buzz_dev_secret".to_string()); - let bucket = std::env::var("BUZZ_GIT_S3_BUCKET") - .or_else(|_| std::env::var("BUZZ_S3_BUCKET")) - .unwrap_or_else(|_| "buzz-media".to_string()); - + fn bucket( + endpoint: String, + access_key: &str, + secret_key: &str, + bucket_name: &str, + region_name: String, + addressing_style: S3AddressingStyle, + ) -> Box { let region = Region::Custom { - region: "us-east-1".into(), + region: region_name, endpoint, }; - let creds = Credentials::new(Some(&access_key), Some(&secret_key), None, None, None) + let creds = Credentials::new(Some(access_key), Some(secret_key), None, None, None) .expect("S3 credentials"); - let bucket = Bucket::new(&bucket, region, creds) - .expect("S3 bucket") - .with_path_style(); + let bucket = Bucket::new(bucket_name, region, creds).expect("S3 bucket"); + match addressing_style { + S3AddressingStyle::Path => bucket.with_path_style(), + S3AddressingStyle::Virtual => bucket, + } + } + + fn from_env() -> Self { + // These E2E assertions inspect the relay's backing bucket directly, so + // they must receive the same provider connection and URL style as the + // relay. Unit/live MinIO probes in buzz-relay keep explicit local + // fixtures and do not need provider overrides. + let endpoint = std::env::var("BUZZ_S3_ENDPOINT") + .unwrap_or_else(|_| "http://localhost:9000".to_string()); + let access_key = + std::env::var("BUZZ_S3_ACCESS_KEY").unwrap_or_else(|_| "buzz_dev".to_string()); + let secret_key = + std::env::var("BUZZ_S3_SECRET_KEY").unwrap_or_else(|_| "buzz_dev_secret".to_string()); + let bucket_name = + std::env::var("BUZZ_S3_BUCKET").unwrap_or_else(|_| "buzz-media".to_string()); + let region_name = + std::env::var("BUZZ_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()); + let addressing_style = std::env::var("BUZZ_S3_ADDRESSING_STYLE") + .unwrap_or_else(|_| "path".to_string()) + .parse::() + .expect("BUZZ_S3_ADDRESSING_STYLE must be 'path' or 'virtual'"); + + let bucket = Self::bucket( + endpoint, + &access_key, + &secret_key, + &bucket_name, + region_name, + addressing_style, + ); Self { bucket } } @@ -192,6 +219,31 @@ impl GitS3Probe { } } +#[test] +fn git_s3_probe_builds_both_addressing_styles() { + let path = GitS3Probe::bucket( + "https://storage.example".to_string(), + "access", + "secret", + "buzz-media", + "us-east-1".to_string(), + S3AddressingStyle::Path, + ); + assert!(path.is_path_style()); + assert_eq!(path.url(), "https://storage.example/buzz-media"); + + let virtual_hosted = GitS3Probe::bucket( + "https://storage.example".to_string(), + "access", + "secret", + "buzz-media", + "auto".to_string(), + S3AddressingStyle::Virtual, + ); + assert!(virtual_hosted.is_subdomain_style()); + assert_eq!(virtual_hosted.url(), "https://buzz-media.storage.example"); +} + #[tokio::test] #[ignore = "requires live relay + MinIO + git"] async fn git_clone_push_fetch_force_roundtrip() { diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index a7c4bcf63b..b2778df28b 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -52,6 +52,48 @@ See: The chart fails at `helm install` / `helm template` time with a clear message if any of these are missing or malformed (see `templates/_validate.tpl`). +## S3 URL addressing + +Buzz uses one URL style for both media and Git/CAS object-store requests: + +| `s3.addressingStyle` | Request shape | Use for | +|---|---|---| +| `path` (default) | `https://endpoint/bucket/key` | Bundled MinIO and endpoints whose DNS does not resolve bucket subdomains | +| `virtual` | `https://bucket.endpoint/key` | AWS-style providers and new Railway Storage Buckets | + +The chart always renders `s3.addressingStyle` as +`BUZZ_S3_ADDRESSING_STYLE`. It renders `s3.region` as `BUZZ_S3_REGION` only +when explicitly set, preserving the relay's existing `AWS_REGION` fallback for +upgrades. Only `path` and `virtual` addressing styles are accepted; invalid +values fail chart rendering and relay startup. The bundled MinIO quickstart +deliberately keeps `path` because its Service DNS resolves one endpoint +hostname, not arbitrary `.` names. + +For a Railway Storage Bucket, map its variables to chart values in the service +or generated Helm configuration: + +```yaml +s3: + endpoint: "${{Object Storage.ENDPOINT}}" + bucket: "${{Object Storage.BUCKET}}" + region: "${{Object Storage.REGION}}" + addressingStyle: virtual +``` + +Store `BUZZ_S3_ACCESS_KEY=${{Object Storage.ACCESS_KEY_ID}}` and +`BUZZ_S3_SECRET_KEY=${{Object Storage.SECRET_ACCESS_KEY}}` in the Secret named by +`secrets.existingSecret`. Railway's Credentials tab is authoritative for older +buckets, which may still require `path`. The setting changes request routing and +SigV4 signing, so do not put the bucket into `s3.endpoint`; pass Railway's base +`ENDPOINT` and `BUCKET` separately. + +Object storage is contacted during relay startup only when +`BUZZ_GIT_CONFORMANCE_PROBE` is enabled (the relay default). A probe failure is +startup-fatal, so Kubernetes readiness never opens. If an operator explicitly +disables that probe through `relay.extraEnv`, `/_readiness` does not test object +storage; configuration is still parsed strictly, but reachability and addressing +errors surface on the first storage operation. + ## Relay Pod extensions The chart exposes narrow extension points for init containers, volumes, relay diff --git a/deploy/charts/buzz/examples/argocd-app.yaml b/deploy/charts/buzz/examples/argocd-app.yaml index 7e90f64bd7..8f6cb76228 100644 --- a/deploy/charts/buzz/examples/argocd-app.yaml +++ b/deploy/charts/buzz/examples/argocd-app.yaml @@ -41,6 +41,8 @@ spec: s3: endpoint: "https://s3.us-east-1.amazonaws.com" bucket: "buzz-media" + region: "us-east-1" + addressingStyle: virtual # accessKey / secretKey live in buzz-secrets persistence: diff --git a/deploy/charts/buzz/examples/flux-helmrelease.yaml b/deploy/charts/buzz/examples/flux-helmrelease.yaml index 16754c0fcb..09a6bfeb6a 100644 --- a/deploy/charts/buzz/examples/flux-helmrelease.yaml +++ b/deploy/charts/buzz/examples/flux-helmrelease.yaml @@ -41,6 +41,8 @@ spec: s3: endpoint: "https://s3.us-east-1.amazonaws.com" bucket: "buzz-media" + region: "us-east-1" + addressingStyle: virtual persistence: git: diff --git a/deploy/charts/buzz/templates/_validate.tpl b/deploy/charts/buzz/templates/_validate.tpl index 946424f9a3..aa7f7ac13c 100644 --- a/deploy/charts/buzz/templates/_validate.tpl +++ b/deploy/charts/buzz/templates/_validate.tpl @@ -75,10 +75,12 @@ surface at template time regardless of which manifest helm renders first. {{- fail "Postgres source missing: enable postgresql.enabled=true, set externalPostgresql.url, or provide secrets.existingSecret with key DATABASE_URL." -}} {{- end -}} -{{/* S3 / object-storage source must exist somewhere (relay hard-fails its - startup conformance probe without a reachable bucket). */}} +{{/* S3 / object-storage source must exist somewhere. With the default + BUZZ_GIT_CONFORMANCE_PROBE behavior, an unreachable bucket is detected + before the relay opens its listener; operators can explicitly disable that + startup gate. */}} {{- if not (or .Values.minio.enabled .Values.s3.endpoint .Values.secrets.existingSecret) -}} - {{- fail "S3/object-storage source missing: enable minio.enabled=true (quickstart in-cluster), set s3.endpoint + s3.bucket + credentials, or provide secrets.existingSecret with keys BUZZ_S3_ACCESS_KEY + BUZZ_S3_SECRET_KEY. The relay runs a startup S3 conformance probe and exits if storage is unreachable." -}} + {{- fail "S3/object-storage source missing: enable minio.enabled=true (quickstart in-cluster), set s3.endpoint + s3.bucket + credentials, or provide secrets.existingSecret with keys BUZZ_S3_ACCESS_KEY + BUZZ_S3_SECRET_KEY. By default the relay runs a startup S3 conformance probe and exits if storage is unreachable; disabling BUZZ_GIT_CONFORMANCE_PROBE also removes that startup storage check." -}} {{- end -}} {{- end -}} diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index bf2df4c2c8..67a93138c5 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -170,6 +170,10 @@ spec: - { name: BUZZ_S3_ENDPOINT, value: {{ $s3Endpoint | quote }} } {{- end }} - { name: BUZZ_S3_BUCKET, value: {{ .Values.s3.bucket | quote }} } + {{- if .Values.s3.region }} + - { name: BUZZ_S3_REGION, value: {{ .Values.s3.region | quote }} } + {{- end }} + - { name: BUZZ_S3_ADDRESSING_STYLE, value: {{ .Values.s3.addressingStyle | quote }} } # ── Secrets (from chart-managed or existing) ───────────── - name: BUZZ_RELAY_PRIVATE_KEY diff --git a/deploy/charts/buzz/tests/render_test.yaml b/deploy/charts/buzz/tests/render_test.yaml index 3e044f5d7c..cf08210781 100644 --- a/deploy/charts/buzz/tests/render_test.yaml +++ b/deploy/charts/buzz/tests/render_test.yaml @@ -30,6 +30,18 @@ tests: path: kind value: Service template: templates/service.yaml + - notContains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_S3_REGION + any: true + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_S3_ADDRESSING_STYLE + value: "path" + template: templates/deployment.yaml - contains: path: spec.template.spec.containers[0].env content: @@ -47,6 +59,32 @@ tests: value: "true" template: templates/deployment.yaml + - it: renders virtual-hosted S3 addressing for providers that require it + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + externalRedis.url: redis://h:6379 + s3.endpoint: https://storage.railway.app + s3.bucket: buzz-media-example + s3.region: auto + s3.addressingStyle: virtual + s3.accessKey: a + s3.secretKey: s + asserts: + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_S3_REGION + value: "auto" + template: templates/deployment.yaml + - contains: + path: spec.template.spec.containers[0].env + content: + name: BUZZ_S3_ADDRESSING_STYLE + value: "virtual" + template: templates/deployment.yaml + - it: lets an explicit value opt out of media read auth for dev/public deployments set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/tests/validation_test.yaml b/deploy/charts/buzz/tests/validation_test.yaml index f0a3869795..a5a0050a86 100644 --- a/deploy/charts/buzz/tests/validation_test.yaml +++ b/deploy/charts/buzz/tests/validation_test.yaml @@ -58,6 +58,17 @@ tests: - failedTemplate: errorPattern: "Postgres source missing" + - it: rejects an invalid S3 addressing style + set: + relayUrl: wss://buzz.example.com + ownerPubkey: "0000000000000000000000000000000000000000000000000000000000000000" + externalPostgresql.url: postgres://u:p@h:5432/d + s3.endpoint: http://minio:9000 + s3.addressingStyle: auto + asserts: + - failedTemplate: + errorPattern: "s3.addressingStyle: s3.addressingStyle must be one of the following:.*path.*virtual" + - it: fails when S3/object-storage source is missing set: relayUrl: wss://buzz.example.com diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 53bb29bb60..9cb6a02c9b 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -198,6 +198,15 @@ "properties": { "endpoint": { "type": "string", "pattern": "^(https?://.+)?$" }, "bucket": { "type": "string", "minLength": 1 }, + "region": { + "type": "string", + "description": "Optional S3 region used for SigV4 signing. When empty, BUZZ_S3_REGION is omitted so the relay can use AWS_REGION or its own default." + }, + "addressingStyle": { + "type": "string", + "enum": ["path", "virtual"], + "description": "S3 URL style shared by media and Git/CAS clients. Defaults to path for bundled MinIO compatibility." + }, "accessKey": { "type": "string" }, "secretKey": { "type": "string" } } diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 8ac5086e27..810f8a9658 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -338,6 +338,12 @@ externalRedis: s3: endpoint: "" bucket: "buzz-media" + # Optional SigV4 signing region. Leave empty to preserve the relay's + # AWS_REGION fallback; set the provider's credential value when needed. + region: "" + # path: https://endpoint/bucket/key (bundled MinIO-compatible default) + # virtual: https://bucket.endpoint/key (standard S3; required by new Railway buckets) + addressingStyle: path accessKey: "" secretKey: "" diff --git a/deploy/compose/.env.example b/deploy/compose/.env.example index 838824c17f..f6ab4fcab9 100644 --- a/deploy/compose/.env.example +++ b/deploy/compose/.env.example @@ -33,6 +33,8 @@ REDIS_PASSWORD=CHANGE_ME_RANDOM_PASSWORD BUZZ_S3_ACCESS_KEY=CHANGE_ME_RANDOM_ACCESS_KEY BUZZ_S3_SECRET_KEY=CHANGE_ME_RANDOM_SECRET_KEY BUZZ_S3_BUCKET=buzz-media +# Bundled MinIO uses path-style URLs; deploy/compose/compose.yml pins this. +BUZZ_S3_ADDRESSING_STYLE=path # Optional host ports. Base compose publishes the relay directly on BUZZ_HTTP_PORT. BUZZ_HTTP_PORT=3000 diff --git a/deploy/compose/README.md b/deploy/compose/README.md index 0de524fb5b..bb0e63fe15 100644 --- a/deploy/compose/README.md +++ b/deploy/compose/README.md @@ -38,6 +38,11 @@ keypair. migrations. - The stack uses Postgres, Redis, MinIO, and a git data volume because those are real Buzz dependencies today. Minimal mode can simplify this later. +- The bundled Compose stack fixes the relay endpoint to `http://minio:9000` and + `BUZZ_S3_ADDRESSING_STYLE=path`: Docker DNS resolves `minio`, not + `.minio`. It is not configurable for an external S3 provider through + `.env`; use the Helm chart or a custom Compose configuration for providers + such as new Railway Storage Buckets that require `virtual` addressing. Run `./run.sh backup-hint` for the backup checklist. diff --git a/deploy/compose/compose.yml b/deploy/compose/compose.yml index bc3c27501e..15337c92a2 100644 --- a/deploy/compose/compose.yml +++ b/deploy/compose/compose.yml @@ -12,6 +12,8 @@ services: DATABASE_URL: postgres://${POSTGRES_USER:-buzz}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-buzz} REDIS_URL: redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379 BUZZ_S3_ENDPOINT: http://minio:9000 + # Docker DNS resolves `minio`, not arbitrary `.minio` hosts. + BUZZ_S3_ADDRESSING_STYLE: path BUZZ_S3_ACCESS_KEY: ${BUZZ_S3_ACCESS_KEY:?set BUZZ_S3_ACCESS_KEY} BUZZ_S3_SECRET_KEY: ${BUZZ_S3_SECRET_KEY:?set BUZZ_S3_SECRET_KEY} BUZZ_S3_BUCKET: ${BUZZ_S3_BUCKET:-buzz-media} diff --git a/desktop/src-tauri/src/commands/media_snapshot_png.rs b/desktop/src-tauri/src/commands/media_snapshot_png.rs index 734d8f5dc8..bcaec6a592 100644 --- a/desktop/src-tauri/src/commands/media_snapshot_png.rs +++ b/desktop/src-tauri/src/commands/media_snapshot_png.rs @@ -204,6 +204,7 @@ mod tests { s3_secret_key: String::new(), s3_bucket: String::new(), s3_region: "us-east-1".to_string(), + s3_addressing_style: buzz_media_pkg::S3AddressingStyle::Path, max_image_bytes: 50 * 1024 * 1024, max_gif_bytes: 10 * 1024 * 1024, max_video_bytes: 524_288_000, From 788b3c002bd2509455444f57f8a03a054b4b496a Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:07:55 -0400 Subject: [PATCH 47/99] fix(git): channel binding tooling + author remediation for unbound repos (#3626) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3527. Repos announced via vanilla NIP-34 (kind:30617 without a `buzz-channel` tag) 404 forever: the SEC-005 read gate requires a channel-membership ACL, and nothing tells the author why or how to fix it. Per the ruling in the originating thread, this ships **bind/rebind tooling plus a narrow author-only remediation carve-out** — the shelved owner-circle approach is intentionally absent. ## Relay - **`api/git/binding.rs` (new):** shared tri-state binding resolver — `Bound(uuid)` / `NotBound` / `Broken`. First-tag, fail-closed: a malformed `buzz-channel` tag is `Broken`, never conflated with "no tag". Both gates use it. - **Read gate (`transport.rs`):** a **never-bound** repo read by **its own announcement author** still returns 404 (status byte-identical to the generic denial) but the body carries remediation: `run: buzz repos bind --id --channel — …`. This leaks nothing — the author announced the repo, and only the author can rebind (30617 is keyed by `(author, d)`). `Broken` bindings stay generic-denial for everyone, including the author (revocation shape). Bound-to-nonexistent-channel stays generic (phase 1; ingest validation is phase 2). - **Push gate (`policy.rs`):** unbound denial now returns `GIT_NO_CHANNEL_BINDING_BODY`. A deploy-skew test pins that the body carries both the new token (`no_channel_binding`) and the legacy phrase (`"no channel binding"`) so already-shipped desktops keep matching. **(Review r1, blocker 2)** `Broken` no longer collapses into "unbound": it denies 403 `invalid channel binding` for *everyone — including the announcement owner —* **before** the owner short-circuit, matching the read gate's fail-closed posture. The remediation token stays NotBound-only. - **`ingest.rs`:** side-effect failure `warn!` → `error!` — prod runs `RUST_LOG=error`, so these failures were invisible during triage. ## Contract - **`buzz-core/git_perms.rs`:** `GIT_NO_CHANNEL_BINDING_TOKEN` / `GIT_NO_CHANNEL_BINDING_BODY` consts as the declared cross-component contract; relay tests and desktop matcher both build on them. ## CLI - **`buzz repos bind --id --channel `** — rebinds an existing announcement, preserving other tags. - **(Review r1, blocker 1)** **`--channel` on `buzz repos create`** — optional; injects exactly one shape-validated `buzz-channel` tag at creation via a pure `build_create_announcement` builder, so the primary create command stops producing repos the relay 404s. UUID existence/membership stays the relay's authority at git-access time (same TOCTOU posture as `repos bind`). Overlaps with #3594 (open, head 6bbe38459) — happy to reconcile whichever lands first; this branch also carries the bind path and tag preservation. ## Desktop - **Rust:** new `commands/project_git_merge_error.rs` (extracted from `project_git_workflow.rs` to respect the 1000-line ratchet); maps the token to a structured `no_channel_binding` error carrying the bind command. - **TS:** new `features/projects/lib/projectBranchErrors.ts` + tests — dual matcher (new token AND legacy spaced phrase); `ProjectBranchDialogs.tsx` uses it. ## Tests / verification (at head f914c7066, base 581baa625) - Workspace `cargo test` green; `clippy -D warnings` clean; desktop Rust 1859 pass; TS 3780 pass; tsc/biome/file-size checks pass. Pre-push hooks re-ran all suites at the pushed head. - Postgres-gated `sec005_read_gate_tests`: all 6 pass, including `read_gate_gives_author_of_unbound_repo_remediation_body` — asserts 404 status, `text/plain` content-type, and exact body bytes, distinguishing remediation from generic denial (a blind `is_err()` can't). - **New (review r1):** `buzz-cli` emitted-event tests — `create_with_channel_emits_exactly_one_binding_tag`, `create_without_channel_emits_no_binding_tag`, `create_rejects_malformed_channel_uuid` (266/266 pass). Postgres-gated `push_gate_denies_owner_through_broken_binding` — owner + malformed-first/valid-second binding → 403 generic body without the remediation token; never-bound control stays 200, pinning the denial to `Broken` specifically. - e2e git tests now bind announcements to a real channel via a `create_test_channel` helper. --------- Signed-off-by: Tyler Longwell Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell --- crates/buzz-cli/src/commands/repos.rs | 257 +++++++++++++++-- crates/buzz-cli/src/lib.rs | 23 +- crates/buzz-core/src/git_perms.rs | 24 ++ crates/buzz-relay/src/api/git/binding.rs | 128 +++++++++ crates/buzz-relay/src/api/git/mod.rs | 1 + crates/buzz-relay/src/api/git/policy.rs | 228 ++++++++++++++- crates/buzz-relay/src/api/git/transport.rs | 269 +++++++++++------- crates/buzz-relay/src/handlers/ingest.rs | 7 +- crates/buzz-test-client/tests/e2e_git.rs | 28 ++ desktop/src-tauri/src/commands/mod.rs | 1 + .../src/commands/project_git_merge_error.rs | 152 ++++++++++ .../src/commands/project_git_workflow.rs | 114 +------- .../projects/lib/projectBranchErrors.test.mjs | 43 +++ .../projects/lib/projectBranchErrors.ts | 35 +++ .../projects/ui/ProjectBranchDialogs.tsx | 17 +- 15 files changed, 1069 insertions(+), 258 deletions(-) create mode 100644 crates/buzz-relay/src/api/git/binding.rs create mode 100644 desktop/src-tauri/src/commands/project_git_merge_error.rs create mode 100644 desktop/src/features/projects/lib/projectBranchErrors.test.mjs create mode 100644 desktop/src/features/projects/lib/projectBranchErrors.ts diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 0f570df1aa..608d495055 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -83,27 +83,36 @@ fn build_protection_tag( Tag::parse(values).map_err(tag_error) } -enum ProtectionChange { - Set(Box), - Remove(String), +enum RepoChange { + SetProtection(Box), + RemoveProtection(String), + /// Bind (or rebind) the repo to a channel: replaces every existing + /// `buzz-channel` tag with exactly one carrying the validated UUID. + BindChannel(String), } fn build_updated_repo_announcement( existing: &Event, - change: ProtectionChange, + change: RepoChange, ) -> Result { let repo_id = repo_id_from_event(existing)?; - let (pattern, replacement) = match change { - ProtectionChange::Set(tag) => { + // What to strip beyond `auth` (always stripped), and what to append. + let (removed_pattern, removed_channel, replacement) = match change { + RepoChange::SetProtection(tag) => { let pattern = protection_pattern(&tag) .ok_or_else(|| CliError::Other("replacement is not a protection tag".into()))? .to_string(); - (pattern, Some(*tag)) + (Some(pattern), false, Some(*tag)) } - ProtectionChange::Remove(pattern) => { + RepoChange::RemoveProtection(pattern) => { RefPattern::parse(&pattern) .map_err(|error| CliError::Usage(format!("invalid ref pattern: {error}")))?; - (pattern, None) + (Some(pattern), false, None) + } + RepoChange::BindChannel(channel) => { + crate::validate::validate_uuid(&channel)?; + let tag = Tag::parse(["buzz-channel", channel.as_str()]).map_err(tag_error)?; + (None, true, Some(tag)) } }; @@ -111,7 +120,13 @@ fn build_updated_repo_announcement( .tags .iter() .filter(|tag| { - !has_tag_name(tag, "auth") && protection_pattern(tag) != Some(pattern.as_str()) + if has_tag_name(tag, "auth") { + return false; + } + if removed_channel && has_tag_name(tag, "buzz-channel") { + return false; + } + removed_pattern.is_none() || protection_pattern(tag) != removed_pattern.as_deref() }) .cloned() .collect(); @@ -199,21 +214,30 @@ async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Resul Ok(()) } -pub async fn cmd_create_repo( - client: &BuzzClient, +/// Build the kind:30617 announcement for `repos create`, including the +/// `buzz-channel` binding when requested. +/// +/// Pure (no I/O) so the emitted tags are unit-testable. Exactly one +/// validated `buzz-channel` tag is appended — the tag is the git ACL +/// (issue #3527: without it the relay 404s every clone/fetch/push), so the +/// UUID is shape-validated here and its existence/membership is the relay's +/// authority at git-access time, same posture as `repos bind`. +#[allow(clippy::too_many_arguments)] +fn build_create_announcement( repo_id: &str, name: Option<&str>, description: Option<&str>, clone_urls: &[String], web_url: Option<&str>, relays: &[String], -) -> Result<(), CliError> { + channel: Option<&str>, +) -> Result { validate_repo_id(repo_id)?; let clone_refs: Vec<&str> = clone_urls.iter().map(|s| s.as_str()).collect(); let relay_refs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect(); - let builder = buzz_sdk::build_repo_announcement( + let mut builder = buzz_sdk::build_repo_announcement( repo_id, name, description, @@ -223,6 +247,33 @@ pub async fn cmd_create_repo( ) .map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?; + if let Some(channel) = channel { + crate::validate::validate_uuid(channel)?; + builder = builder.tag(Tag::parse(["buzz-channel", channel]).map_err(tag_error)?); + } + Ok(builder) +} + +#[allow(clippy::too_many_arguments)] +pub async fn cmd_create_repo( + client: &BuzzClient, + repo_id: &str, + name: Option<&str>, + description: Option<&str>, + clone_urls: &[String], + web_url: Option<&str>, + relays: &[String], + channel: Option<&str>, +) -> Result<(), CliError> { + let builder = build_create_announcement( + repo_id, + name, + description, + clone_urls, + web_url, + relays, + channel, + )?; let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -320,7 +371,8 @@ async fn cmd_protect_set( require_patch, )?; let event = current_repo(client, repo_id).await?; - let builder = build_updated_repo_announcement(&event, ProtectionChange::Set(Box::new(tag)))?; + let builder = + build_updated_repo_announcement(&event, RepoChange::SetProtection(Box::new(tag)))?; submit_repo_update(client, builder).await } @@ -341,8 +393,27 @@ async fn cmd_protect_remove( "repository {repo_id:?} has no protection rule for {ref_pattern:?}" ))); } + let builder = build_updated_repo_announcement( + &event, + RepoChange::RemoveProtection(ref_pattern.to_string()), + )?; + submit_repo_update(client, builder).await +} + +/// Bind (or rebind) a repository to a channel — the fix path for issue +/// #3527's permanently-404 repos. Publishes a read-modify-write update of +/// the caller's own kind:30617 with exactly one `buzz-channel` tag; all +/// other metadata (protections, name, description, future tags) is +/// preserved by the same machinery `repos protect` uses. +/// +/// The UUID is validated for *shape* only — deliberately. Channel existence +/// and the caller's membership are the relay's authority at git-access +/// time; a CLI-side network pre-check would just be TOCTOU with extra +/// latency. +async fn cmd_bind_repo(client: &BuzzClient, repo_id: &str, channel: &str) -> Result<(), CliError> { + let event = current_repo(client, repo_id).await?; let builder = - build_updated_repo_announcement(&event, ProtectionChange::Remove(ref_pattern.to_string()))?; + build_updated_repo_announcement(&event, RepoChange::BindChannel(channel.to_string()))?; submit_repo_update(client, builder).await } @@ -356,6 +427,7 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C clone_urls, web, relays, + channel, } => { cmd_create_repo( client, @@ -365,11 +437,13 @@ pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), C &clone_urls, web.as_deref(), &relays, + channel.as_deref(), ) .await } ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await, ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await, + ReposCmd::Bind { id, channel } => cmd_bind_repo(client, &id, &channel).await, ReposCmd::Protect(command) => match command { ReposProtectCmd::List { id } => cmd_protect_list(client, &id).await, ReposProtectCmd::Set { @@ -403,8 +477,8 @@ mod tests { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; use super::{ - build_protection_tag, build_updated_repo_announcement, protection_rules_json, - validate_write_response, ProtectionChange, + build_create_announcement, build_protection_tag, build_updated_repo_announcement, + protection_rules_json, validate_write_response, RepoChange, }; fn signed_repo(tags: Vec, content: &str, created_at: u64) -> nostr::Event { @@ -439,7 +513,7 @@ mod tests { let updated = build_updated_repo_announcement( &existing, - ProtectionChange::Set(Box::new(replacement)), + RepoChange::SetProtection(Box::new(replacement)), ) .expect("build update") .sign_with_keys(&Keys::generate()) @@ -501,7 +575,7 @@ mod tests { let updated = build_updated_repo_announcement( &existing, - ProtectionChange::Remove("refs/heads/main".into()), + RepoChange::RemoveProtection("refs/heads/main".into()), ) .expect("build removal") .sign_with_keys(&Keys::generate()) @@ -538,7 +612,7 @@ mod tests { let error = build_updated_repo_announcement( &existing, - ProtectionChange::Set(Box::new(replacement)), + RepoChange::SetProtection(Box::new(replacement)), ) .expect_err("malformed existing rule must fail closed"); @@ -564,7 +638,7 @@ mod tests { let error = build_updated_repo_announcement( &existing, - ProtectionChange::Set(Box::new(replacement)), + RepoChange::SetProtection(Box::new(replacement)), ) .expect_err("the 51st rule must be rejected"); @@ -615,6 +689,145 @@ mod tests { .is_some_and(|error| error.contains("needs pattern + at least one rule"))); } + #[test] + fn bind_channel_replaces_duplicates_and_preserves_everything_else() { + let channel = uuid::Uuid::new_v4().to_string(); + let existing = signed_repo( + vec![ + tag(&["d", "demo"]), + tag(&["name", "Demo"]), + // Two stale bindings — e.g. from a buggy or vanilla client. + tag(&["buzz-channel", "old-and-broken"]), + tag(&["buzz-channel", &uuid::Uuid::new_v4().to_string()]), + tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]), + tag(&["buzz-protect", "refs/heads/main", "push:admin"]), + tag(&["future-metadata", "preserve-me"]), + ], + "repository content", + 100, + ); + + let updated = + build_updated_repo_announcement(&existing, RepoChange::BindChannel(channel.clone())) + .expect("build bind update") + .sign_with_keys(&Keys::generate()) + .expect("sign bind update"); + + assert_eq!(updated.content, "repository content"); + assert_eq!(updated.created_at.as_secs(), 101); + // Exactly one binding remains, and it is the requested one. + let bindings: Vec<_> = updated + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel")) + .collect(); + assert_eq!(bindings.len(), 1); + assert_eq!(bindings[0].as_slice(), ["buzz-channel", channel.as_str()]); + // Auth stripped (relay re-stamps); everything else preserved. + assert!(!updated + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth"))); + assert!(updated + .tags + .iter() + .any(|tag| tag.as_slice() == ["buzz-protect", "refs/heads/main", "push:admin"])); + assert!(updated + .tags + .iter() + .any(|tag| tag.as_slice() == ["future-metadata", "preserve-me"])); + assert!(updated + .tags + .iter() + .any(|tag| tag.as_slice() == ["name", "Demo"])); + } + + #[test] + fn bind_channel_adds_binding_to_unbound_repo() { + let channel = uuid::Uuid::new_v4().to_string(); + let existing = signed_repo(vec![tag(&["d", "demo"])], "", 10); + + let updated = + build_updated_repo_announcement(&existing, RepoChange::BindChannel(channel.clone())) + .expect("build bind update") + .sign_with_keys(&Keys::generate()) + .expect("sign bind update"); + + assert!(updated + .tags + .iter() + .any(|tag| tag.as_slice() == ["buzz-channel", channel.as_str()])); + } + + #[test] + fn bind_channel_rejects_malformed_uuid() { + let existing = signed_repo(vec![tag(&["d", "demo"])], "", 10); + + let error = + build_updated_repo_announcement(&existing, RepoChange::BindChannel("nope".into())) + .expect_err("malformed channel id must not build an update"); + + assert!(matches!(error, crate::error::CliError::Usage(_))); + } + + /// Issue #3527: `repos create --channel` must emit exactly one + /// `buzz-channel` tag so the primary create command stops producing + /// repos the relay 404s forever. + #[test] + fn create_with_channel_emits_exactly_one_binding_tag() { + let channel = uuid::Uuid::new_v4().to_string(); + let event = build_create_announcement( + "demo", + Some("Demo"), + None, + &["https://relay.example/git/owner/demo".to_string()], + None, + &[], + Some(&channel), + ) + .expect("build create announcement") + .sign_with_keys(&Keys::generate()) + .expect("sign create announcement"); + + assert_eq!(event.kind, Kind::Custom(30617)); + let bindings: Vec<_> = event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel")) + .collect(); + assert_eq!(bindings.len(), 1, "exactly one buzz-channel tag"); + assert_eq!(bindings[0].as_slice(), ["buzz-channel", channel.as_str()]); + // The standard metadata still rides along. + assert!(event.tags.iter().any(|tag| tag.as_slice() == ["d", "demo"])); + assert!(event + .tags + .iter() + .any(|tag| tag.as_slice() == ["name", "Demo"])); + } + + #[test] + fn create_without_channel_emits_no_binding_tag() { + let event = build_create_announcement("demo", None, None, &[], None, &[], None) + .expect("build create announcement") + .sign_with_keys(&Keys::generate()) + .expect("sign create announcement"); + + assert!( + !event + .tags + .iter() + .any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel")), + "no --channel means no binding tag (vanilla NIP-34 stays possible)" + ); + } + + #[test] + fn create_rejects_malformed_channel_uuid() { + let error = build_create_announcement("demo", None, None, &[], None, &[], Some("nope")) + .expect_err("malformed channel id must not build an announcement"); + assert!(matches!(error, crate::error::CliError::Usage(_))); + } + #[test] fn duplicate_write_response_is_a_conflict() { let error = validate_write_response( diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index df02c65be9..02a58a618e 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1129,6 +1129,11 @@ pub enum ReposCmd { /// Preferred Nostr relay(s) for repo discovery — can be specified multiple times #[arg(long = "nostr-relay")] relays: Vec, + /// Channel UUID to bind the repo to. The `buzz-channel` tag is the + /// git ACL: without it the relay 404s every clone/fetch/push until + /// the author runs `buzz repos bind` (issue #3527). + #[arg(long)] + channel: Option, }, /// Get a repository announcement Get { @@ -1148,6 +1153,20 @@ pub enum ReposCmd { #[arg(long)] limit: Option, }, + /// Bind (or rebind) one of your repositories to a channel. + /// + /// The `buzz-channel` tag on the announcement is the git ACL: the relay + /// authorizes clone/fetch/push by membership in the bound channel. A + /// repo announced without it (e.g. by a vanilla NIP-34 client) returns + /// 404 for everyone until its author binds it here. + Bind { + /// Repository identifier (d-tag). + #[arg(long)] + id: String, + /// Channel UUID to bind. Replaces any existing binding. + #[arg(long)] + channel: String, + }, /// Manage branch and tag protection rules on one of your repositories. #[command(subcommand)] Protect(ReposProtectCmd), @@ -1991,7 +2010,7 @@ mod tests { ); assert_eq!( names(&cmd, "repos"), - vec!["create", "get", "list", "protect"] + vec!["bind", "create", "get", "list", "protect"] ); let repos = cmd .get_subcommands() @@ -2054,7 +2073,7 @@ mod tests { ("patches", 4), ("pr", 5), ("reactions", 3), - ("repos", 4), + ("repos", 5), ("social", 7), ("upload", 1), ("users", 5), diff --git a/crates/buzz-core/src/git_perms.rs b/crates/buzz-core/src/git_perms.rs index 53acd704b9..391781163b 100644 --- a/crates/buzz-core/src/git_perms.rs +++ b/crates/buzz-core/src/git_perms.rs @@ -15,6 +15,30 @@ use crate::channel::MemberRole; use std::fmt; +/// Machine-readable token prefixing the push-policy denial for a kind:30617 +/// announcement with no `buzz-channel` binding. +/// +/// This is a **declared cross-component contract**, not a log string. Known +/// consumers switch on it: +/// - relay `api/git/policy.rs` — produces [`GIT_NO_CHANNEL_BINDING_BODY`] +/// - desktop `src-tauri/commands/project_git_workflow.rs` — merge-failure +/// classifier maps it to a structured `no_channel_binding` error code +/// - desktop `src/features/projects/lib/projectBranchErrors.ts` — dialog +/// copy matcher (TS re-types the literal; its test pins the value) +pub const GIT_NO_CHANNEL_BINDING_TOKEN: &str = "no_channel_binding"; + +/// Full push-policy denial body for an unbound repository. +/// +/// Format: `: `. The trailing prose deliberately +/// repeats the token's meaning because desktops already in the field match +/// the exact phrase `no channel binding` (spaces, not underscores — the +/// token alone would NOT satisfy that matcher). Do not "fix" the redundancy: +/// removing the phrase silently breaks every shipped desktop, and removing +/// the token breaks the structured consumers above. A relay-side test pins +/// both matchers. +pub const GIT_NO_CHANNEL_BINDING_BODY: &str = + "no_channel_binding: repository has no channel binding"; + /// Maximum number of `buzz-protect` tags per repo. pub const MAX_PROTECTION_RULES: usize = 50; /// Maximum character length of a ref pattern. diff --git a/crates/buzz-relay/src/api/git/binding.rs b/crates/buzz-relay/src/api/git/binding.rs new file mode 100644 index 0000000000..7ee0eccb23 --- /dev/null +++ b/crates/buzz-relay/src/api/git/binding.rs @@ -0,0 +1,128 @@ +//! Repo → channel binding resolution, shared by the read gate and push policy. +//! +//! The `buzz-channel` tag on a kind:30617 announcement IS the git ACL: the +//! read gate (SEC-005, `transport::authorize_git_read`) and the push policy +//! endpoint (`policy::hook_callback`) both authorize against membership in +//! the bound channel. Before this module they each parsed the tag with their +//! own code that agreed only by coincidence; the resolver makes the +//! agreement structural. +//! +//! # First-tag, fail-closed semantics +//! +//! Only the *first* `buzz-channel` tag is considered, and it must carry a +//! valid UUID. A malformed first binding resolves to [`RepoBinding::Broken`] +//! even if a later duplicate tag is valid — an ambiguous announcement must +//! fail closed, not silently resolve to whichever duplicate happens to +//! parse. If this ever became "find the first *parseable* tag", an author +//! who can append a second `buzz-channel` tag would pick the channel. +//! +//! # What this deliberately does NOT do +//! +//! No DB access. A well-formed UUID that names a nonexistent or deleted +//! channel still resolves to [`RepoBinding::Bound`]; each gate's own +//! membership lookup then denies (`get_member_role` joins +//! `channels … deleted_at IS NULL`, so a dead channel is indistinguishable +//! from a non-member — the info-leak-safe posture). Likewise each gate keeps +//! its own archived-channel policy: push denies on archived channels, read +//! does not, and this resolver must not unify that asymmetry as a side +//! effect. + +use uuid::Uuid; + +/// How a kind:30617 announcement binds (or fails to bind) a channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RepoBinding { + /// No `buzz-channel` tag at all. The announcement author (the only + /// identity that can rebind — 30617 is keyed by `(author, d)`) may be + /// offered remediation; everyone else gets the generic denial. + NotBound, + /// First `buzz-channel` tag carries a valid UUID. + Bound(Uuid), + /// First `buzz-channel` tag exists but its value is not a UUID. + /// Fail closed with the generic denial — never remediation, which + /// would leak that the repo exists. + Broken, +} + +/// Resolve the channel binding of a kind:30617 announcement from its tags. +pub fn resolve_repo_binding(event: &nostr::Event) -> RepoBinding { + let Some(first) = event + .tags + .iter() + .find(|t| t.as_slice().first().map(String::as_str) == Some("buzz-channel")) + else { + return RepoBinding::NotBound; + }; + match first.as_slice().get(1).map(|v| Uuid::parse_str(v)) { + Some(Ok(id)) => RepoBinding::Bound(id), + _ => RepoBinding::Broken, + } +} + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + use super::{resolve_repo_binding, RepoBinding}; + + fn announcement(tags: Vec) -> nostr::Event { + EventBuilder::new(Kind::Custom(30617), "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign 30617") + } + + #[test] + fn extracts_valid_uuid() { + let ch = uuid::Uuid::new_v4(); + let event = announcement(vec![ + Tag::parse(["d", "repo"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + ]); + assert_eq!(resolve_repo_binding(&event), RepoBinding::Bound(ch)); + } + + #[test] + fn absent_tag_is_not_bound() { + let event = announcement(vec![Tag::parse(["d", "repo"]).unwrap()]); + assert_eq!(resolve_repo_binding(&event), RepoBinding::NotBound); + } + + #[test] + fn malformed_and_empty_values_are_broken_not_absent() { + let malformed = announcement(vec![ + Tag::parse(["d", "repo"]).unwrap(), + Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), + ]); + assert_eq!(resolve_repo_binding(&malformed), RepoBinding::Broken); + + let empty = announcement(vec![ + Tag::parse(["d", "repo"]).unwrap(), + Tag::parse(["buzz-channel"]).unwrap(), + ]); + assert_eq!(resolve_repo_binding(&empty), RepoBinding::Broken); + } + + #[test] + fn fails_closed_on_ambiguous_duplicate_bindings() { + let ch = uuid::Uuid::new_v4(); + let other = uuid::Uuid::new_v4(); + + // Malformed first + valid second: the ambiguity denies; the valid + // duplicate must NOT win, or the duplicate picks the channel. + let malformed_first = announcement(vec![ + Tag::parse(["d", "repo"]).unwrap(), + Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + ]); + assert_eq!(resolve_repo_binding(&malformed_first), RepoBinding::Broken); + + // Valid first + different second: first wins deterministically. + let valid_first = announcement(vec![ + Tag::parse(["d", "repo"]).unwrap(), + Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), + Tag::parse(["buzz-channel", &other.to_string()]).unwrap(), + ]); + assert_eq!(resolve_repo_binding(&valid_first), RepoBinding::Bound(ch)); + } +} diff --git a/crates/buzz-relay/src/api/git/mod.rs b/crates/buzz-relay/src/api/git/mod.rs index ab0510fbeb..dd69d7dc36 100644 --- a/crates/buzz-relay/src/api/git/mod.rs +++ b/crates/buzz-relay/src/api/git/mod.rs @@ -22,6 +22,7 @@ use tower_http::limit::RequestBodyLimitLayer; use crate::state::AppState; +pub mod binding; pub mod cas_publish; pub mod hook; pub mod hydrate; diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index fd6c4fb688..32d63f4600 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -42,7 +42,10 @@ use tracing::{error, warn}; use uuid::Uuid; use buzz_core::channel::MemberRole; -use buzz_core::git_perms::{evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind}; +use buzz_core::git_perms::{ + evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind, + GIT_NO_CHANNEL_BINDING_BODY, +}; use buzz_db::EventQuery; use crate::state::AppState; @@ -297,12 +300,29 @@ pub async fn hook_policy_check( } }; - // 6. Resolve channel and check archived state (applies to ALL pushers including owner). - let channel_id = tags - .iter() - .find(|t| t.first().map(|s| s.as_str()) == Some("buzz-channel")) - .and_then(|t| t.get(1)) - .and_then(|id| Uuid::parse_str(id).ok()); + // 6. Resolve channel binding via the shared resolver (same first-tag, + // fail-closed semantics as the read gate) and check archived state + // (applies to ALL pushers including owner). + // + // `Broken` denies HERE, before owner resolution: a malformed or + // ambiguous first binding fails closed for *everyone*, exactly like the + // read gate. Letting it fall through as "unbound" would hand the owner + // short-circuit below a push path through a binding the read gate + // refuses to honor — the tri-state exists precisely so Broken and + // NotBound cannot collapse. Only genuinely-NotBound repos proceed, and + // only they may earn the remediation-token denial. + let channel_id = match crate::api::git::binding::resolve_repo_binding(&repo_event.event) { + crate::api::git::binding::RepoBinding::Bound(id) => Some(id), + crate::api::git::binding::RepoBinding::NotBound => None, + crate::api::git::binding::RepoBinding::Broken => { + warn!(repo = %req.repo_id, "hook callback: broken buzz-channel binding"); + // Deliberately NOT the no_channel_binding token body: the + // remediation contract is NotBound-only. A broken binding is + // ambiguity, and ambiguity gets a generic denial (matching the + // read gate's posture for the same announcement). + return (StatusCode::FORBIDDEN, "invalid channel binding").into_response(); + } + }; if let Some(ch_id) = channel_id { match state.db.get_channel(community, ch_id).await { @@ -350,7 +370,10 @@ pub async fn hook_policy_check( match channel_id { None => { warn!(repo = %req.repo_id, "hook callback: no buzz-channel binding"); - return (StatusCode::FORBIDDEN, "no channel binding").into_response(); + // Declared cross-component contract — see the const docs in + // buzz-core::git_perms for who consumes the token and why + // the body also repeats the legacy phrase. + return (StatusCode::FORBIDDEN, GIT_NO_CHANNEL_BINDING_BODY).into_response(); } Some(ch_id) => { match state @@ -482,6 +505,30 @@ mod tests { assert!(!verify_hmac(b"wrong-secret", &req)); } + /// Deploy-skew guard for the unbound-repo deny body. The token + /// (`no_channel_binding`, underscores) and the legacy phrase + /// (`no channel binding`, spaces) do NOT contain each other, so the body + /// must carry both: the token for structured consumers (Desktop's merge + /// classifier and dialog matcher), the phrase for desktops already in + /// the field that prose-match it. Relay ships continuously and Desktop + /// on release cadence — dropping the phrase strands every old desktop + /// on a new relay. Asserted against the shared consts, not re-typed + /// literals, so the const and this test cannot drift apart separately. + #[test] + fn no_channel_binding_body_satisfies_old_and_new_matchers() { + assert!( + GIT_NO_CHANNEL_BINDING_BODY.starts_with(&format!( + "{}: ", + buzz_core::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN + )), + "new structured consumers match the token prefix" + ); + assert!( + GIT_NO_CHANNEL_BINDING_BODY.contains("no channel binding"), + "shipped desktops prose-match this exact phrase (spaces, not underscores)" + ); + } + #[test] fn hmac_tampered_repo_id_rejected() { let secret = b"test-secret"; @@ -772,4 +819,169 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/nu "Single-ref HMAC mismatch!\n Rust: {rust_sig}\n Bash: {bash_sig}" ); } + + // ── hook_policy_check binding gate (requires Postgres) ────────────── + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 + + async fn policy_test_state() -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + config.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&config.database_url) + .await + .expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) + } + + /// Announce `repo_id` with the given tags, then push to it as its own + /// announcement author and return the response. + async fn owner_push_response( + state: &Arc, + community: buzz_core::CommunityId, + keys: &nostr::Keys, + repo_id: &str, + binding_tags: Vec, + ) -> axum::response::Response { + use nostr::{EventBuilder, Kind, Tag}; + + let mut tags = vec![Tag::parse(["d", repo_id]).unwrap()]; + tags.extend(binding_tags); + let event = EventBuilder::new(Kind::Custom(30617), "") + .tags(tags) + .sign_with_keys(keys) + .expect("sign 30617"); + state + .db + .insert_event(community, &event, None) + .await + .expect("insert 30617"); + + let owner_hex = keys.public_key().to_hex(); + let mut req = HookCallbackRequest { + repo_id: repo_id.to_string(), + repo_owner: owner_hex.clone(), + community_id: community.as_uuid().to_string(), + pusher_pubkey: owner_hex, + ref_updates: vec![HookRefUpdate { + old_oid: "0".repeat(40), + new_oid: "2".repeat(40), + ref_name: "refs/heads/main".to_string(), + is_ancestor: false, + }], + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + signature: String::new(), + }; + let secret = state.config.git_hook_hmac_secret.clone(); + sign_request(&mut req, secret.as_bytes()); + hook_policy_check(State(Arc::clone(state)), Json(req)).await + } + + async fn body_string(response: axum::response::Response) -> (StatusCode, String) { + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + (status, String::from_utf8(bytes.to_vec()).expect("utf-8")) + } + + /// The tri-state trap the resolver exists to prevent: a broken (malformed + /// or ambiguous-first) binding must fail closed for EVERYONE on push — + /// including the announcement author — *before* the owner short-circuit + /// grants `MemberRole::Owner`. Collapsing `Broken` into "unbound" hands + /// the owner a push path through a binding the read gate refuses to + /// honor. The remediation token stays reserved for genuinely NotBound. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn push_gate_denies_owner_through_broken_binding() { + use nostr::{Keys, Tag}; + + let state = policy_test_state().await; + let host = format!("policy-{}.example", uuid::Uuid::new_v4().simple()); + let community = state + .db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + let keys = Keys::generate(); + + // Malformed first + valid-looking second: the ambiguity must deny, + // and the parseable duplicate must not rescue the push. + let response = owner_push_response( + &state, + community, + &keys, + &format!("repo-{}", uuid::Uuid::new_v4().simple()), + vec![ + Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), + Tag::parse(["buzz-channel", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ], + ) + .await; + let (status, body) = body_string(response).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + body, "invalid channel binding", + "owner pushing through a broken binding must be denied generically" + ); + assert!( + !body.contains(buzz_core::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN), + "remediation token is NotBound-only; Broken must never earn it" + ); + + // Control: the same owner pushing a genuinely NEVER-BOUND repo is + // allowed (owner authority over an unbound announcement is the + // long-standing push semantics). This pins the denial above to + // Broken specifically, not to some broader regression. + let response = owner_push_response( + &state, + community, + &keys, + &format!("repo-{}", uuid::Uuid::new_v4().simple()), + vec![], + ) + .await; + let (status, body) = body_string(response).await; + assert_eq!( + status, + StatusCode::OK, + "owner push to a never-bound repo must remain allowed (got body: {body})" + ); + } } diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index df5bdd4c3e..11c4f6d35b 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -28,6 +28,7 @@ use tokio::process::Command; use tower_http::limit::RequestBodyLimitLayer; use tracing::{error, info, warn}; +use super::binding::{resolve_repo_binding, RepoBinding}; use super::cas_publish::{cas_publish, CasError, ParentState, PublishLimits}; use super::hook::install_hook; use super::hydrate::{ @@ -377,7 +378,15 @@ fn hydrate_error_to_response(owner: &str, repo: &str, err: HydrateError) -> Resp /// error all deny. There is deliberately **no repo-owner bypass**: an owner /// removed from the bound channel loses read access, which is the exact /// exploit shape this gate closes. Every denial is the same generic 404 as a -/// nonexistent repo so membership cannot be probed through the git endpoints. +/// nonexistent repo so membership cannot be probed through the git endpoints +/// — with exactly one carve-out: a **never-bound** repo read by its own +/// **announcement author** returns a 404 whose body tells the author how to +/// bind it (issue #3527: a vanilla NIP-34 client can announce without a +/// `buzz-channel` tag, and the repo then 404s forever with no explanation +/// for anyone). The author already knows the repo exists — they announced it +/// — so the remediation body leaks nothing, and only the author can rebind +/// (kind:30617 is keyed by `(author, d)`). A *broken* binding stays generic +/// even for the author: ambiguity fails closed. async fn authorize_git_read( db: &buzz_db::Db, community: buzz_core::CommunityId, @@ -415,9 +424,32 @@ async fn authorize_git_read( } }; - let Some(channel_id) = repo_bound_channel_id(&repo_event.event) else { - warn!(repo = %repo_name, "git read gate: missing/malformed buzz-channel binding (deny)"); - return Err(denied()); + let channel_id = match resolve_repo_binding(&repo_event.event) { + RepoBinding::Bound(id) => id, + RepoBinding::NotBound => { + // Remediation carve-out: author of a never-bound announcement. + // Status stays 404 — byte-identical to every other denial at the + // status level — so denial *class* is still unprobeable; only + // the body differs, and only for the one identity that already + // knows the repo exists. The body is a single verb-first line: + // Desktop error paths that keep one line keep the instruction. + if repo_event.event.pubkey == *caller { + warn!(repo = %repo_name, "git read gate: unbound repo read by its author (deny with remediation)"); + return Err(( + StatusCode::NOT_FOUND, + format!( + "run: buzz repos bind --id {repo_name} --channel — repository {repo_name:?} has no channel binding, so the relay cannot authorize access" + ), + ) + .into_response()); + } + warn!(repo = %repo_name, "git read gate: missing buzz-channel binding (deny)"); + return Err(denied()); + } + RepoBinding::Broken => { + warn!(repo = %repo_name, "git read gate: malformed buzz-channel binding (deny)"); + return Err(denied()); + } }; match db @@ -433,24 +465,6 @@ async fn authorize_git_read( } } -/// Extract the `buzz-channel` UUID from a kind:30617 announcement. -/// -/// First-tag semantics, matching the push policy endpoint: only the *first* -/// `buzz-channel` tag is considered, and it must carry a valid UUID. A -/// malformed first binding denies even if a later duplicate tag is valid — -/// an ambiguous announcement must fail closed, not silently resolve to -/// whichever duplicate happens to parse. -fn repo_bound_channel_id(event: &nostr::Event) -> Option { - let first = event - .tags - .iter() - .find(|t| t.as_slice().first().map(String::as_str) == Some("buzz-channel"))?; - first - .as_slice() - .get(1) - .and_then(|v| uuid::Uuid::parse_str(v).ok()) -} - /// Pure decision for [`authorize_git_read`]: a read requires a current /// active membership row whose role the relay recognizes. /// @@ -2454,75 +2468,30 @@ mod sec005_read_gate_tests { .expect("sign 30617") } - #[test] - fn repo_bound_channel_id_extracts_valid_uuid() { - let keys = Keys::generate(); - let ch = uuid::Uuid::new_v4(); - let event = announcement( - &keys, - vec![ - Tag::parse(["d", "r"]).unwrap(), - Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), - ], - ); - assert_eq!(repo_bound_channel_id(&event), Some(ch)); - } - - #[test] - fn repo_bound_channel_id_rejects_absent_and_malformed_bindings() { - let keys = Keys::generate(); - let absent = announcement(&keys, vec![Tag::parse(["d", "r"]).unwrap()]); - assert_eq!(repo_bound_channel_id(&absent), None); - - let malformed = announcement( - &keys, - vec![ - Tag::parse(["d", "r"]).unwrap(), - Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), - ], - ); - assert_eq!(repo_bound_channel_id(&malformed), None); - - let empty = announcement( - &keys, - vec![ - Tag::parse(["d", "r"]).unwrap(), - Tag::parse(["buzz-channel"]).unwrap(), - ], - ); - assert_eq!(repo_bound_channel_id(&empty), None); + // Binding *parse* semantics (first-tag fails-closed, duplicate-tag + // ambiguity, malformed vs. absent) are unit-tested where the resolver + // lives: `super::super::binding`. The tests below prove the *gate* wires + // each resolver outcome to the right response — allow, generic denial + // body, or the author remediation body — which the resolver tests + // cannot see. + + /// Collapse an `authorize_git_read` denial to `(status, body)` so tests + /// can assert on the exact bytes a git client would see. A blind + /// `.is_err()` cannot distinguish the generic 404 from the remediation + /// 404 — and that distinction IS the security property. + async fn denial_parts(result: Result<(), Response>) -> (StatusCode, String) { + let response = result.expect_err("expected a denial"); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read denial body"); + ( + status, + String::from_utf8(bytes.to_vec()).expect("utf-8 body"), + ) } - #[test] - fn repo_bound_channel_id_fails_closed_on_ambiguous_duplicate_bindings() { - // First-tag semantics: a malformed first binding must deny even when - // a later duplicate tag is valid. An ambiguous announcement must not - // silently resolve to whichever duplicate happens to parse. - let keys = Keys::generate(); - let ch = uuid::Uuid::new_v4(); - let malformed_first = announcement( - &keys, - vec![ - Tag::parse(["d", "r"]).unwrap(), - Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap(), - Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), - ], - ); - assert_eq!(repo_bound_channel_id(&malformed_first), None); - - // And the mirror image: a valid first binding wins, matching the - // push policy endpoint's first-tag resolution. - let other = uuid::Uuid::new_v4(); - let valid_first = announcement( - &keys, - vec![ - Tag::parse(["d", "r"]).unwrap(), - Tag::parse(["buzz-channel", &ch.to_string()]).unwrap(), - Tag::parse(["buzz-channel", &other.to_string()]).unwrap(), - ], - ); - assert_eq!(repo_bound_channel_id(&valid_first), Some(ch)); - } + const GENERIC_DENIAL: &str = "repository not found"; // ── authorize_git_read matrix (requires Postgres) ──────────────────── @@ -2544,6 +2513,11 @@ mod sec005_read_gate_tests { Missing, /// `buzz-channel` tag whose value is not a UUID. Malformed, + /// `buzz-channel` tag carrying a well-formed UUID that names no + /// channel. The resolver reports `Bound`; the membership lookup + /// (whose SQL joins `channels … deleted_at IS NULL`) then returns + /// no role — the deliberate phase-1 posture for dead bindings. + UnknownChannel, } struct RepoFixture { @@ -2609,6 +2583,9 @@ mod sec005_read_gate_tests { Binding::Malformed => { tags.push(Tag::parse(["buzz-channel", "not-a-uuid"]).unwrap()); } + Binding::UnknownChannel => { + tags.push(Tag::parse(["buzz-channel", &uuid::Uuid::new_v4().to_string()]).unwrap()); + } } let event = announcement(&owner_keys, tags); db.insert_event(community, &event, None) @@ -2676,32 +2653,63 @@ mod sec005_read_gate_tests { #[tokio::test] #[ignore = "requires Postgres"] async fn read_gate_denies_missing_or_malformed_binding_and_absent_repo() { - // Missing buzz-channel tag → deny even for a channel member. + // Missing buzz-channel tag → deny even for a channel member, with + // the generic body: the remediation carve-out is author-only. let f = setup_repo(Binding::Missing).await; let member = f.member_keys.public_key(); - assert!( - authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo) - .await - .is_err(), - "announcement without buzz-channel binding must deny" + let (status, body) = denial_parts( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo).await, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + body, GENERIC_DENIAL, + "unbound repo read by a NON-author must get the generic body — \ + remediation for anyone but the announcement author leaks repo existence" ); - // Malformed buzz-channel tag → deny. + // Malformed buzz-channel tag → deny with the generic body EVEN FOR + // THE AUTHOR. This is the assertion that pins the carve-out to + // NotBound: if it ever fires on Broken, this fails on bytes, not + // on Ok/Err (which cannot see the difference). let g = setup_repo(Binding::Malformed).await; - let member_g = g.member_keys.public_key(); - assert!( - authorize_git_read(&g.db, g.community, &member_g, &g.owner_hex, &g.repo) - .await - .is_err(), - "announcement with malformed buzz-channel binding must deny" + let g_owner = g.owner_keys.public_key(); + let (status, body) = denial_parts( + authorize_git_read(&g.db, g.community, &g_owner, &g.owner_hex, &g.repo).await, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + body, GENERIC_DENIAL, + "broken binding must stay generic even for the author (ambiguity fails closed)" + ); + + // Well-formed UUID naming a nonexistent channel → resolver says + // Bound, membership lookup finds nothing → generic denial for + // everyone, author included. The dead-channel case must be + // indistinguishable from non-membership (phase-1 posture; ingest + // validation closes the front door in phase 2). + let u = setup_repo(Binding::UnknownChannel).await; + let u_owner = u.owner_keys.public_key(); + let (status, body) = denial_parts( + authorize_git_read(&u.db, u.community, &u_owner, &u.owner_hex, &u.repo).await, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + body, GENERIC_DENIAL, + "binding to a nonexistent channel must deny generically, even for the author" ); // Nonexistent announcement → deny. - assert!( - authorize_git_read(&f.db, f.community, &member, &f.owner_hex, "no-such-repo") - .await - .is_err(), - "nonexistent repo must deny" + let (status, body) = denial_parts( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, "no-such-repo").await, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + body, GENERIC_DENIAL, + "nonexistent repo must deny generically" ); // Owner-mismatch: URL owner differs from announcement author → deny. @@ -2722,6 +2730,53 @@ mod sec005_read_gate_tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_gate_gives_author_of_unbound_repo_remediation_body() { + // Issue #3527: the author of a never-bound announcement is the one + // identity that can fix it (30617 is keyed by (author, d)) and the + // one identity remediation cannot leak anything to. Status must stay + // 404 — identical to every other denial — with the bind command in + // the body. + let f = setup_repo(Binding::Missing).await; + let author = f.owner_keys.public_key(); + + let response = authorize_git_read(&f.db, f.community, &author, &f.owner_hex, &f.repo) + .await + .expect_err("unbound repo must still deny its author"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + // Guard against a future "tidy" into Json(...) or a custom + // IntoResponse: git prints `remote:` lines only for text/plain + // bodies — any other content-type makes the remediation silently + // invisible in the user's terminal with no failing assertion. + assert_eq!( + response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("text/plain; charset=utf-8"), + "remediation body must stay text/plain or git clients will swallow it" + ); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("read remediation body"); + let body = String::from_utf8(bytes.to_vec()).expect("utf-8 body"); + assert!( + body.starts_with(&format!("run: buzz repos bind --id {}", f.repo)), + "remediation must lead with the actionable command (got {body:?})" + ); + assert_ne!(body, GENERIC_DENIAL); + + // Same repo, same state, different caller: a member of some channel + // who is not the author still gets the generic body. + let member = f.member_keys.public_key(); + let (_, body) = denial_parts( + authorize_git_read(&f.db, f.community, &member, &f.owner_hex, &f.repo).await, + ) + .await; + assert_eq!(body, GENERIC_DENIAL, "remediation is author-only"); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn read_gate_follows_current_announcement_not_stale_registry() { diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index a30b0e714d..ee644d5a9b 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2481,7 +2481,12 @@ async fn ingest_event_inner( crate::handlers::side_effects::handle_side_effects(tenant, kind_u32, &event, state) .await { - warn!(event_id = %event_id_hex, kind = kind_u32, "Side effect failed: {e}"); + // error!, not warn!: the event was accepted but its side effects + // (channel creation, git repo seeding, …) did not run — the relay + // is now in a state the client believes it isn't. Production runs + // RUST_LOG=error, so warn! made these failures invisible during + // the #3527 triage. + error!(event_id = %event_id_hex, kind = kind_u32, "Side effect failed: {e}"); } } diff --git a/crates/buzz-test-client/tests/e2e_git.rs b/crates/buzz-test-client/tests/e2e_git.rs index f543e56229..3c82e31764 100644 --- a/crates/buzz-test-client/tests/e2e_git.rs +++ b/crates/buzz-test-client/tests/e2e_git.rs @@ -62,6 +62,27 @@ async fn post_event(event: &nostr::Event) { ); } +/// Create a channel (kind:9007) owned by `keys` and return its UUID. +/// +/// The git read gate (SEC-005) authorizes against membership in the channel +/// named by the announcement's `buzz-channel` tag, so every repo these tests +/// announce must be bound to a channel its owner belongs to — creating the +/// channel makes the creator its owner-member. +async fn create_test_channel(keys: &Keys) -> String { + let channel_uuid = uuid::Uuid::new_v4().to_string(); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid]).unwrap(), + Tag::parse(["name", &format!("git-e2e-{channel_uuid}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + post_event(&event).await; + channel_uuid +} + /// Run `git` with the Buzz credential helper and isolated config. fn git_status(args: &[&str], cwd: &Path, owner_nsec: &str) -> std::process::Output { let helper = credential_helper(); @@ -256,10 +277,15 @@ async fn git_clone_push_fetch_force_roundtrip() { let s3 = GitS3Probe::from_env(); // Announce the repo (kind:30617) so the relay creates the bare repo + hook. + // The `buzz-channel` binding is the repo's ACL: without it the read gate + // 404s even for the owner (issue #3527), so bind to a channel the owner + // just created (and therefore belongs to). + let channel = create_test_channel(&owner).await; let announce = EventBuilder::new(Kind::from(30617), "") .tags(vec![ Tag::parse(["d", &repo]).unwrap(), Tag::parse(["name", "e2e git repo"]).unwrap(), + Tag::parse(["buzz-channel", &channel]).unwrap(), ]) .sign_with_keys(&owner) .unwrap(); @@ -393,10 +419,12 @@ async fn git_concurrent_push_one_wins_and_repo_recovers() { let repo = format!("e2e-git-concurrent-{}", std::process::id()); let s3 = GitS3Probe::from_env(); + let channel = create_test_channel(&owner).await; let announce = EventBuilder::new(Kind::from(30617), "") .tags(vec![ Tag::parse(["d", &repo]).unwrap(), Tag::parse(["name", "e2e concurrent git repo"]).unwrap(), + Tag::parse(["buzz-channel", &channel]).unwrap(), ]) .sign_with_keys(&owner) .unwrap(); diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 1c89ee4f77..66ef7ef17b 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -44,6 +44,7 @@ mod project_git; mod project_git_branches; mod project_git_diff; mod project_git_exec; +mod project_git_merge_error; mod project_git_push; mod project_git_workflow; mod project_repo_paths; diff --git a/desktop/src-tauri/src/commands/project_git_merge_error.rs b/desktop/src-tauri/src/commands/project_git_merge_error.rs new file mode 100644 index 0000000000..460ce83fa3 --- /dev/null +++ b/desktop/src-tauri/src/commands/project_git_merge_error.rs @@ -0,0 +1,152 @@ +//! Structured pull-request merge failures returned across the Tauri boundary. + +use serde::Serialize; + +/// Machine-readable recovery metadata for a failed pull-request merge. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestMergeRecovery { + action: String, + target_branch: String, + source_branch: String, +} + +/// Structured pull-request merge failure returned across the Tauri boundary. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectPullRequestMergeError { + code: String, + message: String, + recovery: Option, +} + +impl ProjectPullRequestMergeError { + pub(crate) fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + recovery: None, + } + } + + fn conflict(target_branch: String, source_branch: String) -> Self { + Self { + code: "merge_conflict".to_string(), + message: "Pull request has merge conflicts.".to_string(), + recovery: Some(ProjectPullRequestMergeRecovery { + action: "open_terminal".to_string(), + target_branch, + source_branch, + }), + } + } +} + +impl From for ProjectPullRequestMergeError { + fn from(message: String) -> Self { + // Relay push-policy denial for a repo with no `buzz-channel` binding. + // The stable token is declared in `buzz-core::git_perms` + // (GIT_NO_CHANNEL_BINDING_TOKEN); the relay guarantees the denial body + // starts with it. Push failures reach this conversion as raw + // stderr/`remote:` text, so match the token anywhere in the message. + if message.contains(buzz_core_pkg::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN) { + return Self::new( + buzz_core_pkg::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN, + "This repository is not bound to a channel, so the relay cannot \ + authorize pushes. Bind it with: buzz repos bind --id \ + --channel ", + ); + } + Self::new("merge_failed", message) + } +} + +pub(crate) fn classify_merge_error( + message: String, + has_conflicts: bool, + target_branch: &str, + source_branch: &str, +) -> ProjectPullRequestMergeError { + if has_conflicts { + ProjectPullRequestMergeError::conflict(target_branch.to_string(), source_branch.to_string()) + } else { + ProjectPullRequestMergeError::new( + "merge_failed", + format!("Pull request merge failed: {message}"), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{classify_merge_error, ProjectPullRequestMergeError}; + + #[test] + fn merge_conflict_error_has_stable_recovery_metadata() { + let error = + ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string()); + + assert_eq!(error.code, "merge_conflict"); + assert_eq!(error.message, "Pull request has merge conflicts."); + let recovery = error.recovery.expect("conflict recovery"); + assert_eq!(recovery.action, "open_terminal"); + assert_eq!(recovery.target_branch, "main"); + assert_eq!(recovery.source_branch, "feature/demo"); + } + + #[test] + fn merge_conflict_error_serializes_for_tauri_clients() { + let error = + ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string()); + let value = serde_json::to_value(error).expect("serialize merge conflict"); + + assert_eq!(value["code"], "merge_conflict"); + assert_eq!(value["recovery"]["targetBranch"], "main"); + assert_eq!(value["recovery"]["sourceBranch"], "feature/demo"); + } + + #[test] + fn merge_error_classification_only_recovers_conflicts() { + let conflict = classify_merge_error( + "CONFLICT (content): Merge conflict in src/main.rs".to_string(), + true, + "main", + "feature/demo", + ); + assert_eq!(conflict.code, "merge_conflict"); + assert!(conflict.recovery.is_some()); + + let other = classify_merge_error( + "fatal: refusing to merge unrelated histories".to_string(), + false, + "main", + "feature/demo", + ); + assert_eq!(other.code, "merge_failed"); + assert!(other.recovery.is_none()); + } + + #[test] + fn no_channel_binding_denial_converts_to_structured_code() { + // The relay's push-policy denial arrives as raw git stderr with + // `remote:` framing; the stable token must be recognized wherever it + // sits in the message. + let remote_stderr = format!( + "remote: {}\nerror: failed to push some refs", + buzz_core_pkg::git_perms::GIT_NO_CHANNEL_BINDING_BODY + ); + let error = ProjectPullRequestMergeError::from(remote_stderr); + + assert_eq!( + error.code, + buzz_core_pkg::git_perms::GIT_NO_CHANNEL_BINDING_TOKEN + ); + assert!(error.message.contains("buzz repos bind")); + assert!(error.recovery.is_none()); + + // Unrelated push failures keep the generic code and original text. + let generic = ProjectPullRequestMergeError::from("connection reset".to_string()); + assert_eq!(generic.code, "merge_failed"); + assert_eq!(generic.message, "connection reset"); + } +} diff --git a/desktop/src-tauri/src/commands/project_git_workflow.rs b/desktop/src-tauri/src/commands/project_git_workflow.rs index 39832feb10..624bbf4dfc 100644 --- a/desktop/src-tauri/src/commands/project_git_workflow.rs +++ b/desktop/src-tauri/src/commands/project_git_workflow.rs @@ -32,67 +32,10 @@ pub struct ProjectRepoMergeResult { pub status_publication_error: Option, } -/// Machine-readable recovery metadata for a failed pull-request merge. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectPullRequestMergeRecovery { - action: String, - target_branch: String, - source_branch: String, -} - -/// Structured pull-request merge failure returned across the Tauri boundary. -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ProjectPullRequestMergeError { - code: String, - message: String, - recovery: Option, -} - -impl ProjectPullRequestMergeError { - fn new(code: &str, message: impl Into) -> Self { - Self { - code: code.to_string(), - message: message.into(), - recovery: None, - } - } - - fn conflict(target_branch: String, source_branch: String) -> Self { - Self { - code: "merge_conflict".to_string(), - message: "Pull request has merge conflicts.".to_string(), - recovery: Some(ProjectPullRequestMergeRecovery { - action: "open_terminal".to_string(), - target_branch, - source_branch, - }), - } - } -} - -impl From for ProjectPullRequestMergeError { - fn from(message: String) -> Self { - Self::new("merge_failed", message) - } -} - -fn classify_merge_error( - message: String, - has_conflicts: bool, - target_branch: &str, - source_branch: &str, -) -> ProjectPullRequestMergeError { - if has_conflicts { - ProjectPullRequestMergeError::conflict(target_branch.to_string(), source_branch.to_string()) - } else { - ProjectPullRequestMergeError::new( - "merge_failed", - format!("Pull request merge failed: {message}"), - ) - } -} +/// Machine-readable pull-request merge failure types live in +/// [`super::project_git_merge_error`]; re-imported here for the merge +/// workflow below. +use super::project_git_merge_error::{classify_merge_error, ProjectPullRequestMergeError}; struct ProjectRepoMergeGitResult { message: String, @@ -739,8 +682,8 @@ pub async fn merge_project_pull_request( mod tests { use super::{ align_unborn_head_branch, build_merged_status_event, build_pull_request_status_event, - build_review_request_event, classify_merge_error, normalize_commit, same_repository, - validate_merge_status_metadata, ProjectPullRequestMergeError, + build_review_request_event, normalize_commit, same_repository, + validate_merge_status_metadata, }; use crate::commands::project_git_exec::{build_test_git_auth_config, run_git}; use nostr::{Event, JsonUtil, Keys, Timestamp}; @@ -785,51 +728,6 @@ mod tests { )); } - #[test] - fn merge_conflict_error_has_stable_recovery_metadata() { - let error = - ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string()); - - assert_eq!(error.code, "merge_conflict"); - assert_eq!(error.message, "Pull request has merge conflicts."); - let recovery = error.recovery.expect("conflict recovery"); - assert_eq!(recovery.action, "open_terminal"); - assert_eq!(recovery.target_branch, "main"); - assert_eq!(recovery.source_branch, "feature/demo"); - } - - #[test] - fn merge_conflict_error_serializes_for_tauri_clients() { - let error = - ProjectPullRequestMergeError::conflict("main".to_string(), "feature/demo".to_string()); - let value = serde_json::to_value(error).expect("serialize merge conflict"); - - assert_eq!(value["code"], "merge_conflict"); - assert_eq!(value["recovery"]["targetBranch"], "main"); - assert_eq!(value["recovery"]["sourceBranch"], "feature/demo"); - } - - #[test] - fn merge_error_classification_only_recovers_conflicts() { - let conflict = classify_merge_error( - "CONFLICT (content): Merge conflict in src/main.rs".to_string(), - true, - "main", - "feature/demo", - ); - assert_eq!(conflict.code, "merge_conflict"); - assert!(conflict.recovery.is_some()); - - let other = classify_merge_error( - "fatal: refusing to merge unrelated histories".to_string(), - false, - "main", - "feature/demo", - ); - assert_eq!(other.code, "merge_failed"); - assert!(other.recovery.is_none()); - } - #[test] fn merged_status_is_signed_by_repository_owner() { let keys = Keys::generate(); diff --git a/desktop/src/features/projects/lib/projectBranchErrors.test.mjs b/desktop/src/features/projects/lib/projectBranchErrors.test.mjs new file mode 100644 index 0000000000..c1c2a21a28 --- /dev/null +++ b/desktop/src/features/projects/lib/projectBranchErrors.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + isNoChannelBindingError, + projectBranchErrorMessage, +} from "./projectBranchErrors.ts"; + +test("recognizes the relay's stable denial token", () => { + // Body produced by the relay push policy (buzz-core + // GIT_NO_CHANNEL_BINDING_BODY), as it arrives wrapped in git stderr. + assert.ok( + isNoChannelBindingError( + "remote: no_channel_binding: repository has no channel binding\nerror: failed to push some refs", + ), + ); +}); + +test("recognizes the legacy spaced phrase from older relays", () => { + assert.ok(isNoChannelBindingError("push denied: no channel binding")); +}); + +test("does not match unrelated errors", () => { + assert.ok(!isNoChannelBindingError("connection reset by peer")); + assert.ok(!isNoChannelBindingError("no channel")); +}); + +test("maps binding denials to remediation copy", () => { + const message = projectBranchErrorMessage( + new Error("remote: no_channel_binding: repository has no channel binding"), + "Failed to create branch.", + ); + assert.ok(message.includes("buzz repos bind")); +}); + +test("passes through other errors and falls back for non-errors", () => { + assert.equal( + projectBranchErrorMessage(new Error("boom"), "fallback"), + "boom", + ); + assert.equal(projectBranchErrorMessage("boom", "fallback"), "fallback"); + assert.equal(projectBranchErrorMessage(null, "fallback"), "fallback"); +}); diff --git a/desktop/src/features/projects/lib/projectBranchErrors.ts b/desktop/src/features/projects/lib/projectBranchErrors.ts new file mode 100644 index 0000000000..2cf990f1af --- /dev/null +++ b/desktop/src/features/projects/lib/projectBranchErrors.ts @@ -0,0 +1,35 @@ +/** + * Relay push-policy denial token for a repository with no `buzz-channel` + * binding. Declared in Rust as `buzz-core::git_perms:: + * GIT_NO_CHANNEL_BINDING_TOKEN`; the relay's denial body starts with it + * ("no_channel_binding: repository has no channel binding"). The legacy + * spaced phrase is kept as a second matcher so this build also recognizes + * denials from relays deployed before the token existed. + */ +const NO_CHANNEL_BINDING_TOKEN = "no_channel_binding"; +const NO_CHANNEL_BINDING_LEGACY_PHRASE = "no channel binding"; + +const NO_CHANNEL_BINDING_COPY = + "This repository is not linked to a project channel, so the relay cannot " + + "authorize access. The repository owner can link it with: " + + "buzz repos bind --id --channel "; + +/** True when a git/relay error text is the unbound-repository denial. */ +export function isNoChannelBindingError(message: string): boolean { + return ( + message.includes(NO_CHANNEL_BINDING_TOKEN) || + message.includes(NO_CHANNEL_BINDING_LEGACY_PHRASE) + ); +} + +/** Map a thrown branch-operation error to user-facing dialog copy. */ +export function projectBranchErrorMessage( + error: unknown, + fallback: string, +): string { + if (!(error instanceof Error)) return fallback; + if (isNoChannelBindingError(error.message)) { + return NO_CHANNEL_BINDING_COPY; + } + return error.message; +} diff --git a/desktop/src/features/projects/ui/ProjectBranchDialogs.tsx b/desktop/src/features/projects/ui/ProjectBranchDialogs.tsx index 20e8d14e1b..c2bde47355 100644 --- a/desktop/src/features/projects/ui/ProjectBranchDialogs.tsx +++ b/desktop/src/features/projects/ui/ProjectBranchDialogs.tsx @@ -5,6 +5,7 @@ import { normalizeProjectBranchName, projectBranchNameError, } from "@/features/projects/lib/projectBranches"; +import { projectBranchErrorMessage } from "@/features/projects/lib/projectBranchErrors"; import { AlertDialog, AlertDialogCancel, @@ -25,14 +26,6 @@ import { } from "@/shared/ui/dialog"; import { Input } from "@/shared/ui/input"; -function errorMessage(error: unknown, fallback: string) { - if (!(error instanceof Error)) return fallback; - if (error.message.includes("no channel binding")) { - return "This repository is owned by another identity and is not linked to a project channel."; - } - return error.message; -} - export function CreateProjectBranchDialog({ existingBranches, onCreate, @@ -69,7 +62,9 @@ export function CreateProjectBranchDialog({ await onCreate(branch); onOpenChange(false); } catch (error) { - setSubmitError(errorMessage(error, "Failed to create branch.")); + setSubmitError( + projectBranchErrorMessage(error, "Failed to create branch."), + ); } } @@ -165,7 +160,9 @@ export function DeleteProjectBranchDialog({ await onDelete(); onOpenChange(false); } catch (error) { - setSubmitError(errorMessage(error, "Failed to delete branch.")); + setSubmitError( + projectBranchErrorMessage(error, "Failed to delete branch."), + ); } } From 63496cc1d4c6f1b7c613801bdcc694169dcf391a Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:35:15 -0400 Subject: [PATCH 48/99] feat(replica): portable heartbeat-token fence with snapshot-local reader routing (#3268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Expands read-replica usage on the relay per the Rev 2 design (thread 39d1e174 in #buzz-read-only-replica-usage): replaces the Aurora-incompatible WAL-LSN read-side fence observation with a portable **heartbeat token**, makes the freshness proof **snapshot-local to the serving reader session**, and adds a default-off bounded-staleness gate for head fetches. ### Probe (writer side, unchanged ordering) The ordered writer scan is kept verbatim — `S = clock_timestamp()` → masked-visibility `pg_stat_activity` oldest-xact scan — and now ends by committing a heartbeat token **last** on the same pinned connection (single-row `UPDATE replica_heartbeat ... RETURNING token, epoch`, migration `0026`). The single-row update serializes all pods' probes, so tokens are globally commit-ordered; the three-bucket completeness argument carries over unchanged. Ring retains `(token, committed_at, fence_wall)`; epoch change resets the ring; a same-epoch token regression (restore adversary) clears the ring **and rotates the epoch on the writer** so pre-rewind readers fail the epoch check. Cadence 1s. ### Routing (snapshot-local proof) Every routed read opens `BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY` on a reader session and observes the heartbeat as the transaction's **first statement** — the proof's snapshot is exactly the snapshot the page, participants batch, and bridge aux closure read from (`ReadSession` carries the open transaction; drop = rollback). Fail-closed everywhere: begin failure, missing heartbeat row, epoch mismatch, token below the ring, over-budget entry → writer. - **Predicate B (cursor pages, default-on):** same completeness math as the existing fence — cursor timestamp must be ≤ the proved wall; thread candidate-terminal and above-wall pages re-run on the writer. - **Predicate A (head fetches, default-OFF):** gated by `BUZZ_REPLICA_HEAD_MAX_AGE_SECS` (0 = off, clamped to fence staleness). Bounded-stale head semantics are an explicit product decision — **do not enable anywhere without Tyler's backdated-event-semantics acceptance.** ### Observability `buzz_db_route_decision{path, decision, reason}` across all five paths, `buzz_db_replica_heartbeat_age_seconds` gauge, and per-decision debug logs carrying the proved token plus backend identity: `addr:port pid=N`, prefixed with `aurora_db_instance_identifier()` when the endpoint supports it (probed once per process on an autocommit checkout; SQLSTATE 42883 caches a definitive false; identity is evidence, never a routing gate). ## Review & verification - **Wren:** full review 9/9/9 at `5f81b10e5`; identity delta re-review approved at exact head `fedb46368` (90/90 unit, clippy `-D warnings`, fmt independently reproduced). - **Max:** local E2E **PASS** at `5f81b10e5` — isolated PG17 writer + two streaming standbys behind HAProxy; paused-reader legs split exactly as designed (6 replica/fresh + 6 writer/stale), recovery clean, RR snapshot hardening observed in runtime logs. Evidence: `WORK_LOGS/2026-07-28_REPLICA_HEARTBEAT_REPLACEMENT_SHA_E2E.md`. - **My gates at head (same shell):** buzz-db 90 unit + 143 Postgres-gated green (scratch DBs); buzz-relay 767/768 — lone red is the pre-existing `mesh_demo` flake, red on base; clippy `-D warnings` + fmt clean. - Key regression tests: `routed_request_holds_one_snapshot_across_page_and_aux` (mutation-verified both directions), same-epoch rotation, capability-probe negative, head-gate truth table, divergent-fixture routing suite. ## Post-merge plan Merge publishes the immutable `sha-*` main image → bb-public PR pins it → ArgoCD sync → Max runs the production cursor-routing canary on Aurora (positive identity branch proven live). Head gate stays off. Client `since`-widening ships separately. --- ## Update — full read routing (Rev 6, commit `9fa3c9c0b`) Extends routing to the remaining read seams per `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md` Rev 6 (thread cf5deba4 in #buzz-read-only-replica-usage). ### New routed seams — ALL deploy-default dark The new seams (`query_events_routed`, `query_events_routed_bounded`, `count_events_routed`, `get_events_by_ids_routed`, `query_feed_{mentions,needs_action,activity}_routed`) are **Bounded-only** and gated on `BUZZ_REPLICA_READ_MAX_AGE_MS`: unset ⇒ every new seam records `writer/disabled` and merging is a no-op. COUNT and feed/by-ids never take the covered arm (deletion visibility: covered bounds insert-completeness only). **Note:** the pre-existing cursor paths (channel windows, thread pages) are *not* gated by this env var — they route at B=0 today and that status quo is unchanged. ### Reader pool (D4/D5) - Lazy pool (`connect_lazy`, `min_connections(0)`): reader-down at boot can't crash the relay; a warn-only boot ping is the only boot-time visibility, and it primes the Aurora identity capability cache. Priming is an optimization only — the routed path itself spends a single acquire budget regardless (see the single-checkout fix below), because a boot ping that *fails* is correlated with exactly the reader-unavailable case the budget bound exists for. - `READER_ACQUIRE_TIMEOUT` = 150ms; a miss fails closed to the writer with reason **`reader_acquire_timeout`** — named for the mechanism, not a diagnosis. The budget includes cold connects and sqlx's `size` counts in-flight dials, so this metric alone does not distinguish contention from slow connection establishment (see `proved_reader` doc-comment for the runbook guidance; the pool gauges are 10s samples and are for capacity planning). - `BUZZ_DB_READ_POOL_SIZE` sizes the reader independently (invalid/0 inherits writer sizing); `read_pool_stats().max` reports the reader's own ceiling. ### Community isolation (formal-model question) Isolation is structural — an explicit `community_id = $n` predicate compiled into every query builder; no RLS, no session-GUC tenant state (zero `CREATE POLICY` across migrations). The `_on` variants reuse the exact same builders with only the executor swapped. Proven by a seven-seam two-community divergent-fixture test (`routed_reads_are_confined_to_the_requested_community`), mutation-tested by Dawn: all four single-predicate stubs killed; the mention-join feeds are defended in depth (three independent predicates) so only complete removal leaks there. ### Accepted limitation D6: client-side staleness on bounded reads (up to `_MS`) is accepted product behavior when the gate is enabled; gate stays off at merge. ## Update — single acquire budget per routed read (commit `dd26caa9f`) Max found (and Dawn independently reproduced, 302–330ms measured) that the boot-unavailable cold path spent **two** stacked `READER_ACQUIRE_TIMEOUT` budgets: the Aurora capability probe did its own `pool.acquire()` before `begin_with` acquired again. Fixed by acquiring **once** per routed read — the capability probe runs on the held connection (`reader_aurora_capability_on`) and the read-only `REPEATABLE READ` transaction begins on that same connection (`Transaction::begin` accepts a `PoolConnection` at `'static` via sqlx's `MaybePoolConnection`). Reason codes unchanged; capability still never negatively cached. Measured routed fallback: ~150ms (one budget). Ships with a PG-gated regression fixture (`routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold`, authored by Dawn): size-1 reader saturated, capability cache asserted cold, routes through `count_events_routed`, asserts writer answer + one-budget elapsed + `writer/reader_acquire_timeout` label (mutation-tested: `pool_busy` and `reader_validation_error` mutants both killed). It fails at 330ms on the previous commit and passes on this one. ### Verification at `dd26caa9f` (pinned toolchain rustc 1.95.0 via repo `bin/`; ambient rustc 1.89 fails sqlx resolution — always prepend `bin/`) - buzz-db: 94 unit + **150/151** PG-gated serial (`--test-threads=1`; suite is not parallel-safe against one Postgres). The 1 red is `test_usage_metrics_lock_has_single_owner_and_releases_on_drop` — a **pre-existing** same-key-same-database advisory-lock collision with any live relay pointed at the shared `buzz` DB (the relay re-arms `USAGE_METRICS_LOCK_KEY` on a 300s tick). It passes on a private scratch DB with the relay still running — verified this run. Remedy is a scratch `TEST_DATABASE_URL`, **not** terminating lock holders: inspect `pg_locks`/`pg_stat_activity` and resolve the owning process first; an idle advisory holder may be a live dev relay. Pre-existing, not a #3268 blocker — filed as #3619. - buzz-relay: 773/773; clippy `-D warnings` + fmt clean. (`mesh_demo` echo test is a known pre-existing Redis-dependent flake — Dawn confirmed it fails identically on the base commit, untouched by this PR.) - Dawn: independent re-verification at `dd26caa9f` — byte-identical diff application confirmed, suite reproduced, and fixture proven to still discriminate (fails at 320ms with only the production fix reverted). Gate clear. Max: independent rig pass at `dd26caa9f` — 94/94 unit, **15/15 routing/fallback matrix** (cursor behavior, default-dark seams, seven-seam confinement, one-snapshot, dead-reader/no-URL fallback, hard-delete fail-closed, reader max), 773/773 relay, private-DB lock control. Gate clear; recommends merge + staged bb-block rollout (no-reader-URL no-op → cursor-only → observe `buzz_db_route_decision` → conservative nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS`; rollback is config-only). ## Known gap — CI does not run the PG-backed fixtures (#3622) Dawn found post-sign-off (confirmed by Max and Eva at `dd26caa9f`): **every Postgres-backed fixture in this PR is `#[ignore]`d and no CI job selects it.** `Unit Tests` runs `cargo nextest run -p buzz-db --lib` (`Justfile:279-285`, ignored tests excluded); the only `--run-ignored ignored-only` invocations (`ci.yml:690`, `:702`) filter to `relay_invite::tests`. So the one-budget regression, the seven-seam isolation fixture, and the fence/floor-guard/fallback tests have zero automated execution — the verification record above is exhaustive but manual, at this exact SHA. Not introduced by this PR (the `#[ignore]` + narrow-filter pattern predates it; 34 ignored tests total) and not a merge blocker: all new seams are dark until `BUZZ_REPLICA_READ_MAX_AGE_MS` is set. But it changes the rollout gate — **do not set a nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS` in bb-block until CI enforces these fixtures.** Filed as #3622 (explicit CI selection against the existing backend-integration Postgres archive; ordering note: #3619 must land first or be excluded, since widening the filter would select the colliding usage-metrics lock test). ## Live redteam results (Max, real Helm + PG17 streaming replication at `dd26caa9f`) — #3643, #3644 Max deployed this exact SHA via the repo chart against a physical writer/standby pair and attacked it. **The PR's fenced routing passed every fault**: healthy baseline routes proven on the real standby; paused WAL replay moved head/cursor reads to `writer/stale`; B=0 moved routed reads to `writer/disabled`; reader-URL removal rolled back cleanly. The redteam also found a **pre-existing** production risk this PR does not create and does not fix: NIP-50 search builds its own eager, unfenced pool straight from `READ_DATABASE_URL` (`main.rs:389-402`, introduced by #2084, present in the deployed bb-block `sha-dd222a5` and bb-public `sha-22be8bb` images, and both production ESOs already template `READ_DATABASE_URL`). Measured: ~30s search stall on reader loss, acknowledged-but-unsearchable writes under replica lag (unaffected by `BUZZ_REPLICA_READ_MAX_AGE_MS`), fatal eager connect blocking pod startup when the reader is down, and `replica=true` FTS even at B=0. Filed as **#3643** (fix: pin FTS to the writer, or fence it like the routed seams) with **#3644** for the writer-pointing-reader-URL telemetry gap. Aurora's cluster-ro writer-fallback DNS softens the outage modes in prod but not the lag mode. **Revised ladder consequence:** step 2 ("add `READ_DATABASE_URL`, budget unset") is NOT a no-op with today's binary — it hands FTS an unfenced replica pool. Rollout order is now: merge (still a true no-op — bb-block/bb-public already run the unfenced-FTS code and this PR only adds dark seams) → fix #3643 → CI enforcement #3622 → then reader URL + budget per the original ladder, re-running Max's three deployment tests (reader-down latency, reader-down cold boot, paused-replay read-your-own-write search). ## Activation gate (consolidated) — merge is clear; ALL of the below precede any nonzero `BUZZ_REPLICA_READ_MAX_AGE_MS` Six independent verification passes at `dd26caa9f` (Dawn ×2 incl. byte-level + defang check, Max matrix + live Helm redteam, Wren full CRUD regression + greenfield 0026 + 151/151 PG, Eva). Every induced fault was either handled correctly by this PR's machinery or traced to pre-existing main code. Remaining work gates **activation, not merge**: 1. **#3643** — unfenced NIP-50 FTS pool (pre-existing #2084; live in prod today; also explains the blackhole search hang Wren observed — the routed path was measured bounded at 151-152ms against a silent endpoint, sqlx `inner.rs:252-255`). 2. **#3651** — reader `statement_timeout`: statements after acquire are unbounded; mid-transaction blackhole reproduced hanging ≥15s (Dawn). Small fix via `.after_connect` on the reader pool. 3. **#3622** — CI enforcement of the PG fixtures (with #3619 ordering). 4. **Recovery-conflict cancellation live test** — standby cancels a routed read mid-snapshot → complete writer page, never partial (#3651's fix also bounds the cancellation-never-arrives shape). 5. **DDL replication lag test** — migration on writer + paused replay + budget on → fallback, not client-visible SQL errors. 6. **Reader-tx soak** — 30-60min mixed load; no idle-in-tx accumulation on the standby. Then Max's three deployment tests (reader-down latency, reader-down cold boot, paused-replay read-your-own-write search) re-run on the fixed binary before the first nonzero budget on bb-block. --------- Signed-off-by: Tyler Longwell Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell --- Cargo.lock | 2 + crates/buzz-db/Cargo.toml | 2 + crates/buzz-db/src/event.rs | 40 +- crates/buzz-db/src/feed.rs | 62 +- crates/buzz-db/src/lib.rs | 2362 ++++++++++++++++++++++- crates/buzz-db/src/migration.rs | 19 +- crates/buzz-db/src/replica_fence.rs | 827 ++++++-- crates/buzz-db/src/thread.rs | 55 +- crates/buzz-relay/src/api/bridge.rs | 48 +- crates/buzz-relay/src/config.rs | 118 ++ crates/buzz-relay/src/handlers/count.rs | 16 +- crates/buzz-relay/src/handlers/req.rs | 4 +- crates/buzz-relay/src/main.rs | 14 +- migrations/0026_replica_heartbeat.sql | 38 + schema/schema.sql | 20 + 15 files changed, 3375 insertions(+), 252 deletions(-) create mode 100644 migrations/0026_replica_heartbeat.sql diff --git a/Cargo.lock b/Cargo.lock index c3ea86d6b3..49104b22d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -949,6 +949,8 @@ dependencies = [ "buzz-core", "chrono", "hex", + "metrics", + "metrics-util", "nostr", "rand 0.10.1", "serde", diff --git a/crates/buzz-db/Cargo.toml b/crates/buzz-db/Cargo.toml index 01f1e172b6..6f76a11bc1 100644 --- a/crates/buzz-db/Cargo.toml +++ b/crates/buzz-db/Cargo.toml @@ -21,6 +21,8 @@ tracing = { workspace = true } thiserror = { workspace = true } nostr = { workspace = true } rand = { workspace = true } +metrics = { workspace = true } [dev-dependencies] tokio = { workspace = true } +metrics-util = { workspace = true } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 520fd1536f..0e54196d11 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -316,6 +316,17 @@ pub async fn insert_event( /// Uses `QueryBuilder` for dynamic filter composition — avoids string concatenation /// while keeping all user values in bind parameters. pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result> { + let mut conn = pool.acquire().await?; + query_events_on(&mut conn, q).await +} + +/// [`query_events`] on a specific session — the replica-routing path runs +/// follow-up (aux) queries on the exact reader connection whose heartbeat +/// observation proved coverage for the page they annotate. +pub(crate) async fn query_events_on( + conn: &mut sqlx::PgConnection, + q: &EventQuery, +) -> Result> { // Composite cursor requires both halves. if q.before_id.is_some() && q.until.is_none() { return Err(DbError::InvalidData( @@ -538,7 +549,7 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result Result Result { + let mut conn = pool.acquire().await?; + count_events_on(&mut conn, q).await +} + +/// [`count_events`] on a specific session — the replica-routing path runs +/// the count on the exact reader connection whose heartbeat observation +/// proved its predicate. +pub(crate) async fn count_events_on(conn: &mut sqlx::PgConnection, q: &EventQuery) -> Result { // Empty list means "match nothing" — return 0 immediately. if q.kinds.as_deref().is_some_and(|k| k.is_empty()) { return Ok(0); @@ -730,7 +749,7 @@ pub async fn count_events(pool: &PgPool, q: &EventQuery) -> Result { } } - let row = qb.build().fetch_one(pool).await?; + let row = qb.build().fetch_one(&mut *conn).await?; let cnt: i64 = row.try_get("cnt")?; Ok(cnt) @@ -990,6 +1009,21 @@ pub async fn get_events_by_ids( pool: &PgPool, community_id: CommunityId, ids: &[&[u8]], +) -> Result> { + if ids.is_empty() { + return Ok(vec![]); + } + let mut conn = pool.acquire().await?; + get_events_by_ids_on(&mut conn, community_id, ids).await +} + +/// [`get_events_by_ids`] on a specific session — the replica-routing path +/// runs the query on the exact reader connection whose heartbeat +/// observation proved its predicate. +pub(crate) async fn get_events_by_ids_on( + conn: &mut sqlx::PgConnection, + community_id: CommunityId, + ids: &[&[u8]], ) -> Result> { if ids.is_empty() { return Ok(vec![]); @@ -1008,7 +1042,7 @@ pub async fn get_events_by_ids( } qb.push(")"); - let rows = qb.build().fetch_all(pool).await?; + let rows = qb.build().fetch_all(&mut *conn).await?; let mut out = Vec::with_capacity(rows.len()); for row in rows { diff --git a/crates/buzz-db/src/feed.rs b/crates/buzz-db/src/feed.rs index 511a2a6083..40e58d0d06 100644 --- a/crates/buzz-db/src/feed.rs +++ b/crates/buzz-db/src/feed.rs @@ -132,6 +132,29 @@ pub async fn query_mentions( accessible_channel_ids: &[Uuid], since: Option>, limit: i64, +) -> Result> { + let mut conn = pool.acquire().await?; + query_mentions_on( + &mut conn, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await +} + +/// [`query_mentions`] on a specific session — the replica-routing path runs +/// the query on the exact reader connection whose heartbeat observation +/// proved its predicate. +pub(crate) async fn query_mentions_on( + conn: &mut sqlx::PgConnection, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, ) -> Result> { let mut qb = build_mentions_query( community, @@ -140,7 +163,7 @@ pub async fn query_mentions( since, limit, ); - let rows = qb.build().fetch_all(pool).await?; + let rows = qb.build().fetch_all(&mut *conn).await?; collect_stored_events(rows) } @@ -193,6 +216,27 @@ pub async fn query_needs_action( accessible_channel_ids: &[Uuid], since: Option>, limit: i64, +) -> Result> { + let mut conn = pool.acquire().await?; + query_needs_action_on( + &mut conn, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await +} + +/// [`query_needs_action`] on a specific session — see [`query_mentions_on`]. +pub(crate) async fn query_needs_action_on( + conn: &mut sqlx::PgConnection, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, ) -> Result> { let mut qb = build_needs_action_query( community, @@ -201,7 +245,7 @@ pub async fn query_needs_action( since, limit, ); - let rows = qb.build().fetch_all(pool).await?; + let rows = qb.build().fetch_all(&mut *conn).await?; collect_stored_events(rows) } @@ -241,9 +285,21 @@ pub async fn query_activity( accessible_channel_ids: &[Uuid], since: Option>, limit: i64, +) -> Result> { + let mut conn = pool.acquire().await?; + query_activity_on(&mut conn, community, accessible_channel_ids, since, limit).await +} + +/// [`query_activity`] on a specific session — see [`query_mentions_on`]. +pub(crate) async fn query_activity_on( + conn: &mut sqlx::PgConnection, + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, ) -> Result> { let mut qb = build_activity_query(community, accessible_channel_ids, since, limit); - let rows = qb.build().fetch_all(pool).await?; + let rows = qb.build().fetch_all(&mut *conn).await?; collect_stored_events(rows) } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 2a3ba9a63e..0c6ea36dac 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -181,12 +181,289 @@ pub struct Db { /// route here (see [`Db::read`]); locks, transactions, and anything /// consistency-critical stays on `pool`. pub(crate) read_pool: Option, + /// Maximum connections configured for the read-replica pool (from + /// [`DbConfig::read_max_connections`], defaulting to the writer's + /// sizing). Kept separately from `max_connections` so + /// [`Db::read_pool_stats`] reports the reader's own ceiling — a + /// utilisation gauge derived from the writer's max would understate + /// reader saturation by exactly the ratio of the two pool sizes. + pub(crate) read_max_connections: u32, /// Freshness fence gating cursor-page routing to the replica. /// /// Starts closed; a background probe ([`replica_fence::run_probe`]) - /// advances it after each verified writer→replica LSN handshake. When - /// closed or stale, every cursor page routes to the writer. + /// commits heartbeat tokens and retains proof entries. Routing proves + /// coverage per request on the serving reader session; when the ring is + /// empty or stale, every routed read stays on the writer. pub(crate) fence: std::sync::Arc, + /// Bounded-staleness routing budget `B`: a read routed under + /// [`RoutePredicate::Bounded`] may be served from a proved replica + /// session only when the proved heartbeat entry is at most this old. + /// `None` disables the bounded arm entirely (the rollout default) — + /// bounded-stale read semantics are a product decision, not an + /// invariant, so the gate ships off. + pub(crate) replica_read_max_age: Option, + /// Whether the reader endpoint supports the Aurora PostgreSQL identity + /// function ([`replica_fence::AURORA_IDENTITY_FN`]) — probed + /// once per process on the first routed read (on a plain autocommit + /// checkout, outside any request transaction) and cached. Unset means + /// not yet probed (or the probe hit a transient error and will retry). + /// Shared across `Db` clones. + pub(crate) reader_aurora_identity: std::sync::Arc>, +} + +/// The session that served (or will serve) a routed read, so follow-up +/// queries in the same request (the channel-window aux closure) run on the +/// **same proved snapshot** — a different pooled reader session may sit at a +/// different replay position, and even the same connection advances its +/// snapshot between autocommit statements. +/// +/// `Replica` holds the request's `REPEATABLE READ, READ ONLY` transaction: +/// the heartbeat observation was its first statement, so the snapshot the +/// proof was taken against is exactly the snapshot every follow-up sees. +/// Dropping the session rolls the read-only transaction back and returns +/// the connection to the pool. +/// +/// `Writer` carries the writer pool: follow-ups there are authoritative by +/// construction and need no session pinning. +pub struct ReadSession { + inner: ReadSessionInner, +} + +enum ReadSessionInner { + /// The proved replica request transaction (snapshot-anchored), plus the + /// writer pool so a mid-request replica failure (e.g. a hot-standby + /// recovery conflict cancelling the held snapshot) degrades the session + /// to the writer instead of surfacing an error: degraded capacity, + /// never holes — and never a 500 the writer could have served. + Replica { + tx: sqlx::Transaction<'static, sqlx::Postgres>, + writer: PgPool, + }, + /// The writer pool (cheap clone; Arc-backed). + Writer(PgPool), +} + +impl ReadSession { + /// Query events on this session (see [`Db::query_events`]). + /// + /// If the proved replica transaction fails mid-request, the session + /// permanently degrades to the writer and the query is re-run there. + /// The writer is always at or ahead of any replica replay position, so + /// the degraded follow-up can only observe *more* than the proof-time + /// snapshot, never less — fresher aux rows, the same failure semantics + /// as a request that routed to the writer to begin with. + pub async fn query_events(&mut self, q: &EventQuery) -> Result> { + let degraded = match &mut self.inner { + ReadSessionInner::Replica { tx, writer } => { + match event::query_events_on(tx, q).await { + Ok(rows) => return Ok(rows), + Err(e) => { + tracing::warn!( + error = %e, + "replica session query failed mid-request; degrading to writer" + ); + // Deliberately not a `buzz_db_route_decision` event: + // the page's route was already recorded, and the + // offload metric must stay one-event-per-request. + metrics::counter!("buzz_db_read_session_degraded").increment(1); + writer.clone() + } + } + } + ReadSessionInner::Writer(pool) => return event::query_events(pool, q).await, + }; + // Replacing the inner drops the replica transaction (rolling it + // back and returning the reader connection to its pool). + self.inner = ReadSessionInner::Writer(degraded.clone()); + event::query_events(°raded, q).await + } + + /// Whether this session is a proved replica connection (observability). + pub fn is_replica(&self) -> bool { + matches!(self.inner, ReadSessionInner::Replica { .. }) + } +} + +/// Where one routed read is served (see [`Db::route_read`]). +enum RouteDecision { + /// A reader request transaction whose first-statement heartbeat + /// observation proved this fence entry — the page runs inside it. The + /// `&'static str` is the metric reason (`covered`/`fresh`); the caller + /// records the route only once the page is actually served from the + /// replica, so a post-verification writer re-run or a mid-query replica + /// failure emits exactly one `buzz_db_route_decision` event per request + /// (the offload percentage is read straight off `decision="replica"`). + Replica( + sqlx::Transaction<'static, sqlx::Postgres>, + replica_fence::TokenEntry, + &'static str, + ), + /// Fail closed: serve from the writer pool (already recorded). + Writer, +} + +/// The ONLY place [`route_proof::ChannelScoped`] can be constructed. A +/// crate-root tuple struct would be mintable via `ChannelScoped(())` from +/// every descendant module — tuple-struct field privacy is module-scoped — +/// so the token lives in its own module and E0423 enforces the invariant. +mod route_proof { + use uuid::Uuid; + + /// Proof that a query/page can only return rows with + /// `channel_id IS NOT NULL` — the domain of the commit-time floor guard + /// (migration 0021). `channel_ids` (retains channel-NULL rows) and + /// `global_only = false` are explicitly NOT proofs. + /// + /// Each constructor keys off *how* its path proves channel-bearing-ness: + /// a pinned query filter, a bare `Uuid` argument, or a `NOT NULL` column + /// reached through an inner join. Do not add a universal constructor + /// callers reshape their inputs to fit, and never fabricate a throwaway + /// `EventQuery` purely to mint a token — the proof must be the SQL's + /// shape, not "someone assembled a struct". + #[derive(Clone, Copy)] + pub(crate) struct ChannelScoped(()); + + impl ChannelScoped { + /// Constructor 1: the query pins a single channel + /// (`EventQuery.channel_id = Some(_)`, compiled to a + /// `channel_id = $n` predicate). This proof covers BOTH query + /// builders — the SELECT builder (`event::query_events_on`) and the + /// COUNT builder (`event::count_events`) pin identically; if the + /// two ever drift, this comment is a lie and the routed COUNT seam + /// is unsound. + /// Sound under conjunction: any additional clause (e.g. + /// `channel_ids`, which alone retains channel-NULL rows) is ANDed, + /// and `channel_id = ` never matches NULL — the pin strictly + /// narrows and cannot be widened back out to global rows. + pub(crate) fn from_pinned_channel(q: &crate::event::EventQuery) -> Option { + q.channel_id.map(|_| ChannelScoped(())) + } + + /// Constructor 2 (thread pages): the page is an inner JOIN from + /// `thread_metadata` to `events`, and `thread_metadata.channel_id` + /// is `UUID NOT NULL` — every writer that creates a row passes a + /// concrete channel (`ThreadMetadataParams.channel_id: Uuid`, + /// non-Option). Channel-bearing by construction of the join, not by + /// query predicate. + pub(crate) fn from_thread_metadata_join() -> Self { + ChannelScoped(()) + } + + /// Constructor 3 (channel windows): the channel arrives as a bare + /// `Uuid` argument and the SQL binds it unconditionally + /// (`e.channel_id = $2` in `get_channel_window_on`); every served + /// row is channel-bearing. No `EventQuery` exists on this path. + pub(crate) fn from_channel_id(_channel_id: Uuid) -> Self { + ChannelScoped(()) + } + } +} +use route_proof::ChannelScoped; + +/// The predicate one routed read must satisfy (see [`Db::route_read`]). +/// +/// Discipline: no `Default`, no `Deserialize`, stays non-`pub` — any of +/// those re-opens the [`ChannelScoped`] mint. +enum RoutePredicate { + /// Bounded staleness: the proved entry must be within the configured + /// read budget `B` (default off). Bounds TIME — the page misses at most + /// the freshest `B` of writes. Sound for ANY query shape, including + /// global (channel-NULL) rows: it relies only on heartbeat commit order, + /// not the floor guard. + Bounded, + /// Completeness: the proved wall must cover the page's upper bound. + /// Bounds CONTENT — every row at/below `upper` is present, meaningful + /// even when the cursor is hours old, where `B`-freshness says nothing. + /// Sound ONLY on the floor guard's domain (channel-bearing rows), hence + /// the proof token. `upper` is non-optional: the no-upper-bound + /// post-verifying case is [`RoutePredicate::CoveredPostVerified`]. + /// + /// Bounds INSERT-completeness only — "no missing rows", not "no extra + /// rows". Soft deletes are `UPDATE .. SET deleted_at` commits outside + /// the floor guard and never touch `created_at`, so a covered page can + /// briefly serve a row the writer already excludes; deletion visibility + /// is bounded by replication lag under `FENCE_STALENESS` (30s), not by + /// `upper` or `B`. Do not extend the covered arm to a surface that + /// cannot absorb extra rows (this is why the routed COUNT seam is + /// bounded-only). + Covered { + upper: DateTime, + /// Never read — the field exists so constructing this variant + /// requires minting the token through `route_proof`. + #[allow(dead_code)] + proof: ChannelScoped, + }, + /// Forward-walking thread pages: no upper bound is derivable from the + /// cursor; the caller post-verifies the served rows against the proved + /// wall (full page + tail at/below the wall, else re-run on the writer). + /// Only the thread path constructs this — a general routed caller does + /// no post-verification and must never self-certify. + CoveredPostVerified { + #[allow(dead_code)] + proof: ChannelScoped, + }, + /// Either arm admits, covered tried first (it has no budget dependence). + /// For general routed reads that are channel-pinned AND carry an + /// `until` upper bound. + BoundedOrCovered { + upper: DateTime, + /// Never read — see [`RoutePredicate::Covered::proof`]. + #[allow(dead_code)] + proof: ChannelScoped, + }, +} + +impl RoutePredicate { + /// A channel-window request: cursor pages are covered-only — for deep + /// keyset pages only coverage answers "have all rows below the cursor + /// replayed?" — and a head fetch is bounded. The channel id is the + /// bare-`Uuid` proof that the window SQL pins a channel. + fn from_channel_cursor(channel_id: Uuid, cursor: &Option<(DateTime, Vec)>) -> Self { + match cursor { + Some((ts, _)) => RoutePredicate::Covered { + upper: *ts, + proof: ChannelScoped::from_channel_id(channel_id), + }, + None => RoutePredicate::Bounded, + } + } + + /// General entry point for the routed query seams: derives the strongest + /// sound predicate from the query shape. Never produces a covered arm + /// without both a channel-scope proof AND a real upper bound. + /// + /// `routing_enabled` is whether `BUZZ_REPLICA_READ_MAX_AGE_MS` is set + /// (non-zero). When it is NOT, this returns `Bounded` — which the zero + /// budget then fails closed — so the new seams are genuinely dark at + /// the deploy default even for channel-pinned queries carrying `until`. + /// Without this gate, `BoundedOrCovered` would take the covered arm + /// (which has no budget dependence) and route on day one with no env + /// var set and no kill switch short of removing the replica URL + /// (Dawn's covered-at-zero-budget catch). The pre-existing cursor + /// paths (`Covered`/`CoveredPostVerified` from channel windows and + /// thread pages) intentionally still route at B=0 — status quo, + /// unchanged. + fn for_query(q: &event::EventQuery, routing_enabled: bool) -> Self { + if !routing_enabled { + return RoutePredicate::Bounded; + } + match (ChannelScoped::from_pinned_channel(q), q.until) { + (Some(proof), Some(upper)) => RoutePredicate::BoundedOrCovered { upper, proof }, + _ => RoutePredicate::Bounded, + } + } +} + +/// Map the configured read budget (`BUZZ_REPLICA_READ_MAX_AGE_MS`) to the +/// runtime gate: `0` disables bounded-staleness routing; anything above the +/// fence staleness gate is clamped to it (an entry older than the staleness +/// gate never routes anyway, so a larger budget would only misrepresent the +/// config). +fn read_budget_from_ms(ms: u64) -> Option { + match ms { + 0 => None, + ms => Some(Duration::from_millis(ms).min(replica_fence::FENCE_STALENESS)), + } } /// Snapshot of Postgres connection pool utilisation. @@ -232,6 +509,9 @@ pub struct DbConfig { pub read_database_url: Option, /// Maximum number of connections in the pool. pub max_connections: u32, + /// Maximum connections in the read-replica pool (env + /// `BUZZ_DB_READ_POOL_SIZE`). `None` inherits [`Self::max_connections`]. + pub read_max_connections: Option, /// Minimum number of idle connections to maintain. pub min_connections: u32, /// Seconds to wait when acquiring a connection before timing out. @@ -240,6 +520,13 @@ pub struct DbConfig { pub max_lifetime_secs: u64, /// Seconds a connection may sit idle before being closed. pub idle_timeout_secs: u64, + /// Replica read budget `B` in milliseconds (bounded arm, env + /// `BUZZ_REPLICA_READ_MAX_AGE_MS`). `0` disables bounded-staleness + /// routing — the rollout default. Values above + /// [`replica_fence::FENCE_STALENESS`] are clamped to it: an entry older + /// than the staleness gate never routes anyway, so a larger budget + /// would only misrepresent the config. + pub replica_read_max_age_ms: u64, } impl Default for DbConfig { @@ -251,10 +538,12 @@ impl Default for DbConfig { database_url: "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string(), // sadscan:disable np.postgres.1 read_database_url: None, max_connections: 20, + read_max_connections: None, min_connections: 2, acquire_timeout_secs: 3, max_lifetime_secs: 1800, idle_timeout_secs: 600, + replica_read_max_age_ms: 0, } } } @@ -361,15 +650,22 @@ impl Db { /// proof hold for every insert path that goes through this pool. pub async fn new(config: &DbConfig) -> Result { let pool = Self::connect_pool(config, &config.database_url, true).await?; + let read_max_connections = config + .read_max_connections + .unwrap_or(config.max_connections); let read_pool = match &config.read_database_url { - Some(url) => Some(Self::connect_pool(config, url, false).await?), + Some(url) => Some(Self::connect_read_pool(config, url, read_max_connections)?), None => None, }; + let replica_read_max_age = read_budget_from_ms(config.replica_read_max_age_ms); Ok(Self { pool, max_connections: config.max_connections, read_pool, + read_max_connections, fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), }) } @@ -401,13 +697,90 @@ impl Db { Ok(options.connect(url).await?) } + /// Reader acquire timeout — deliberately far below the writer's + /// (seconds-denominated) timeout. Failing closed to the writer must be + /// fast: a saturated reader pool that made routed reads wait the full + /// writer-style timeout would add dead latency during exactly the load + /// spike the offload exists for. A miss here surfaces as + /// `writer/reader_acquire_timeout` (see [`Db::proved_reader`] for why + /// the reason names the mechanism rather than a diagnosis). + const READER_ACQUIRE_TIMEOUT: Duration = Duration::from_millis(150); + + /// Connect the read-replica pool **lazily** — no connection is + /// attempted at construction, so a reader that is down at boot cannot + /// crash the relay (it starts all-writer with the fence closed and + /// recovers when the replica returns). + /// + /// `min_connections` is pinned to 0 explicitly: sqlx's lazy pool still + /// spawns an eager background connect task to satisfy a nonzero + /// minimum, which would reintroduce boot-time reader dial attempts (and + /// their log noise) that "lazy" is meant to avoid. With 0, connections + /// are dialed only on first acquire; the ~10-minute reaper never tops + /// the pool back up, which is fine — routed reads re-fill it on demand. + /// + /// No floor guard: replica sessions are read-only, the trigger never + /// fires there (see [`Db::connect_pool`]). + fn connect_read_pool(config: &DbConfig, url: &str, max_connections: u32) -> Result { + Ok(PgPoolOptions::new() + .max_connections(max_connections) + .min_connections(0) + .acquire_timeout(Self::READER_ACQUIRE_TIMEOUT) + .max_lifetime(Duration::from_secs(config.max_lifetime_secs)) + .idle_timeout(Duration::from_secs(config.idle_timeout_secs)) + .connect_lazy(url)?) + } + + /// Spawn a one-shot reader reachability probe that only WARNs. + /// + /// With a lazy pool and `min_connections(0)`, nothing dials the replica + /// until the first routed read — so a misconfigured `READ_DATABASE_URL` + /// would otherwise be invisible until traffic arrives and quietly falls + /// back to the writer. This ping is the only boot-time reader-down + /// visibility; it must never gate startup or [`Db::spawn_fence_probe`]. + /// + /// On success it also primes the Aurora identity capability cache + /// ([`Db::reader_aurora_identity`]) on the connection it already holds, + /// so the first routed read doesn't spend a second acquire (up to + /// another [`Db::READER_ACQUIRE_TIMEOUT`]) inside + /// [`Db::reader_aurora_capability_on`]. Prime failure is fine: the routed + /// path re-probes on the connection it already holds, so a failed prime + /// costs a round trip rather than a second acquire budget. + pub fn spawn_read_pool_boot_ping(&self) { + let Some(read_pool) = self.read_pool.clone() else { + return; + }; + let aurora_identity = self.reader_aurora_identity.clone(); + tokio::spawn(async move { + match read_pool.acquire().await { + Ok(mut conn) => { + tracing::info!("read replica reachable at boot"); + match replica_fence::reader_supports_aurora_identity(&mut conn).await { + Ok(supported) => { + let _ = aurora_identity.set(supported); + } + Err(e) => tracing::debug!( + error = %e, + "aurora identity boot prime failed; first routed read will probe" + ), + } + } + Err(e) => tracing::warn!( + "read replica unreachable at boot; serving all-writer until it recovers: {e}" + ), + } + }); + } + /// Creates a `Db` from an existing `PgPool` (useful in tests). pub fn from_pool(pool: PgPool) -> Self { Self { max_connections: pool.options().get_max_connections(), + read_max_connections: pool.options().get_max_connections(), pool, read_pool: None, fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age: None, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), } } @@ -421,12 +794,21 @@ impl Db { pub fn from_pools(pool: PgPool, read_pool: PgPool) -> Self { Self { max_connections: pool.options().get_max_connections(), + read_max_connections: read_pool.options().get_max_connections(), pool, read_pool: Some(read_pool), fence: std::sync::Arc::new(replica_fence::ReplicaFence::new()), + replica_read_max_age: None, + reader_aurora_identity: std::sync::Arc::new(std::sync::OnceLock::new()), } } + /// Test hook: set the head-fetch routing budget (Predicate A), which + /// [`Db::from_pools`] leaves disabled. + pub fn set_replica_read_max_age_for_tests(&mut self, budget: Option) { + self.replica_read_max_age = budget; + } + /// The freshness fence gating replica routing (see [`replica_fence`]). pub fn fence(&self) -> &std::sync::Arc { &self.fence @@ -438,7 +820,7 @@ impl Db { /// Ordering matters (Perci, PR #2084 review): this must run **after** /// the migration decision. On a relay with `BUZZ_AUTO_MIGRATE` off, the /// writer pool arms the GUC regardless, but if migration 0021 has not - /// been applied there is no trigger enforcing it — and an LSN probe + /// been applied there is no trigger enforcing it — and a heartbeat probe /// would open the fence over an unenforced floor. So the probe is gated /// on an unconditional two-part verification against the live schema: /// catalog shape ([`replica_fence::verify_floor_guard_catalog`]) and @@ -449,14 +831,13 @@ impl Db { /// stays closed: every cursor page routes to the writer. The relay keeps /// serving — degraded capacity, never holes. pub async fn spawn_fence_probe(&self) -> Result { - let Some(read_pool) = &self.read_pool else { + if self.read_pool.is_none() { return Ok(false); - }; + } replica_fence::verify_floor_guard_catalog(&self.pool).await?; replica_fence::verify_floor_guard_behavior(&self.pool).await?; tokio::spawn(replica_fence::run_probe( self.pool.clone(), - read_pool.clone(), std::sync::Arc::clone(&self.fence), )); Ok(true) @@ -465,11 +846,13 @@ impl Db { /// The pool for lag-tolerant reads: the read replica when configured, /// otherwise the writer pool. /// - /// Routing contract — a query may use this pool only when a stale (bounded - /// replication lag) result is acceptable to its caller. Keyset-cursor - /// pagination over immutable history qualifies; head-of-channel fetches, - /// auth/membership checks, locks, and anything inside a transaction do not. - pub fn read(&self) -> &PgPool { + /// Removed as a public escape hatch (Dawn, review of 1b0aa0dfa): the + /// raw replica pool carries **no fence proof**, which is exactly the + /// bug class the routed-read machinery exists to eliminate. All replica + /// reads must go through [`Db::route_read`]-backed entry points; this + /// remains only for the fence's own plumbing tests. + #[cfg(test)] + fn read(&self) -> &PgPool { self.read_pool.as_ref().unwrap_or(&self.pool) } @@ -478,6 +861,153 @@ impl Db { self.read_pool.is_some() } + /// Open a reader request transaction and complete the connection-local + /// half of the fence proof: `BEGIN ISOLATION LEVEL REPEATABLE READ, READ + /// ONLY`, then observe the heartbeat token/epoch as the transaction's + /// **first statement** — anchoring the snapshot every follow-up + /// statement (page, participants, aux closure) sees to exactly the + /// snapshot the proof was taken against — and resolve it against the + /// retained ring. Returns the open transaction together with the + /// strongest [`replica_fence::TokenEntry`] its observation supports, or + /// the fail-closed reason for route metrics. + /// + /// `REPEATABLE READ` is the strongest isolation a hot standby supports + /// (`SERIALIZABLE` is writer-only); `READ ONLY` documents intent and + /// rejects accidental writes. Everything but `Ok` fails closed — begin + /// failure, missing heartbeat row (migration not yet replayed there), + /// observation error, epoch mismatch, or a token below every retained + /// entry all route the request to the writer. + async fn proved_reader( + &self, + read_pool: &PgPool, + ) -> std::result::Result< + ( + sqlx::Transaction<'static, sqlx::Postgres>, + replica_fence::TokenEntry, + ), + &'static str, + > { + // One checkout per routed read. The Aurora capability probe and the + // read-only transaction share a single `acquire()` so the request path + // spends exactly one READER_ACQUIRE_TIMEOUT budget. Probing through + // `read_pool` separately would spend a second budget whenever the + // capability is uncached — i.e. after a failed boot ping, which is + // precisely the reader-unavailable case the bound must hold for. + let conn = match read_pool.acquire().await { + Ok(conn) => conn, + Err(sqlx::Error::PoolTimedOut) => { + tracing::warn!("reader pool acquire timed out; routing to writer"); + return Err("reader_acquire_timeout"); + } + Err(e) => { + tracing::warn!(error = %e, "reader connection acquire failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + let mut conn = conn; + let aurora = self.reader_aurora_capability_on(&mut conn).await; + let mut tx = match sqlx::Transaction::begin( + conn, + Some(sqlx::SqlStr::from_static( + "BEGIN ISOLATION LEVEL REPEATABLE READ, READ ONLY", + )), + ) + .await + { + Ok(tx) => tx, + // The acquire miss gets its own reason code: the reader pool's + // short acquire timeout (READER_ACQUIRE_TIMEOUT) makes this the + // fast fail-closed path under load, and + // `buzz_db_route_decision{decision="writer",reason="reader_acquire_timeout"}` + // is the operator's alert signal for a struggling reader pool. + // + // The reason deliberately names the mechanism, not a diagnosis: + // `PoolTimedOut` proves only that no connection was handed out + // within the 150ms budget. That budget includes cold connect + // (TCP+TLS+auth), and sqlx's `size` counts in-flight dials, so + // this fires for slow connection establishment as well as for + // established-connection contention — and neither `size == 0` + // nor `size >= max` recovers the missing causal bit (in-flight + // dials hold a size slot, and a cold burst can push + // `active = size - idle` toward max with zero busy connections). + // Runbook: correlate with `buzz_db_read_pool_active` / `_max` + // and reader connection health/latency; high active suggests + // contention, but this metric alone does not distinguish + // contention from slow connects. Note the gauge is a coarse + // sample (BUZZ_POOL_METRICS_INTERVAL_SECS, default 10s) while + // the event it explains lasts ~150ms — a short burst may fall + // between samples entirely, so absence of elevated active is + // NOT evidence of a cold connect. + Err(sqlx::Error::PoolTimedOut) => { + tracing::warn!("reader pool acquire timed out; routing to writer"); + return Err("reader_acquire_timeout"); + } + Err(e) => { + tracing::warn!(error = %e, "reader transaction begin failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + let obs = match replica_fence::observe_heartbeat(&mut tx, aurora).await { + Ok(Some(observation)) => observation, + Ok(None) => return Err("reader_validation_error"), + Err(e) => { + tracing::warn!(error = %e, "heartbeat observation failed; routing to writer"); + return Err("reader_validation_error"); + } + }; + match self.fence.resolve(obs.token, obs.epoch) { + replica_fence::ResolveOutcome::Proved(entry) => { + tracing::debug!( + token = obs.token, + proved_token = entry.token, + backend = %obs.backend, + "reader snapshot proved fence coverage" + ); + Ok((tx, entry)) + } + replica_fence::ResolveOutcome::EpochMismatch => Err("reader_validation_error"), + replica_fence::ResolveOutcome::TokenBehind => Err("reader_token_behind"), + } + } + + /// Whether the reader endpoint supports the Aurora PostgreSQL identity + /// function ([`replica_fence::AURORA_IDENTITY_FN`]), probed + /// once per process and cached (see [`Db::reader_aurora_identity`]). + /// The probe runs on a plain autocommit checkout — never inside the + /// request transaction, where an undefined-function error would abort + /// it. Probe failure (acquire or transient) degrades to the plain + /// identity tuple for THIS request without caching, so a later request + /// retries; identity is evidence, never a routing gate. + /// Aurora capability on a connection the caller already holds, so the + /// routed path never spends a second acquire budget. + async fn reader_aurora_capability_on( + &self, + conn: &mut sqlx::pool::PoolConnection, + ) -> bool { + if let Some(cached) = self.reader_aurora_identity.get() { + return *cached; + } + match replica_fence::reader_supports_aurora_identity(conn).await { + Ok(supported) => *self.reader_aurora_identity.get_or_init(|| supported), + Err(e) => { + tracing::debug!(error = %e, "aurora identity probe failed; will retry"); + false + } + } + } + + /// Record one route decision (Rev 2 observability): which path, where it + /// went, and why. + fn record_route(path: &'static str, decision: &'static str, reason: &'static str) { + metrics::counter!( + "buzz_db_route_decision", + "path" => path, + "decision" => decision, + "reason" => reason, + ) + .increment(1); + } + /// Run pending database migrations. pub async fn migrate(&self) -> Result<()> { migration::run_migrations(&self.pool).await @@ -502,11 +1032,18 @@ impl Db { } /// Pool utilisation stats for the read-replica pool, when configured. + /// + /// `max` is the **reader's** ceiling ([`Db::read_max_connections`]), not + /// the writer's: `buzz_db_read_pool_active / buzz_db_read_pool_max` is + /// the operator's utilisation signal for tuning `BUZZ_DB_READ_POOL_SIZE`, + /// and deriving it from the writer's max would misreport saturation by + /// exactly the ratio of the two pool sizes — in the direction that hides + /// the problem. pub fn read_pool_stats(&self) -> Option { self.read_pool.as_ref().map(|p| DbPoolStats { size: p.size(), idle: p.num_idle() as u32, - max: self.max_connections, + max: self.read_max_connections, }) } @@ -1094,15 +1631,126 @@ impl Db { } /// Queries events matching the given filter parameters. + /// + /// Always reads from the WRITER pool. If the result influences a write + /// or a permission decision, this is the method to call. Display-path + /// callers that tolerate bounded staleness should use + /// [`Db::query_events_routed`] instead — converting a caller is an + /// explicit, per-callsite decision, never a change to this method. pub async fn query_events(&self, q: &EventQuery) -> Result> { event::query_events(&self.pool, q).await } + /// [`Db::query_events`] with replica routing — the opt-in fast path for + /// display reads. + /// + /// Rule of thumb: **if the result influences a write or a permission, + /// it reads from the writer** — do not convert such a caller to this + /// method. Every new caller must be added to the caller-classification + /// table in `PLANS/REPLICA_FULL_READ_ROUTING_DESIGN.md`. + /// + /// Routing derives the strongest sound predicate from the query shape + /// ([`RoutePredicate::for_query`]): a channel-pinned query with an + /// `until` upper bound may be served covered (provably complete below + /// the fence wall); anything else is bounded-staleness only. The whole + /// seam is gated on `BUZZ_REPLICA_READ_MAX_AGE_MS` (default off): when + /// unset, even covered-eligible queries stay on the writer, so merging + /// this seam is a true no-op until the budget is configured. Every + /// failure fails closed to the writer. + pub async fn query_events_routed( + &self, + path: &'static str, + q: &EventQuery, + ) -> Result> { + let predicate = RoutePredicate::for_query(q, self.replica_read_max_age.is_some()); + match self.route_read(path, predicate).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match event::query_events_on(&mut tx, q).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + // Mid-query replica failure: fail closed to the + // writer rather than surfacing a routed error. + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + event::query_events(&self.pool, q).await + } + } + } + RouteDecision::Writer => event::query_events(&self.pool, q).await, + } + } + + /// [`Db::query_events_routed`] restricted to the BOUNDED arm — for + /// reads whose result feeds a COUNT rather than a displayed page. + /// + /// The covered arm bounds insert-completeness only; stale deletions can + /// briefly inflate the result set (see [`RoutePredicate::Covered`]). A + /// display page absorbs that per-row; a number derived from the rows + /// does not. Same classification-table requirement as + /// [`Db::query_events_routed`]. + pub async fn query_events_routed_bounded( + &self, + path: &'static str, + q: &EventQuery, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match event::query_events_on(&mut tx, q).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + event::query_events(&self.pool, q).await + } + } + } + RouteDecision::Writer => event::query_events(&self.pool, q).await, + } + } + /// Count events matching the given query (NIP-45 COUNT support). + /// + /// Always reads from the WRITER pool — see [`Db::query_events`] for the + /// writer-vs-routed rule. pub async fn count_events(&self, q: &EventQuery) -> Result { event::count_events(&self.pool, q).await } + /// [`Db::count_events`] with replica routing — same contract, rules, + /// and classification-table requirement as [`Db::query_events_routed`]. + /// + /// Counts route on the BOUNDED arm only, never covered: the covered + /// arm bounds insert-completeness but not deletion visibility (soft + /// deletes are UPDATEs outside the floor guard), and a count has no + /// downstream per-row re-filter to absorb extra rows — a silently + /// inflated number for up to `FENCE_STALENESS` is a different product + /// statement than a page briefly showing a deleted row. `Bounded` ties + /// the error to the accepted budget `B`. + pub async fn count_events_routed(&self, path: &'static str, q: &EventQuery) -> Result { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match event::count_events_on(&mut tx, q).await { + Ok(count) => { + Self::record_route(path, "replica", reason); + Ok(count) + } + Err(e) => { + tracing::warn!(path, "replica count failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + event::count_events(&self.pool, q).await + } + } + } + RouteDecision::Writer => event::count_events(&self.pool, q).await, + } + } + /// Return whether a creator-signed huddle-start event links a parent /// channel to an ephemeral huddle channel. pub async fn huddle_started_link_exists( @@ -1222,6 +1870,37 @@ impl Db { event::get_events_by_ids(&self.pool, community_id, ids).await } + /// [`Db::get_events_by_ids`] with replica routing — same contract and + /// classification-table requirement as [`Db::query_events_routed`]. + /// + /// By-id fetches route on the BOUNDED arm only: an id list carries no + /// channel pin, so no fence floor can prove insert-completeness — the + /// covered arm is structurally unavailable. Used for FTS hit hydration, + /// where a missing row degrades to a skipped search hit downstream. + pub async fn get_events_by_ids_routed( + &self, + path: &'static str, + community_id: CommunityId, + ids: &[&[u8]], + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match event::get_events_by_ids_on(&mut tx, community_id, ids).await { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + event::get_events_by_ids(&self.pool, community_id, ids).await + } + } + } + RouteDecision::Writer => event::get_events_by_ids(&self.pool, community_id, ids).await, + } + } + /// Exclusively claim a batch of due matcher jobs from one community. pub async fn claim_due_push_match_batch( &self, @@ -1990,19 +2669,25 @@ impl Db { /// Fetch replies under a root event. /// - /// Routing: the head fetch (`cursor: None`) always reads the writer. - /// Cursor-bearing pages may read the replica pool when one is configured - /// AND the freshness fence is open ([`replica_fence`]). Thread pagination - /// walks forward from oldest to newest, so a replica page is served only - /// when it is provably complete: + /// Routing mirrors [`Db::get_channel_window_with_session`]: a head + /// fetch (`cursor: None`) is Predicate A (bounded staleness, gated by + /// the default-off head budget); cursor pages are Predicate B + /// (completeness). Thread pagination walks **forward** from oldest to + /// newest, so a cursor carries no upper bound — instead the served page + /// is post-verified against the wall the serving session proved: /// /// - an under-`limit` page is a candidate terminal page — the client /// treats it as EOF, so it is re-run on the writer to keep the EOF /// decision authoritative (a lagged replica could truncate the tail); - /// - a full page whose newest row exceeds the fence could straddle a row - /// the replica has not replayed (commit order is not `created_at` - /// order), so it is also re-run on the writer. Only a full page that - /// sits entirely at or below the fence is served from the replica. + /// - a full page whose newest row exceeds the proved fence wall could + /// straddle a row the session has not replayed (commit order is not + /// `created_at` order), so it is also re-run on the writer. Only a + /// full page that sits entirely at or below the proved wall is served + /// from the replica. + /// + /// A head fetch routed under Predicate A skips the re-run: bounded + /// staleness (missing at most the freshest budget-window of replies) is + /// exactly the semantic the head gate accepts. pub async fn get_thread_replies( &self, community_id: CommunityId, @@ -2011,25 +2696,59 @@ impl Db { limit: u32, cursor: Option<&[u8]>, ) -> Result> { - if cursor.is_some() && self.has_read_pool() && self.fence.verified_through().is_some() { - let replies = thread::get_thread_replies( - self.read(), + let (path, predicate): (&'static str, RoutePredicate) = match cursor { + Some(_) => ( + "thread_cursor", + RoutePredicate::CoveredPostVerified { + proof: ChannelScoped::from_thread_metadata_join(), + }, + ), + None => ("thread_head", RoutePredicate::Bounded), + }; + if let RouteDecision::Replica(mut tx, entry, reason) = + self.route_read(path, predicate).await + { + match thread::get_thread_replies_on( + &mut tx, community_id, root_event_id, depth_limit, limit, cursor, ) - .await?; - let full = replies.len() >= limit as usize; - let below_fence = replies - .last() - .is_some_and(|tail| self.fence.covers(tail.created_at)); - if full && below_fence { - return Ok(replies); + .await + { + Ok(replies) => { + if cursor.is_none() { + // Predicate A: bounded-stale head page, served as proved. + Self::record_route(path, "replica", reason); + return Ok(replies); + } + let full = replies.len() >= limit as usize; + let below_fence = replies + .last() + .is_some_and(|tail| tail.created_at <= entry.fence_wall); + if full && below_fence { + Self::record_route(path, "replica", reason); + return Ok(replies); + } + // Candidate terminal page, or page reaching above the + // proved wall — verify against the writer. Recorded as + // the request's ONLY route event: the replica leg was + // discarded, so counting it would overstate offload. + Self::record_route("thread_eof", "writer", "stale"); + } + Err(e) => { + // Mid-request replica failure (e.g. a hot-standby + // recovery conflict) fails closed to the writer. + tracing::warn!( + error = %e, + path, + "replica thread query failed; re-running on writer" + ); + Self::record_route(path, "writer", "replica_error"); + } } - // Candidate terminal page, or page reaching above the fence — - // verify against the writer. } thread::get_thread_replies( &self.pool, @@ -2053,15 +2772,8 @@ impl Db { /// One channel window: top-level rows + summaries + server `has_more`. /// - /// Routing: the head fetch (`cursor: None`) always reads the writer — it - /// must include just-committed events. A cursor-bearing page scrolls - /// *backward* into history bounded above by the cursor timestamp - /// (`created_at < ts`, or `= ts` with the id tiebreak), so it may read - /// the replica when one is configured AND the freshness fence covers the - /// cursor timestamp: every row the page could contain is then provably - /// replayed on the replica ([`replica_fence`]). Pages whose cursor - /// reaches above the fence — the freshest sliver of history — stay on - /// the writer. + /// Convenience wrapper over [`Db::get_channel_window_with_session`] for + /// callers with no follow-up queries; the serving session is released. pub async fn get_channel_window( &self, community_id: CommunityId, @@ -2070,11 +2782,199 @@ impl Db { cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, ) -> Result { - let pool = match &cursor { - Some((ts, _)) if self.has_read_pool() && self.fence.covers(*ts) => self.read(), - _ => &self.pool, + self.get_channel_window_with_session(community_id, channel_id, limit, cursor, kind_filter) + .await + .map(|(window, _session)| window) + } + + /// [`Db::get_channel_window`], additionally returning the session that + /// served the page so request-scoped follow-ups (the aux closure) run on + /// the same proved connection. + /// + /// Routing: + /// + /// - **Cursor page** (Predicate B — completeness): scrolls *backward* + /// into history bounded above by the cursor timestamp (`created_at < + /// ts`, or `= ts` with the id tiebreak), so it may be served by a + /// replica session when one is configured AND that session **proves** + /// coverage of the cursor timestamp: the heartbeat token/epoch is + /// observed on the exact connection that will serve the page and + /// resolved against the fence's retained ring ([`replica_fence`]). + /// - **Head fetch** (Predicate A — bounded staleness): served by a + /// proved replica session only when the head gate is configured + /// ([`DbConfig::replica_read_max_age_ms`], default off) and the + /// proved entry is within the budget. This trades a bounded staleness + /// window (budget plus probe cadence) on the GET leg for writer + /// offload. NOTE: enabling the budget also breaks read-your-own-writes + /// on the GET leg; the client-side WS `since`-overlap union intended + /// to cover fresh events has NOT shipped yet — do not enable + /// `BUZZ_REPLICA_HEAD_MAX_AGE_SECS` until it has, proven by a + /// post-then-immediately-refetch test. + /// + /// Every failure fails closed to the writer and is recorded in + /// `buzz_db_route_decision`. + pub async fn get_channel_window_with_session( + &self, + community_id: CommunityId, + channel_id: Uuid, + limit: u32, + cursor: Option<(DateTime, Vec)>, + kind_filter: Option<&[u32]>, + ) -> Result<(thread::ChannelWindow, ReadSession)> { + let path: &'static str = if cursor.is_some() { + "channel_cursor" + } else { + "channel_head" + }; + match self + .route_read( + path, + RoutePredicate::from_channel_cursor(channel_id, &cursor), + ) + .await + { + RouteDecision::Replica(mut tx, _entry, reason) => { + match thread::get_channel_window_on( + &mut tx, + community_id, + channel_id, + limit, + cursor.clone(), + kind_filter, + ) + .await + { + Ok(window) => { + Self::record_route(path, "replica", reason); + return Ok(( + window, + ReadSession { + inner: ReadSessionInner::Replica { + tx, + writer: self.pool.clone(), + }, + }, + )); + } + Err(e) => { + // A mid-request replica failure (e.g. a hot-standby + // recovery conflict cancelling the held snapshot) + // fails closed to the writer: a stale-but-served + // page, never an error the writer could have + // answered. Dropping `tx` rolls the reader + // transaction back. + tracing::warn!( + error = %e, + path, + "replica window query failed; re-running on writer" + ); + Self::record_route(path, "writer", "replica_error"); + } + } + } + RouteDecision::Writer => {} + } + let window = thread::get_channel_window( + &self.pool, + community_id, + channel_id, + limit, + cursor, + kind_filter, + ) + .await?; + Ok(( + window, + ReadSession { + inner: ReadSessionInner::Writer(self.pool.clone()), + }, + )) + } + + /// Shared route decision for one read: evaluate the predicate against a + /// proved reader session and record the decision. Fail closed to the + /// writer everywhere. + async fn route_read(&self, path: &'static str, predicate: RoutePredicate) -> RouteDecision { + let Some(read_pool) = &self.read_pool else { + Self::record_route(path, "writer", "disabled"); + return RouteDecision::Writer; + }; + // Cheap prechecks on the shared ring before spending a reader + // checkout; the connection-local observation still has to prove it. + let Some(newest) = self.fence.newest() else { + Self::record_route(path, "writer", "uninitialized"); + return RouteDecision::Writer; + }; + // Precheck helpers against the newest shared entry: if the newest + // cannot satisfy an arm, no proved (older-or-equal) entry can. + let bounded_precheck = + |budget: &Option| -> std::result::Result<(), &'static str> { + match budget { + Some(budget) if newest.committed_at.elapsed() <= *budget => Ok(()), + Some(_) => Err("stale"), + None => Err("disabled"), + } + }; + let covered_precheck = |upper: &DateTime| -> std::result::Result<(), &'static str> { + if *upper <= newest.fence_wall { + Ok(()) + } else { + Err("stale") + } + }; + let precheck = match &predicate { + RoutePredicate::Bounded => bounded_precheck(&self.replica_read_max_age), + RoutePredicate::Covered { upper, .. } => covered_precheck(upper), + // No upper bound: the caller post-verifies served rows. + RoutePredicate::CoveredPostVerified { .. } => Ok(()), + // Covered first (no budget dependence), else bounded. + RoutePredicate::BoundedOrCovered { upper, .. } => { + covered_precheck(upper).or_else(|_| bounded_precheck(&self.replica_read_max_age)) + } }; - thread::get_channel_window(pool, community_id, channel_id, limit, cursor, kind_filter).await + if let Err(reason) = precheck { + Self::record_route(path, "writer", reason); + return RouteDecision::Writer; + } + match self.proved_reader(read_pool).await { + Ok((tx, entry)) => { + // Re-evaluate against the entry the session actually proved + // (it may be older than the shared newest). + let bounded_holds = || { + self.replica_read_max_age + .is_some_and(|budget| entry.committed_at.elapsed() <= budget) + }; + let verdict: Option<&'static str> = match &predicate { + RoutePredicate::Bounded => bounded_holds().then_some("fresh"), + RoutePredicate::Covered { upper, .. } => { + (*upper <= entry.fence_wall).then_some("covered") + } + // No upper bound: the caller post-verifies the served + // rows against the proved wall. + RoutePredicate::CoveredPostVerified { .. } => Some("covered"), + RoutePredicate::BoundedOrCovered { upper, .. } => { + if *upper <= entry.fence_wall { + Some("covered") + } else { + bounded_holds().then_some("fresh") + } + } + }; + match verdict { + Some(reason) => RouteDecision::Replica(tx, entry, reason), + None => { + // The session proves an older entry than the + // predicate needs (replication lag) — fail closed. + Self::record_route(path, "writer", "stale"); + RouteDecision::Writer + } + } + } + Err(reason) => { + Self::record_route(path, "writer", reason); + RouteDecision::Writer + } + } } /// Look up a single thread_metadata row by event_id. @@ -2239,6 +3139,67 @@ impl Db { .await } + /// [`Db::query_feed_mentions`] with replica routing — same contract and + /// classification-table requirement as [`Db::query_events_routed`]. + /// + /// Feed queries route on the BOUNDED arm only: the `accessible_channel_ids` + /// parameter admits community-global rows alongside channel rows, so no + /// single channel's fence floor can prove completeness — the covered arm + /// is structurally unavailable, not merely unchosen. + pub async fn query_feed_mentions_routed( + &self, + path: &'static str, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match feed::query_mentions_on( + &mut tx, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + feed::query_mentions( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + RouteDecision::Writer => { + feed::query_mentions( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + /// Find events that require action from the given pubkey. pub async fn query_feed_needs_action( &self, @@ -2259,6 +3220,63 @@ impl Db { .await } + /// [`Db::query_feed_needs_action`] with replica routing — BOUNDED arm + /// only; see [`Db::query_feed_mentions_routed`] for why the covered arm + /// is structurally unavailable to feed queries. + pub async fn query_feed_needs_action_routed( + &self, + path: &'static str, + community: CommunityId, + pubkey_bytes: &[u8], + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match feed::query_needs_action_on( + &mut tx, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + feed::query_needs_action( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + RouteDecision::Writer => { + feed::query_needs_action( + &self.pool, + community, + pubkey_bytes, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + /// Find recent activity across accessible channels. pub async fn query_feed_activity( &self, @@ -2270,6 +3288,53 @@ impl Db { feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit).await } + /// [`Db::query_feed_activity`] with replica routing — BOUNDED arm only; + /// see [`Db::query_feed_mentions_routed`] for why the covered arm is + /// structurally unavailable to feed queries. + pub async fn query_feed_activity_routed( + &self, + path: &'static str, + community: CommunityId, + accessible_channel_ids: &[Uuid], + since: Option>, + limit: i64, + ) -> Result> { + match self.route_read(path, RoutePredicate::Bounded).await { + RouteDecision::Replica(mut tx, _entry, reason) => { + match feed::query_activity_on( + &mut tx, + community, + accessible_channel_ids, + since, + limit, + ) + .await + { + Ok(events) => { + Self::record_route(path, "replica", reason); + Ok(events) + } + Err(e) => { + tracing::warn!(path, "replica read failed; re-running on writer: {e}"); + Self::record_route(path, "writer", "replica_error"); + feed::query_activity( + &self.pool, + community, + accessible_channel_ids, + since, + limit, + ) + .await + } + } + } + RouteDecision::Writer => { + feed::query_activity(&self.pool, community, accessible_channel_ids, since, limit) + .await + } + } + } + /// Create a new API token record. #[allow(clippy::too_many_arguments)] pub async fn create_api_token( @@ -5436,26 +6501,183 @@ mod tests { assert!(db.read_pool_stats().is_none()); } - /// Channel window: head fetch (no cursor) reads the WRITER; cursor pages - /// read the REPLICA. Divergent fixtures prove which pool served each. - #[tokio::test] - #[ignore = "requires Postgres"] - async fn channel_window_routes_head_to_writer_and_cursor_pages_to_replica() { - let admin = PgPool::connect(&admin_url().await) - .await - .expect("connect admin"); - let (writer, wname) = create_scratch_db(&admin, "routing_w").await; - let (replica, rname) = create_scratch_db(&admin, "routing_r").await; + #[test] + fn read_budget_zero_disables_and_large_values_clamp_to_staleness() { + assert_eq!(read_budget_from_ms(0), None, "0 = bounded routing off"); + assert_eq!( + read_budget_from_ms(1000), + Some(std::time::Duration::from_millis(1000)) + ); + assert_eq!( + read_budget_from_ms(10_000_000), + Some(replica_fence::FENCE_STALENESS), + "budgets above the staleness gate clamp to it" + ); + } - let author = nostr::Keys::generate(); - let community = Uuid::new_v4(); + /// Truth table for [`RoutePredicate::for_query`]: the strongest sound + /// predicate per query shape, and — the deploy-day default row — that + /// `routing_enabled = false` (BUZZ_REPLICA_READ_MAX_AGE_MS unset) + /// forces `Bounded` even for covered-eligible shapes, so the zero + /// budget fails the new seams closed (Dawn's covered-at-zero-budget + /// catch, design doc rev 5). + #[test] + fn for_query_predicate_truth_table() { + let community = CommunityId::from_uuid(Uuid::new_v4()); let channel = Uuid::new_v4(); - seed_community_channel(&writer, community, channel, &author).await; - seed_community_channel(&replica, community, channel, &author).await; + let until = chrono::Utc::now(); - // Shared history (both databases): m1 < m2 < m3. - let base = 1_700_000_000u64; - let m1 = signed_event_at(&author, "m1", base); + let pinned_with_until = { + let mut q = event::EventQuery::for_community(community); + q.channel_id = Some(channel); + q.until = Some(until); + q + }; + let pinned_no_until = { + let mut q = event::EventQuery::for_community(community); + q.channel_id = Some(channel); + q + }; + let unpinned_with_until = { + let mut q = event::EventQuery::for_community(community); + q.until = Some(until); + q + }; + let global_only = { + let mut q = event::EventQuery::for_community(community); + q.global_only = true; + q.until = Some(until); + q + }; + + // Deploy-day default: budget unset ⇒ Bounded regardless of shape. + // The zero budget then fails Bounded closed, so the new seams + // record writer/disabled — merging with no env var set is a no-op. + assert!( + matches!( + RoutePredicate::for_query(&pinned_with_until, false), + RoutePredicate::Bounded + ), + "budget unset must not reach the covered arm even when eligible" + ); + + // Budget set + channel pin + until ⇒ the strongest predicate. + assert!(matches!( + RoutePredicate::for_query(&pinned_with_until, true), + RoutePredicate::BoundedOrCovered { .. } + )); + + // Missing either covered precondition ⇒ Bounded. + assert!(matches!( + RoutePredicate::for_query(&pinned_no_until, true), + RoutePredicate::Bounded + )); + assert!(matches!( + RoutePredicate::for_query(&unpinned_with_until, true), + RoutePredicate::Bounded + )); + // global_only implies `channel_id = None`, so the channel-pin + // precondition fails and no covered arm is possible — `for_query` + // never inspects `global_only` itself; the row holds because + // constructor 1 (channel pin) returns None for an unpinned query. + assert!(matches!( + RoutePredicate::for_query(&global_only, true), + RoutePredicate::Bounded + )); + } + + /// The pre-existing cursor paths are NOT budget-gated: a channel-window + /// cursor page still derives `Covered` with no `routing_enabled` input + /// at all — at B=0 today it routes covered, and that status quo is + /// intentionally unchanged by the `for_query` gate (Max's matrix row: + /// old paths route at budget-unset; only the new seams go dark). + #[test] + fn channel_cursor_predicate_is_not_budget_gated() { + let channel = Uuid::new_v4(); + let cursor = Some((chrono::Utc::now(), vec![1u8; 32])); + assert!(matches!( + RoutePredicate::from_channel_cursor(channel, &cursor), + RoutePredicate::Covered { .. } + )); + // Head fetch (no cursor) is bounded — gated by the budget. + assert!(matches!( + RoutePredicate::from_channel_cursor(channel, &None), + RoutePredicate::Bounded + )); + } + + /// D5 wiring: `read_pool_stats().max` must be the READER pool's own + /// ceiling, not the writer's — `buzz_db_read_pool_active / _max` is the + /// operator's utilisation signal and inheriting the writer's max hides + /// reader saturation by exactly the sizing ratio. Pure wiring test: + /// `connect_lazy` never touches the network, but it does spawn the + /// pool reaper task, which needs a Tokio runtime — hence + /// `#[tokio::test]` despite the test body itself never awaiting. + #[tokio::test] + async fn read_pool_stats_reports_reader_ceiling_not_writer() { + let writer = sqlx::postgres::PgPoolOptions::new() + .max_connections(20) + .connect_lazy(TEST_DB_URL) + .expect("lazy writer pool"); + let reader = sqlx::postgres::PgPoolOptions::new() + .max_connections(40) + .connect_lazy(TEST_DB_URL) + .expect("lazy reader pool"); + let db = Db::from_pools(writer, reader); + assert_eq!(db.pool_stats().max, 20); + assert_eq!( + db.read_pool_stats().expect("read pool configured").max, + 40, + "reader gauge must report the reader's own ceiling" + ); + } + + /// D4 wiring: the reader pool is built lazily with `min_connections(0)` + /// and the short reader acquire timeout — construction must succeed + /// with no replica listening (reader-down at boot must not crash the + /// relay), and `read_max_connections` must honour + /// `DbConfig::read_max_connections` over the writer sizing. + /// `#[tokio::test]` because `connect_lazy` spawns the pool reaper task, + /// which needs a Tokio runtime even though nothing is dialed. + #[tokio::test] + async fn connect_read_pool_is_lazy_and_independently_sized() { + let config = DbConfig { + max_connections: 20, + read_max_connections: Some(7), + ..DbConfig::default() + }; + // Unroutable per RFC 5737 TEST-NET-1: proves nothing is dialed at + // construction time. + let pool = Db::connect_read_pool(&config, "postgres://user:pw@192.0.2.1:5432/none", 7) + .expect("lazy construction must not dial the replica"); + assert_eq!(pool.options().get_max_connections(), 7); + assert_eq!(pool.options().get_min_connections(), 0); + assert_eq!( + pool.options().get_acquire_timeout(), + Db::READER_ACQUIRE_TIMEOUT + ); + } + + /// Channel window: head fetch (no cursor) reads the WRITER; cursor pages + /// read the REPLICA. Divergent fixtures prove which pool served each. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn channel_window_routes_head_to_writer_and_cursor_pages_to_replica() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "routing_w").await; + let (replica, rname) = create_scratch_db(&admin, "routing_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + // Shared history (both databases): m1 < m2 < m3. + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); let m2 = signed_event_at(&author, "m2", base + 10); let m3 = signed_event_at(&author, "m3", base + 20); for pool in [&writer, &replica] { @@ -5519,6 +6741,984 @@ mod tests { drop_scratch_db(&admin, writer, &wname).await; } + /// Fail-closed on a mid-request replica failure (Dawn, review of + /// 1b0aa0dfa): a replica-routed page whose query errors *after* the + /// proof (the live shape is a hot-standby recovery conflict — 40001 / + /// 25P02 — cancelling the held snapshot under `max_standby_streaming_delay`) + /// must be re-run on the writer and served, never surfaced as an error + /// the writer could have answered. Degraded capacity, never holes. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn replica_window_failure_falls_back_to_writer() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fb_w").await; + let (replica, rname) = create_scratch_db(&admin, "fb_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + let m3 = signed_event_at(&author, "m3", base + 20); + for pool in [&writer, &replica] { + for ev in [&m1, &m2, &m3] { + insert_top_level(pool, community, channel, ev).await; + } + } + let marker = signed_event_at(&author, "replica-only-marker", base + 5); + insert_top_level(&replica, community, channel, &marker).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + + // Guard against a vacuous pass: the cursor page must actually be + // replica-eligible before we break the replica. + let healthy = db + .get_channel_window(cid, channel, 10, Some(cursor.clone()), None) + .await + .expect("healthy cursor window"); + assert!( + healthy + .rows + .iter() + .any(|r| r.stored_event.event.content == "replica-only-marker"), + "fixture must route the cursor page to the replica while healthy" + ); + + // Break the replica AFTER the proof point: the heartbeat table stays + // intact (the observation succeeds), the page query then fails. + sqlx::query("DROP TABLE events CASCADE") + .execute(&replica) + .await + .expect("drop replica events"); + + let page = db + .get_channel_window(cid, channel, 10, Some(cursor), None) + .await + .expect("replica failure must fall back to the writer, not error"); + let contents: Vec<&str> = page + .rows + .iter() + .map(|r| r.stored_event.event.content.as_str()) + .collect(); + assert_eq!( + contents, + vec!["m2", "m1"], + "fallback page must be the writer's answer (no replica marker)" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + + /// [`replica_window_failure_falls_back_to_writer`] for the thread-replies + /// path: a replica-routed thread page whose query errors after the proof + /// re-runs on the writer instead of surfacing an error. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn replica_thread_failure_falls_back_to_writer() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "fbt_w").await; + let (replica, rname) = create_scratch_db(&admin, "fbt_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let root = signed_event_at(&author, "root", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &root).await; + } + let replies: Vec = (1..=3) + .map(|i| signed_event_at(&author, &format!("r{i}"), base + 10 * i as u64)) + .collect(); + for pool in [&writer, &replica] { + for reply in &replies { + insert_thread_reply(pool, community, channel, &root, reply).await; + } + } + // Replica-only divergent reply between r2 and r3 marks replica serves. + let ghost = signed_event_at(&author, "replica-only-ghost", base + 25); + insert_thread_reply(&replica, community, channel, &root, &ghost).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let page1 = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 2, None) + .await + .expect("head page"); + let cur = thread_cursor(page1.last().expect("page 1 non-empty")); + + // Healthy: the full page after r2 is the replica's [ghost]. + let healthy = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("healthy replica page"); + assert_eq!( + healthy[0].stored_event.event.content, "replica-only-ghost", + "fixture must route the cursor page to the replica while healthy" + ); + + sqlx::query("DROP TABLE events CASCADE") + .execute(&replica) + .await + .expect("drop replica events"); + + let page = db + .get_thread_replies(cid, root.id.as_bytes(), Some(10), 1, Some(&cur)) + .await + .expect("replica failure must fall back to the writer, not error"); + assert_eq!( + page[0].stored_event.event.content, "r3", + "fallback page must be the writer's answer" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + + /// Mid-request degradation of the held session (Dawn, review of + /// 1b0aa0dfa): when the proved replica transaction dies between the page + /// and an aux follow-up (stand-in: `pg_terminate_backend` on the reader + /// connection, the same tx-fatal shape as a recovery-conflict cancel), + /// [`ReadSession::query_events`] must re-run the query on the writer and + /// permanently degrade the session instead of surfacing the error. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn read_session_degrades_to_writer_when_replica_connection_dies() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "deg_w").await; + let (replica, rname) = create_scratch_db(&admin, "deg_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + for pool in [&writer, &replica] { + for ev in [&m1, &m2] { + insert_top_level(pool, community, channel, ev).await; + } + } + // Writer-only row proves the degraded aux ran on the writer. + let fresh = signed_event_at(&author, "fresh-writer-only", base + 20); + insert_top_level(&writer, community, channel, &fresh).await; + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + let (_window, mut session) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .await + .expect("routed cursor window"); + assert!( + session.is_replica(), + "fixture must route this page to the replica" + ); + + // Kill the reader's backend out from under the held transaction. + sqlx::query( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity \ + WHERE datname = $1 AND pid <> pg_backend_pid()", + ) + .bind(&rname) + .execute(&admin) + .await + .expect("terminate replica backends"); + + let mut aux = EventQuery::for_community(cid); + aux.channel_id = Some(channel); + let rows = session + .query_events(&aux) + .await + .expect("session must degrade to the writer, not error"); + assert!( + rows.iter() + .any(|se| se.event.content == "fresh-writer-only"), + "degraded aux must be served by the writer" + ); + assert!( + !session.is_replica(), + "the session must be permanently degraded to the writer" + ); + + drop(session); + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + + /// Snapshot continuity (Wren, review of 17ea2ff6a): the routed request + /// runs inside ONE `REPEATABLE READ, READ ONLY` transaction whose first + /// statement was the heartbeat observation — so a row committed on the + /// replica *after* the proof must be invisible to every follow-up + /// statement in the same request (page, participants, aux). This + /// distinguishes the transaction contract from mere connection reuse: + /// autocommit statements on the same backend advance their snapshot + /// per statement and WOULD see the mid-request row. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn routed_request_holds_one_snapshot_across_page_and_aux() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "snap_w").await; + let (replica, rname) = create_scratch_db(&admin, "snap_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let m1 = signed_event_at(&author, "m1", base); + let m2 = signed_event_at(&author, "m2", base + 10); + for pool in [&writer, &replica] { + for ev in [&m1, &m2] { + insert_top_level(pool, community, channel, ev).await; + } + } + + let db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Head page on the writer yields the cursor for a replica-routed page. + let head = db + .get_channel_window(cid, channel, 1, None, None) + .await + .expect("head window"); + let cursor = head.next_cursor.expect("has_more implies next_cursor"); + + // Route the cursor page to the replica and HOLD the session. + let (window, mut session) = db + .get_channel_window_with_session(cid, channel, 10, Some(cursor), None) + .await + .expect("routed cursor window"); + assert!( + session.is_replica(), + "fixture must route this page to the replica" + ); + assert_eq!(window.rows.len(), 1, "page after m2 is [m1]"); + + // Mid-request: a new event commits on the replica (stands in for + // replay advancing between the page and the aux closure). + let mid = signed_event_at(&author, "mid-request-commit", base + 5); + insert_top_level(&replica, community, channel, &mid).await; + + // A fresh autocommit statement on ANOTHER session sees it — the row + // is really there (control for the assertion below). + let mut control = EventQuery::for_community(cid); + control.channel_id = Some(channel); + let visible_elsewhere = event::query_events(&replica, &control) + .await + .expect("control query"); + assert!( + visible_elsewhere + .iter() + .any(|se| se.event.content == "mid-request-commit"), + "control: the mid-request row must be committed and visible to a new snapshot" + ); + + // The held request session must NOT see it: its snapshot was + // anchored by the heartbeat observation, before the commit. + let mut aux = EventQuery::for_community(cid); + aux.channel_id = Some(channel); + let in_request = session.query_events(&aux).await.expect("aux query"); + assert!( + !in_request + .iter() + .any(|se| se.event.content == "mid-request-commit"), + "request transaction must hold the proof-time snapshot; a \ + mid-request commit leaking in means the aux ran outside the \ + request transaction (autocommit connection reuse)" + ); + // Rows from the proof-time snapshot are still served. + assert!( + in_request.iter().any(|se| se.event.content == "m1"), + "proof-time rows must remain visible in the request snapshot" + ); + + drop(session); + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + + /// Head gate (Predicate A): with the budget unset, a head fetch reads + /// the writer even over an open fence; with a budget set and a fresh + /// proved entry, the head page is served by the replica session + /// (bounded staleness accepted); with a budget the fence entry exceeds, + /// the head page falls back to the writer. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn head_fetch_routes_by_configured_budget() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "head_w").await; + let (replica, rname) = create_scratch_db(&admin, "head_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let shared = signed_event_at(&author, "shared", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &shared).await; + } + // Divergent heads prove which pool served the fetch. + let fresh = signed_event_at(&author, "fresh-writer-only", base + 30); + insert_top_level(&writer, community, channel, &fresh).await; + let marker = signed_event_at(&author, "replica-only-marker", base + 20); + insert_top_level(&replica, community, channel, &marker).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + let head_contents = |w: &thread::ChannelWindow| -> Vec { + w.rows + .iter() + .map(|r| r.stored_event.event.content.clone()) + .collect() + }; + + // Budget unset (rollout default): head → writer, fence open or not. + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, gate off"); + assert_eq!( + head_contents(&head), + vec!["fresh-writer-only".to_string(), "shared".to_string()], + "head routing must default off" + ); + + // Budget set, entry fresh (just recorded): head → replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, gate on"); + assert_eq!( + head_contents(&head), + vec!["replica-only-marker".to_string(), "shared".to_string()], + "a fresh proved entry within budget must serve the head from the replica" + ); + + // Entry older than the budget: head falls back to the writer. + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + let head = db + .get_channel_window(cid, channel, 2, None, None) + .await + .expect("head, entry too old"); + assert_eq!( + head_contents(&head), + vec!["fresh-writer-only".to_string(), "shared".to_string()], + "an over-budget entry must fail the head gate closed" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + + /// End-to-end deploy-default proof for the NEW routed seams: with the + /// budget unset, a covered-eligible query (channel-pinned + `until`) + /// through [`Db::query_events_routed`] is served by the WRITER — the + /// `for_query` gate keeps the covered arm dark (rev 5). With the budget + /// set and a fresh proved entry, the same query routes to the replica. + /// Divergent fixtures prove which pool served each read. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_events_routed_defaults_dark_and_routes_covered_when_enabled() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "qer_w").await; + let (replica, rname) = create_scratch_db(&admin, "qer_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + let shared = signed_event_at(&author, "shared", base); + for pool in [&writer, &replica] { + insert_top_level(pool, community, channel, &shared).await; + } + let writer_only = signed_event_at(&author, "writer-only", base + 10); + insert_top_level(&writer, community, channel, &writer_only).await; + let replica_only = signed_event_at(&author, "replica-only", base + 20); + insert_top_level(&replica, community, channel, &replica_only).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Covered-eligible shape: channel-pinned with an `until` upper + // bound below the (now) fence wall. + let q = { + let mut q = EventQuery::for_community(cid); + q.channel_id = Some(channel); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + q + }; + let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { + evs.iter().map(|e| e.event.content.clone()).collect() + }; + + // Deploy default: budget unset ⇒ writer, even though the shape is + // covered-eligible and the fence is open. + let rows = db + .query_events_routed("test_routed", &q) + .await + .expect("routed query, gate off"); + assert!( + contents(&rows).contains("writer-only"), + "budget unset must serve the writer" + ); + assert!( + !contents(&rows).contains("replica-only"), + "budget unset must not reach the replica via the covered arm" + ); + + // Budget set ⇒ the covered arm serves it from the replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let rows = db + .query_events_routed("test_routed", &q) + .await + .expect("routed query, gate on"); + assert!( + contents(&rows).contains("replica-only"), + "budget set + covered-eligible must route to the replica" + ); + assert!(!contents(&rows).contains("writer-only")); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + + /// COUNT is bounded-only (rev 5 deletion-visibility rule): a + /// covered-eligible shape must NOT let a count take the covered arm. + /// With the budget unset the count reads the WRITER even with an open + /// fence; with the budget set and a fresh entry it reads the replica + /// under the bounded arm. Divergent row counts prove the serving pool. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn count_events_routed_is_bounded_only() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "cnt_w").await; + let (replica, rname) = create_scratch_db(&admin, "cnt_r").await; + + let author = nostr::Keys::generate(); + let community = Uuid::new_v4(); + let channel = Uuid::new_v4(); + seed_community_channel(&writer, community, channel, &author).await; + seed_community_channel(&replica, community, channel, &author).await; + + let base = 1_700_000_000u64; + // Writer: 2 rows. Replica: 1 row. + for (i, content) in ["a", "b"].iter().enumerate() { + let ev = signed_event_at(&author, content, base + i as u64); + insert_top_level(&writer, community, channel, &ev).await; + } + let ev = signed_event_at(&author, "c", base); + insert_top_level(&replica, community, channel, &ev).await; + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + let cid = CommunityId::from_uuid(community); + + // Covered-eligible shape on purpose: pinned + until. A count must + // ignore that eligibility. + let q = { + let mut q = EventQuery::for_community(cid); + q.channel_id = Some(channel); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + q + }; + + // Budget unset ⇒ bounded arm disabled ⇒ writer. + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, gate off"); + assert_eq!(n, 2, "budget unset must count on the writer"); + + // Budget set + fresh entry ⇒ bounded arm ⇒ replica. + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, gate on"); + assert_eq!(n, 1, "budget set must count on the replica (bounded)"); + + // Entry older than the budget ⇒ bounded fails ⇒ writer. Covered + // would still hold here (upper <= wall) — proving count never + // consults it. + db.fence().close(); + db.fence().force_open_for_tests_at( + chrono::Utc::now(), + std::time::Instant::now() - std::time::Duration::from_secs(10), + ); + let n = db + .count_events_routed("test_count", &q) + .await + .expect("count, entry too old"); + assert_eq!( + n, 2, + "an over-budget entry must fail the count closed to the writer, \ + even when the covered arm would admit the shape" + ); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + + /// Community separation across every routed seam, verified on + /// REPLICA-SERVED reads. + /// + /// The pre-existing feed/event scoping tests prove the shared SQL + /// builders confine rows to one community, but they exercise those + /// builders through the WRITER wrapper. `_on` variants are + /// executor-only refactors, so scoping *should* be identical — this + /// test refuses to take that on faith and re-proves it through the + /// routed executor, on a snapshot the replica actually served. + /// + /// Construction: two communities A and B exist in BOTH databases with + /// the same ids. The replica additionally holds a `replica-only` row in + /// each — divergent fixtures, so any row bearing that content proves + /// the replica (not the writer) served the read. Every assertion + /// requests A and demands B's rows never appear, including B's + /// `replica-only` row, which is the one a leaky predicate would surface. + /// The routed fallback must cost ONE reader acquire budget, even when the + /// Aurora capability cache is cold. + /// + /// Regression test for a stacked-budget bug found at `9fa3c9c0b`: the + /// capability probe used to `acquire()` from the pool itself and return + /// `false` *uncached* on `PoolTimedOut`, so the routed read then spent a + /// SECOND `READER_ACQUIRE_TIMEOUT` inside `begin`. Measured 302ms against + /// a ~150ms documented bound. Boot priming + /// ([`Db::spawn_read_pool_boot_ping`]) hid it only when the boot ping + /// SUCCEEDED — and a reader that is unavailable at boot is exactly the + /// case the bound is specified for, so the two failures are correlated. + /// + /// The fixture reproduces that state deliberately: a size-1 reader whose + /// sole connection is established and then HELD (so every further acquire + /// must time out), with `reader_aurora_identity` asserted cold. It routes + /// through `count_events_routed` rather than calling `proved_reader` + /// directly, because `buzz_db_route_decision` is emitted by `route_read` + /// — a direct call would prove the timing but never emit the label. + /// + /// Timing uses an upper bound of 2x the budget minus a margin: it must + /// fail for two stacked budgets (~300ms) while tolerating scheduler + /// jitter on one (~150ms). Asserting a lower bound too would pin the + /// budget's own value, which `reader_acquire_timeout_is_the_documented_budget` + /// already covers. + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn routed_fallback_spends_one_acquire_budget_when_aurora_cache_is_cold() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed, wname) = create_scratch_db(&admin, "one_budget").await; + seed.close().await; + let base = admin_url().await; + let scratch_url = { + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], wname) + }; + + // `Db::new` so the writer arms the floor guard and the reader is the + // real lazy `connect_read_pool` pool (min_connections=0, 150ms + // acquire timeout). Reader is sized 1 so holding one connection + // saturates it. + let mut db = Db::new(&DbConfig { + database_url: scratch_url.clone(), + read_database_url: Some(scratch_url), + max_connections: 4, + read_max_connections: Some(1), + ..DbConfig::default() + }) + .await + .expect("connect armed Db with size-1 lazy reader"); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(Duration::from_secs(5))); + + let read_pool = db.read_pool.clone().expect("reader pool configured"); + // Establish and hold the reader's only connection: saturated. + let held = read_pool + .acquire() + .await + .expect("establish the reader's sole connection"); + assert_eq!( + db.read_max_connections, 1, + "reader max must report 1 for this fixture to test saturation" + ); + assert_eq!( + read_pool.size(), + 1, + "the sole reader connection is established and held" + ); + // The bug is only observable with the capability cache cold; if a + // future change primes it here, this fixture would silently stop + // discriminating. + assert!( + db.reader_aurora_identity.get().is_none(), + "Aurora capability must be UNPRIMED (post-boot-ping-failure state)" + ); + + let recorder = metrics_util::debugging::DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let query = EventQuery::for_community(CommunityId::from_uuid(Uuid::new_v4())); + + // The recorder is installed thread-locally, so it must stay installed + // across the `.await` — hence the guard form rather than + // `with_local_recorder`, whose closure cannot host an await. The + // `current_thread` flavor keeps the route decision on this thread; on + // a multi-thread runtime the emit could land on a worker where no + // local recorder is installed and the label assertions would vacuously + // see an empty snapshot. + let start = std::time::Instant::now(); + let count = { + let _guard = metrics::set_default_local_recorder(&recorder); + db.count_events_routed("one_budget_probe", &query).await + } + .expect("writer fallback still answers the read"); + let elapsed = start.elapsed(); + + assert_eq!(count, 0, "writer answered on an empty scratch database"); + assert!( + elapsed < Duration::from_millis(250), + "routed fallback must spend ONE {}ms acquire budget, not two; took {}ms", + Db::READER_ACQUIRE_TIMEOUT.as_millis(), + elapsed.as_millis() + ); + + let reasons: std::collections::HashMap<(String, String), u64> = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_db_route_decision") + .map(|(key, _, _, value)| { + let metrics_util::debugging::DebugValue::Counter(n) = value else { + panic!("buzz_db_route_decision must be a counter"); + }; + let labels: Vec<_> = key.key().labels().collect(); + let get = |name: &str| { + labels + .iter() + .find(|l| l.key() == name) + .map(|l| l.value().to_owned()) + .unwrap_or_default() + }; + ((get("decision"), get("reason")), n) + }) + .collect(); + + assert_eq!( + reasons.get(&("writer".to_owned(), "reader_acquire_timeout".to_owned())), + Some(&1), + "saturated reader must fall back as writer/reader_acquire_timeout; got {reasons:?}" + ); + // `reader_validation_error` would mean we misclassified a timeout as a + // broken reader, and `pool_busy` is the retired name — neither may + // appear in ANY emitted label. + assert!( + !reasons + .keys() + .any(|(_, reason)| reason == "reader_validation_error" || reason == "pool_busy"), + "no reader_validation_error or retired pool_busy label may be emitted; got {reasons:?}" + ); + + drop(held); + drop_scratch_db(&admin, db.pool.clone(), &wname).await; + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn routed_reads_are_confined_to_the_requested_community() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (writer, wname) = create_scratch_db(&admin, "sep_w").await; + let (replica, rname) = create_scratch_db(&admin, "sep_r").await; + + let author = nostr::Keys::generate(); + let (comm_a, chan_a) = (Uuid::new_v4(), Uuid::new_v4()); + let (comm_b, chan_b) = (Uuid::new_v4(), Uuid::new_v4()); + for pool in [&writer, &replica] { + seed_community_channel(pool, comm_a, chan_a, &author).await; + seed_community_channel(pool, comm_b, chan_b, &author).await; + } + + // A p-tag mention is what makes a row eligible for the mentions and + // needs-action feeds. Kind 9 satisfies mentions + activity; + // needs-action admits only approval/reminder kinds, so each + // community also gets a kind-46010 row. + let mentioned = nostr::Keys::generate(); + let mentioned_hex = mentioned.public_key().to_hex(); + let mentioned_bytes = mentioned.public_key().to_bytes(); + let tagged_kind = |kind: u16, content: &str, secs: u64| { + nostr::EventBuilder::new(nostr::Kind::Custom(kind), content) + .tags([nostr::Tag::parse(["p", mentioned_hex.as_str()]).expect("p tag")]) + .custom_created_at(nostr::Timestamp::from(secs)) + .sign_with_keys(&author) + .expect("sign event") + }; + let tagged = |content: &str, secs: u64| tagged_kind(9, content, secs); + + let base = 1_700_000_000u64; + // Shared rows (both DBs) + replica-only rows (divergence) per community. + let a_shared = tagged("a-shared", base); + let b_shared = tagged("b-shared", base + 1); + for pool in [&writer, &replica] { + insert_top_level(pool, comm_a, chan_a, &a_shared).await; + insert_mentions( + pool, + CommunityId::from_uuid(comm_a), + &a_shared, + Some(chan_a), + ) + .await + .expect("mentions a-shared"); + insert_top_level(pool, comm_b, chan_b, &b_shared).await; + insert_mentions( + pool, + CommunityId::from_uuid(comm_b), + &b_shared, + Some(chan_b), + ) + .await + .expect("mentions b-shared"); + } + let a_replica_only = tagged("a-replica-only", base + 10); + let b_replica_only = tagged("b-replica-only", base + 11); + insert_top_level(&replica, comm_a, chan_a, &a_replica_only).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_a), + &a_replica_only, + Some(chan_a), + ) + .await + .expect("mentions a-replica-only"); + insert_top_level(&replica, comm_b, chan_b, &b_replica_only).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_b), + &b_replica_only, + Some(chan_b), + ) + .await + .expect("mentions b-replica-only"); + + // Needs-action fixtures: approval kind, replica-only in BOTH + // communities, so the assertion below is replica-served on A and + // must still not see B's. + let a_approval = tagged_kind(46010, "a-approval-replica-only", base + 20); + let b_approval = tagged_kind(46010, "b-approval-replica-only", base + 21); + insert_top_level(&replica, comm_a, chan_a, &a_approval).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_a), + &a_approval, + Some(chan_a), + ) + .await + .expect("mentions a-approval"); + insert_top_level(&replica, comm_b, chan_b, &b_approval).await; + insert_mentions( + &replica, + CommunityId::from_uuid(comm_b), + &b_approval, + Some(chan_b), + ) + .await + .expect("mentions b-approval"); + + let mut db = Db::from_pools(writer.clone(), replica.clone()); + db.fence().force_open_for_tests(chrono::Utc::now()); + db.set_replica_read_max_age_for_tests(Some(std::time::Duration::from_secs(5))); + let cid_a = CommunityId::from_uuid(comm_a); + + let contents = |evs: &[StoredEvent]| -> std::collections::BTreeSet { + evs.iter().map(|e| e.event.content.clone()).collect() + }; + // Every routed seam must (a) have been served by the replica — + // proven by a divergent row absent from the writer — and (b) contain + // no row belonging to community B. All B fixtures are named `b-*`, + // so the leak check is a single prefix scan. + let assert_a_only = |rows: &[StoredEvent], marker: &str, seam: &str| { + let got = contents(rows); + assert!( + got.contains(marker), + "{seam}: must be replica-served (divergent row `{marker}` absent from writer); got {got:?}" + ); + assert!( + !got.iter().any(|c| c.starts_with("b-")), + "{seam}: community B rows leaked into a community A read; got {got:?}" + ); + }; + + // 1. Generic query — covered arm (channel-pinned + `until`). + let mut q = EventQuery::for_community(cid_a); + q.channel_id = Some(chan_a); + q.until = chrono::DateTime::from_timestamp((base + 60) as i64, 0); + let rows = db + .query_events_routed("sep_query", &q) + .await + .expect("routed query"); + assert_a_only(&rows, "a-replica-only", "query_events_routed"); + + // 2. Generic query — bounded arm (no channel pin at all, so a + // missing community predicate could not be masked by the pin). + let unpinned = EventQuery::for_community(cid_a); + let rows = db + .query_events_routed_bounded("sep_query_bounded", &unpinned) + .await + .expect("routed bounded query"); + assert_a_only(&rows, "a-replica-only", "query_events_routed_bounded"); + + // 3. COUNT — bounded-only. Community A holds 3 rows on the replica + // (shared + replica-only + approval) but only 1 on the writer, + // and 3 more exist in community B. Exactly 3 proves the read was + // both replica-served and community-confined. + let count = db + .count_events_routed("sep_count", &unpinned) + .await + .expect("routed count"); + assert_eq!( + count, 3, + "count must see A's three replica rows only — not B's, not the writer's one" + ); + + // 4. By-ID hydration — ids carry no channel pin, and B's ids are + // requested alongside A's. Only A's may hydrate. + let ids: Vec<&[u8]> = vec![ + a_shared.id.as_bytes(), + a_replica_only.id.as_bytes(), + b_shared.id.as_bytes(), + b_replica_only.id.as_bytes(), + ]; + let rows = db + .get_events_by_ids_routed("sep_by_ids", cid_a, &ids) + .await + .expect("routed by-ids"); + assert_a_only(&rows, "a-replica-only", "get_events_by_ids_routed"); + + // 5-7. All three feed builders, each given BOTH channels as + // accessible — so only the community predicate can exclude B. + let both = [chan_a, chan_b]; + let rows = db + .query_feed_mentions_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) + .await + .expect("routed mentions"); + assert_a_only(&rows, "a-replica-only", "query_feed_mentions_routed"); + + let rows = db + .query_feed_needs_action_routed("sep_feed", cid_a, &mentioned_bytes, &both, None, 50) + .await + .expect("routed needs action"); + assert_a_only( + &rows, + "a-approval-replica-only", + "query_feed_needs_action_routed", + ); + + let rows = db + .query_feed_activity_routed("sep_feed", cid_a, &both, None, 50) + .await + .expect("routed activity"); + assert_a_only(&rows, "a-replica-only", "query_feed_activity_routed"); + + drop_scratch_db(&admin, replica, &rname).await; + drop_scratch_db(&admin, writer, &wname).await; + } + + /// D4: a LAZY reader pool (connect_lazy, min_connections=0, never yet + /// used) must still let [`Db::spawn_fence_probe`] verify the writer's + /// floor guard and spawn — reader-down or reader-idle at boot must not + /// disable fence probing. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn lazy_reader_pool_still_spawns_fence_probe() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin"); + let (seed, wname) = create_scratch_db(&admin, "lazy_w").await; + seed.close().await; + + let writer_url = { + let base = admin_url().await; + let idx = base.rfind('/').expect("db url has a path segment"); + format!("{}/{}", &base[..idx], wname) + }; + // `Db::new` (not `from_pools`) so the WRITER pool arms the + // `buzz.created_at_floor` GUC — `spawn_fence_probe` verifies the + // floor guard on a writer connection, and `create_scratch_db`'s + // plain `PgPool::connect` never arms it. The reader is still the + // lazy `connect_read_pool` pool this test is about. + let db = Db::new(&DbConfig { + database_url: writer_url.clone(), + read_database_url: Some(writer_url), + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db with lazy reader"); + + let spawned = db + .spawn_fence_probe() + .await + .expect("floor-guard verification must pass on the migrated writer"); + assert!(spawned, "a configured (lazy) reader must spawn the probe"); + + drop_scratch_db(&admin, db.pool.clone(), &wname).await; + } + /// Thread replies: head fetch reads the writer; a FULL cursor page is /// served by the replica; an UNDER-limit cursor page (candidate terminal /// page) is re-run on the writer so a lagged replica can never truncate @@ -6012,21 +8212,39 @@ mod tests { let base = admin_url().await; let idx = base.rfind('/').expect("db url has a path segment"); - let db = Db::new(&DbConfig { - database_url: format!("{}/{}", &base[..idx], wname), - read_database_url: Some(format!("{}/{}", &base[..idx], rname)), + let writer_url = format!("{}/{}", &base[..idx], wname); + let replica_url = format!("{}/{}", &base[..idx], rname); + + // Healthy schema: verification passes, probe starts. A SEPARATE Db + // instance, because its background probe legitimately opens its own + // fence (the heartbeat probe is writer-side only) — the refusal + // assertions below must run against a fence whose spawns were all + // refused. + let db_healthy = Db::new(&DbConfig { + database_url: writer_url.clone(), + read_database_url: Some(replica_url.clone()), max_connections: 2, ..DbConfig::default() }) .await .expect("connect armed Db with replica"); - - // Healthy schema: verification passes, probe starts. assert!( - db.spawn_fence_probe().await.expect("verification passes"), + db_healthy + .spawn_fence_probe() + .await + .expect("verification passes"), "probe must start on a verified schema" ); + let db = Db::new(&DbConfig { + database_url: writer_url, + read_database_url: Some(replica_url), + max_connections: 2, + ..DbConfig::default() + }) + .await + .expect("connect armed Db with replica"); + // Sabotage A: catalog-shaped no-op — same trigger, gutted function // body. Catalog check alone would pass; behavior check must refuse. sqlx::query( @@ -6066,6 +8284,10 @@ mod tests { "fence must remain closed when verification refuses the probe" ); + db_healthy.pool.close().await; + if let Some(rp) = &db_healthy.read_pool { + rp.close().await; + } db.pool.close().await; if let Some(rp) = &db.read_pool { rp.close().await; diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index 1d1b7e05d4..6985916bba 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -347,6 +347,7 @@ mod tests { "push_gateway_delivery_auth_replays", "push_gateway_delivery_request_replays", "product_feedback", + "replica_heartbeat", ] { if normalized[insert_pos..].contains(&format!("'{value}'")) { globals.insert(value.to_owned()); @@ -560,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 25); + assert_eq!(migrations.len(), 26); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -904,6 +905,20 @@ mod tests { desired_schema.contains("CREATE TABLE join_policy_acceptances"), "desired-state schema must include join-policy evidence used by invite claims", ); + + // Replica heartbeat (this branch, renumbered to 0026 after + // 0025_relay_invites landed on main): the fence's portable read-side + // observation. A single CHECK'd row makes the token update the + // serialization point (multi-pod commit ordering), and the epoch + // column is what detects token resets — both are load-bearing for + // the routing proof. + assert_eq!(migrations[25].version, 26); + let heartbeat = migrations[25].sql.as_str(); + assert!(heartbeat.contains("CREATE TABLE replica_heartbeat")); + assert!(heartbeat.contains("CHECK (id = 1)")); + assert!(heartbeat.contains("epoch")); + assert!(heartbeat.contains("INSERT INTO replica_heartbeat (id) VALUES (1)")); + assert!(heartbeat.contains("_operator_global_tables")); } #[test] @@ -1146,7 +1161,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(25)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(26)); } #[tokio::test] diff --git a/crates/buzz-db/src/replica_fence.rs b/crates/buzz-db/src/replica_fence.rs index 03bc1c77f0..c840db393e 100644 --- a/crates/buzz-db/src/replica_fence.rs +++ b/crates/buzz-db/src/replica_fence.rs @@ -9,38 +9,59 @@ //! (`clock_timestamp()`, evaluated inside commit processing). Enforcement //! is armed per session via the `buzz.created_at_floor` GUC, which the //! relay's writer pool sets on every connection. -//! 2. **Ordered LSN handshake** (this module): on one pinned writer -//! connection, three separately-awaited statements sample +//! 2. **Ordered heartbeat handshake** (this module): on one pinned writer +//! connection, separately-awaited statements sample //! `S = clock_timestamp()`, then scan `pg_stat_activity` for the oldest -//! open transaction, then capture `L = pg_current_wal_lsn()` **last**. -//! Once the replica reports `pg_last_wal_replay_lsn() >= L`, every -//! transaction partitions into exactly three buckets: -//! (a) finished before the activity scan — its commit WAL precedes `L`, -//! so the replica has replayed it; +//! open transaction, then — **last** — commit heartbeat token `M` via a +//! single-row `UPDATE replica_heartbeat ... RETURNING token, epoch` +//! (migration 0026). Because the single-row UPDATE serializes all pods' +//! probes, tokens are globally commit-ordered. A reader **session** that +//! observes `token >= M` on its own connection has, by WAL/storage replay +//! order, also replayed every commit that preceded M's commit; every +//! transaction then partitions into exactly three buckets: +//! (a) finished before the activity scan — its commit precedes `M`'s +//! commit, so the replica session has replayed it; //! (b) open at the activity scan — represented by `xact_start`, so it is //! bounded by the `oldest_xact_start` term; //! (c) started after the activity scan — its deferred floor guard runs //! after `S`, so it cannot commit a row with //! `created_at < S - floor`. -//! There is no fourth bucket. The fence therefore advances to -//! `min(oldest_xact_start, S) - floor - clock_margin`, and every -//! channel-window row with `created_at <= fence` is on the replica. +//! There is no fourth bucket. Each committed token `M` therefore proves a +//! **fence wall** of `min(oldest_xact_start, S) - floor - clock_margin`: +//! every channel-window row with `created_at <= fence_wall(M)` is present +//! on any reader session observing `token >= M`. +//! +//! Unlike the previous WAL-LSN observation (`pg_last_wal_replay_lsn()`, which +//! Aurora reader endpoints hide), the token observation is portable and — +//! critically — **snapshot-local**: routing opens a `REPEATABLE READ, READ +//! ONLY` transaction on the reader session that will serve the page and +//! observes the heartbeat as its first statement, so the proof binds to the +//! exact snapshot every follow-up statement in the request (page, +//! participants, aux closure) reads from — never to a different pooled +//! session (readers behind one endpoint may sit at different replay +//! positions), and never to a later autocommit snapshot on the same wire. +//! An observed token lower than the newest retained `M` is ordinary +//! replication lag, not a fault; the resolver simply proves from an older +//! retained `M`. Regression detection is writer-side only: a non-monotonic +//! `RETURNING token` or an epoch change (restore/re-seed) clears the retained +//! ring, so no stale entry can masquerade as fresh coverage. //! //! Everything fails **closed**: probe errors, masked `pg_stat_activity` -//! visibility, NULL/absent replica LSN (Aurora observability differences), -//! non-advancing replay, or probe staleness all close the fence, which routes -//! all reads back to the writer — degraded capacity, never holes. +//! visibility, an unreadable heartbeat row on the reader session, an epoch +//! mismatch, or an observed token below every retained entry all route the +//! request back to the writer — degraded capacity, never holes. //! //! Operational bypasses (sessions without the GUC, `session_replication_role //! = replica` restores) are outside the proof by design and require holding //! the fence closed for their duration; see `migrations/0021`. -use std::sync::atomic::{AtomicI64, Ordering}; -use std::sync::Arc; -use std::time::Duration; +use std::collections::VecDeque; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use chrono::{DateTime, Utc}; -use sqlx::{PgPool, Row}; +use sqlx::{PgConnection, PgPool, Row}; +use uuid::Uuid; /// Seconds of `created_at` history the commit-time floor guard tolerates. /// @@ -58,79 +79,231 @@ pub const CREATED_AT_FLOOR_SECS: i64 = 960; /// between machines. pub const FENCE_CLOCK_MARGIN_SECS: i64 = 5; -/// How often the probe samples the writer and checks the replica. -pub const PROBE_INTERVAL: Duration = Duration::from_secs(5); +/// How often the probe samples the writer and commits a heartbeat token. +/// +/// 500ms keeps the cadence at least 2x under the smallest sensible bounded +/// budget (`BUZZ_REPLICA_READ_MAX_AGE_MS`, deploy plan 1000ms) so +/// eligibility doesn't flap between beats. Cost is one single-row UPDATE +/// tuple of WAL per beat per pod — ~20 beats/s fleet-wide, <0.1% of the +/// writer. +pub const PROBE_INTERVAL: Duration = Duration::from_millis(500); -/// A fence older than this is stale: the probe has stopped confirming -/// freshness and the fence closes until a new handshake completes. +/// A fence whose newest entry is older than this is stale: the probe has +/// stopped committing tokens and routing eligibility closes until a new +/// handshake completes. +/// +/// Note this is an availability hygiene gate, not a soundness requirement: +/// a retained entry's proof (`token >= M` on a session implies every row +/// `<= fence_wall(M)` is present there) never decays. Closing on staleness +/// just stops spending reader checkouts once the probe is evidently dead. pub const FENCE_STALENESS: Duration = Duration::from_secs(30); -/// Sentinel: fence closed (no verified replica coverage). -const CLOSED: i64 = i64::MIN; +/// How many `(token, fence_wall)` entries the fence retains. At one probe +/// per [`PROBE_INTERVAL`] (500ms) this is ~60 seconds of history — a reader +/// session lagging further than that behind the newest token fails closed +/// (routes to the writer) rather than proving from thin air. Aurora reader +/// lag is typically tens of milliseconds; a reader minutes behind is a +/// fault, not a routing candidate. +const RING_CAPACITY: usize = 120; -/// Shared fence state. `Db` holds an `Arc` of this; the probe task advances -/// it and cursor routing consults it. -#[derive(Debug)] +// The retained window must outlast the staleness gate: if the ring held +// less than FENCE_STALENESS of history, a non-stale newest entry could +// coexist with proved-but-evicted older entries, failing sessions closed +// for capacity rather than lag. Compile-checked so a future cadence or +// capacity tweak can't silently shrink the window below the gate. +const _: () = assert!( + RING_CAPACITY as u64 * PROBE_INTERVAL.as_millis() as u64 > FENCE_STALENESS.as_millis() as u64, + "fence ring must retain more history than the staleness gate" +); + +/// One retained heartbeat observation: proof material for reader sessions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TokenEntry { + /// The committed heartbeat token `M`. + pub token: i64, + /// Monotonic instant captured just before `M` was committed. `elapsed()` + /// bounds (from above) how old a session observing `token >= M` can be — + /// the freshness term of the head-routing predicate. + pub committed_at: Instant, + /// `min(oldest_xact_start, S) - floor - clock_margin` for `M`'s + /// handshake: every channel-window row with `created_at <= fence_wall` + /// is present on any session observing `token >= M`. + pub fence_wall: DateTime, +} + +#[derive(Debug, Default)] +struct FenceInner { + /// Epoch the retained ring belongs to. `None` until the first probe — + /// or after the test hook, whose injected entry deliberately bypasses + /// the epoch comparison in [`ReplicaFence::resolve`]. + epoch: Option, + /// Retained entries in strictly increasing token order. + ring: VecDeque, +} + +/// Outcome of recording one probe sample. +#[derive(Debug, PartialEq, Eq)] +pub enum RecordOutcome { + /// Entry retained; proofs may cite it. + Recorded, + /// The token went backwards within the same epoch — a restore that kept + /// the old epoch. The ring was cleared and the entry discarded; the + /// probe must rotate the epoch before recording again (a reader still on + /// the pre-rewind timeline could otherwise observe a *higher* token that + /// proves nothing about the new timeline). + TokenRegression, +} + +/// Outcome of resolving one reader-session observation against the ring. +/// Everything but [`ResolveOutcome::Proved`] fails closed (routes to the +/// writer); the variants exist so route metrics can name the reason. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolveOutcome { + /// The observation proves this retained entry. + Proved(TokenEntry), + /// The observed epoch is not the ring's epoch — the session is on a + /// different timeline (restore) or the ring was rotated under it. + EpochMismatch, + /// The observed token is below every retained entry: the reader lags + /// further than the ring's history (or the ring is empty). + TokenBehind, +} + +impl ResolveOutcome { + /// The proved entry, if any. + pub fn proved(self) -> Option { + match self { + ResolveOutcome::Proved(entry) => Some(entry), + _ => None, + } + } +} + +/// Shared fence state. `Db` holds an `Arc` of this; the probe task records +/// entries and per-request routing resolves proofs against it. +#[derive(Debug, Default)] pub struct ReplicaFence { - /// Unix micros of the newest verified-complete timestamp, or `CLOSED`. - fence_micros: AtomicI64, - /// Unix micros when the fence was last advanced (staleness check). - updated_micros: AtomicI64, + inner: Mutex, } impl ReplicaFence { - /// A new fence, initially closed. + /// A new fence, initially closed (empty ring). pub fn new() -> Self { - Self { - fence_micros: AtomicI64::new(CLOSED), - updated_micros: AtomicI64::new(CLOSED), - } + Self::default() } - /// Close the fence: all cursor reads route to the writer. + /// Close the fence: drop all retained proofs; reads route to the writer. pub fn close(&self) { - self.fence_micros.store(CLOSED, Ordering::Relaxed); + let mut inner = self.inner.lock().expect("fence lock poisoned"); + inner.ring.clear(); } - fn advance(&self, fence: DateTime) { - self.fence_micros - .store(fence.timestamp_micros(), Ordering::Relaxed); - self.updated_micros - .store(Utc::now().timestamp_micros(), Ordering::Relaxed); + /// Record one probe sample. Epoch changes (re-seed) clear the ring and + /// start a new one under the observed epoch — sound, because an entry + /// only proves commits on its own timeline and readers must match the + /// epoch to cite it. A same-epoch token regression is the unsafe case: + /// see [`RecordOutcome::TokenRegression`]. + pub fn record( + &self, + token: i64, + epoch: Uuid, + committed_at: Instant, + fence_wall: DateTime, + ) -> RecordOutcome { + let mut inner = self.inner.lock().expect("fence lock poisoned"); + if inner.epoch != Some(epoch) { + inner.ring.clear(); + inner.epoch = Some(epoch); + } else if inner.ring.back().is_some_and(|last| token <= last.token) { + inner.ring.clear(); + return RecordOutcome::TokenRegression; + } + if inner.ring.len() == RING_CAPACITY { + inner.ring.pop_front(); + } + inner.ring.push_back(TokenEntry { + token, + committed_at, + fence_wall, + }); + RecordOutcome::Recorded } - /// The current fence, or `None` when closed or stale. + /// Resolve the strongest proof a reader session's observation supports: + /// the greatest retained entry with `entry.token <= observed_token`, + /// provided the observed epoch matches the ring's. Non-`Proved` outcomes + /// fail closed; they are distinguished only for route metrics. + pub fn resolve(&self, observed_token: i64, observed_epoch: Uuid) -> ResolveOutcome { + let inner = self.inner.lock().expect("fence lock poisoned"); + match inner.epoch { + Some(e) if e != observed_epoch => return ResolveOutcome::EpochMismatch, + // `None` with a non-empty ring only happens via the test hook; + // the epoch comparison is deliberately skipped there. + _ => {} + } + inner + .ring + .iter() + .rev() + .find(|entry| entry.token <= observed_token) + .copied() + .map_or(ResolveOutcome::TokenBehind, ResolveOutcome::Proved) + } + + /// The newest retained entry, staleness-gated. Used as the cheap + /// pre-check before spending a reader checkout, and for observability. + pub fn newest(&self) -> Option { + let inner = self.inner.lock().expect("fence lock poisoned"); + inner + .ring + .back() + .filter(|entry| entry.committed_at.elapsed() <= FENCE_STALENESS) + .copied() + } + + /// Age of the newest retained entry, ungated (observability: how long + /// since the probe last committed a token). + pub fn heartbeat_age(&self) -> Option { + let inner = self.inner.lock().expect("fence lock poisoned"); + inner.ring.back().map(|entry| entry.committed_at.elapsed()) + } + + /// The newest fence wall, or `None` when closed or stale. /// - /// Rows with `created_at <= fence` are verified present on the replica. + /// Rows with `created_at <= fence` are verified present on a reader + /// session that proves the newest entry; whether a *given* session does + /// is decided per request via [`ReplicaFence::resolve`]. pub fn verified_through(&self) -> Option> { - let raw = self.fence_micros.load(Ordering::Relaxed); - if raw == CLOSED { - return None; - } - let updated = self.updated_micros.load(Ordering::Relaxed); - let age_micros = Utc::now().timestamp_micros().saturating_sub(updated); - if age_micros > FENCE_STALENESS.as_micros() as i64 { - return None; - } - DateTime::from_timestamp_micros(raw) + self.newest().map(|entry| entry.fence_wall) } - /// Whether the replica verifiably holds every channel-window row at or - /// before `ts`. + /// Whether some retained entry's wall covers `ts` — the cheap routing + /// pre-check (the connection-local observation still has to prove it). pub fn covers(&self, ts: DateTime) -> bool { self.verified_through().is_some_and(|fence| ts <= fence) } /// Test hook: force the fence open through `ts` without a probe. - /// Used by routing tests that stand up a divergent fake replica. + /// Injects an entry any observed token satisfies (`i64::MIN`) with no + /// epoch recorded, so the epoch comparison is bypassed — routing tests + /// stand up a divergent fake replica whose heartbeat epoch differs from + /// the writer's. pub fn force_open_for_tests(&self, ts: DateTime) { - self.advance(ts); + self.force_open_for_tests_at(ts, Instant::now()); } -} -impl Default for ReplicaFence { - fn default() -> Self { - Self::new() + /// [`ReplicaFence::force_open_for_tests`] with an explicit commit + /// instant, for pinning age-gated behavior (head-budget and staleness + /// tests inject entries "committed" in the past). + pub fn force_open_for_tests_at(&self, ts: DateTime, committed_at: Instant) { + let mut inner = self.inner.lock().expect("fence lock poisoned"); + inner.epoch = None; + inner.ring.clear(); + inner.ring.push_back(TokenEntry { + token: i64::MIN, + committed_at, + fence_wall: ts, + }); } } @@ -332,15 +505,20 @@ struct WriterSample { /// Oldest open transaction among other client backends at scan time, /// or `None` when no transaction was open. oldest_xact_start: Option>, - /// `L`: writer `pg_current_wal_lsn()` captured last, as text. - wal_lsn: String, + /// `M`: the heartbeat token committed **last**, after the scan. + token: i64, + /// Heartbeat epoch returned with `M`. + epoch: Uuid, + /// Monotonic instant captured immediately before committing `M` — an + /// upper bound on how old a session observing `token >= M` can be. + committed_at: Instant, } /// Errors that close the fence. All variants are logged and treated /// identically: fail closed. #[derive(Debug, thiserror::Error)] pub enum ProbeError { - /// A probe query against writer or replica failed. + /// A probe query against the writer failed. #[error("writer probe query failed: {0}")] Writer(#[from] sqlx::Error), /// `pg_stat_activity` hid state for another backend that could hold an @@ -353,14 +531,15 @@ pub enum ProbeError { /// Number of other client backends with masked/unknown state. masked: i64, }, - /// The replica returned NULL for the replay-LSN comparison. - #[error("replica did not report a comparable replay LSN")] - ReplicaLsnUnavailable, + /// The single heartbeat row (migration 0026) is missing on the writer. + #[error("replica_heartbeat row missing on the writer — migration 0026 not applied?")] + HeartbeatRowMissing, } -/// Take one ordered writer sample: S, then activity scan, then L **last**. +/// Take one ordered writer sample: S, then activity scan, then commit the +/// heartbeat token **last**. /// -/// The three statements are separately awaited on a single pinned connection; +/// The statements are separately awaited on a single pinned connection; /// a single SELECT would not guarantee evaluation order across the /// subexpressions, reopening the race this ordering exists to close. async fn sample_writer(writer: &PgPool) -> Result { @@ -393,8 +572,8 @@ async fn sample_writer(writer: &PgPool) -> Result { // // Prepared transactions (2PC) are a bucket of their own: while // prepared they have left `pg_stat_activity` but can still commit - // after `L`. Their deferred floor guard already ran at PREPARE, so - // `pg_prepared_xacts.prepared` bounds their rows exactly like + // after the token. Their deferred floor guard already ran at PREPARE, + // so `pg_prepared_xacts.prepared` bounds their rows exactly like // `xact_start`; fold it into the same minimum. let row = sqlx::query( r#" @@ -423,80 +602,171 @@ async fn sample_writer(writer: &PgPool) -> Result { } let oldest_xact_start: Option> = row.get("oldest_xact_start"); - // 3. L last. - let wal_lsn: String = sqlx::query_scalar("SELECT pg_current_wal_lsn()::text") - .fetch_one(&mut *conn) - .await?; + // 3. Token commit LAST, on the same pinned connection. The single-row + // UPDATE serializes concurrent pods' probes, so RETURNING token is + // globally commit-ordered. `committed_at` is captured before the + // round trip so `elapsed()` over-estimates the observation's age — + // the conservative direction for the head-freshness bound. + let committed_at = Instant::now(); + let row = sqlx::query( + "UPDATE replica_heartbeat SET token = token + 1 WHERE id = 1 RETURNING token, epoch", + ) + .fetch_optional(&mut *conn) + .await? + .ok_or(ProbeError::HeartbeatRowMissing)?; Ok(WriterSample { sampled_at, oldest_xact_start, - wal_lsn, + token: row.get("token"), + epoch: row.get("epoch"), + committed_at, }) } -/// Whether the replica has replayed at least through `wal_lsn`. -/// -/// The comparison happens on the replica in pg_lsn domain. The -/// `pg_is_in_recovery()` gate is load-bearing: after crash recovery or -/// promotion a *primary* returns a static non-NULL `pg_last_wal_replay_lsn()` -/// rather than NULL, so NULL-checking alone would not reliably detect a -/// misrouted "replica" URL. Not-in-recovery, NULL replay LSN, or Aurora -/// hiding either is an error → fence closes. -async fn replica_covers(replica: &PgPool, wal_lsn: &str) -> Result { - let covered: Option = sqlx::query_scalar( - r#" - SELECT CASE - WHEN pg_is_in_recovery() THEN pg_last_wal_replay_lsn() >= $1::pg_lsn - ELSE NULL - END - "#, - ) - .bind(wal_lsn) - .fetch_one(replica) - .await?; - covered.ok_or(ProbeError::ReplicaLsnUnavailable) +/// The fence wall proved by one handshake: +/// `min(oldest_xact_start, S) - floor - clock_margin`. +fn fence_wall(sample_s: DateTime, oldest_xact_start: Option>) -> DateTime { + let lower = match oldest_xact_start { + Some(oldest) => oldest.min(sample_s), + None => sample_s, + }; + lower + - chrono::Duration::seconds(CREATED_AT_FLOOR_SECS) + - chrono::Duration::seconds(FENCE_CLOCK_MARGIN_SECS) } -/// Run one full handshake and, on success, advance the fence. +/// Run one full handshake and record the resulting `(token, fence_wall)`. /// -/// Returns the new fence value for observability. `Ok(None)` means the -/// replica has not yet replayed past the sample; the fence is left as-is -/// (staleness will close it if this persists). -pub async fn probe_once( - writer: &PgPool, - replica: &PgPool, - fence: &ReplicaFence, -) -> Result>, ProbeError> { +/// On a same-epoch token regression (the writer was restored from a backup +/// that kept its epoch), the retained ring has already been cleared by +/// [`ReplicaFence::record`]; this additionally **rotates the epoch** on the +/// writer and records the rotated token, so a reader still serving the +/// pre-rewind timeline (whose old, higher token would otherwise satisfy +/// `token >= M`) fails the epoch check instead of proving stale coverage. +pub async fn probe_once(writer: &PgPool, fence: &ReplicaFence) -> Result { let sample = sample_writer(writer).await?; - if !replica_covers(replica, &sample.wal_lsn).await? { - return Ok(None); + let wall = fence_wall(sample.sampled_at, sample.oldest_xact_start); + match fence.record(sample.token, sample.epoch, sample.committed_at, wall) { + RecordOutcome::Recorded => Ok(TokenEntry { + token: sample.token, + committed_at: sample.committed_at, + fence_wall: wall, + }), + RecordOutcome::TokenRegression => { + tracing::warn!( + token = sample.token, + "replica heartbeat token regressed within its epoch (restore?); rotating epoch" + ); + // The rotation commit happens after this sample's activity scan, + // so the same three-bucket argument (and the same wall) holds + // for the rotated token. + let committed_at = Instant::now(); + let row = sqlx::query( + "UPDATE replica_heartbeat SET epoch = gen_random_uuid(), token = token + 1 \ + WHERE id = 1 RETURNING token, epoch", + ) + .fetch_optional(writer) + .await? + .ok_or(ProbeError::HeartbeatRowMissing)?; + let token: i64 = row.get("token"); + let epoch: Uuid = row.get("epoch"); + // A fresh epoch always clears and records; regression is + // impossible against an empty ring. + fence.record(token, epoch, committed_at, wall); + Ok(TokenEntry { + token, + committed_at, + fence_wall: wall, + }) + } } - let lower = match sample.oldest_xact_start { - Some(oldest) => oldest.min(sample.sampled_at), - None => sample.sampled_at, +} + +/// The Aurora **PostgreSQL** instance-identity function. Named once so the +/// capability probe and the observation query can never disagree — and +/// pinned by a unit test, because the MySQL-family spelling +/// (`aurora_server_id`) is a near-miss that would make the capability probe +/// cache a permanent `false` on real Aurora (42883) and silently strip the +/// instance id from canary evidence. +pub const AURORA_IDENTITY_FN: &str = "aurora_db_instance_identifier"; + +/// Whether this reader endpoint supports [`AURORA_IDENTITY_FN`] — probed +/// ONCE per process on a plain autocommit checkout, never inside a request +/// transaction (an undefined-function error would abort the transaction +/// and fail the proof). `Ok(false)` is the definitive "not Aurora" answer +/// (undefined_function, SQLSTATE 42883); transient errors surface as `Err` +/// so the caller can retry the probe on a later request instead of caching +/// a wrong answer. +pub async fn reader_supports_aurora_identity(conn: &mut PgConnection) -> Result { + match sqlx::query(sqlx::AssertSqlSafe(format!( + "SELECT {AURORA_IDENTITY_FN}()" + ))) + .fetch_one(&mut *conn) + .await + { + Ok(_) => Ok(true), + Err(sqlx::Error::Database(e)) if e.code().as_deref() == Some("42883") => Ok(false), + Err(e) => Err(e), + } +} + +/// Observe the heartbeat on a specific reader session — the +/// connection-local half of the proof. Returns the observed token/epoch +/// plus the backend identity of the session for route-decision evidence: +/// `addr:port pid=N` (`local` on unix sockets), prefixed with the Aurora +/// instance id when `aurora` is set (only pass `true` after +/// [`reader_supports_aurora_identity`] confirmed it — the function +/// reference fails at parse time on plain Postgres). `None` when the row +/// is missing there (migration not yet replayed): fail closed. +pub async fn observe_heartbeat( + conn: &mut PgConnection, + aurora: bool, +) -> Result, sqlx::Error> { + const ADDR_PID: &str = "COALESCE(host(inet_server_addr()) || ':' || \ + inet_server_port()::text, 'local') || ' pid=' || pg_backend_pid()::text"; + let sql = if aurora { + format!( + "SELECT token, epoch, {AURORA_IDENTITY_FN}() || ' @ ' || {ADDR_PID} AS backend \ + FROM replica_heartbeat WHERE id = 1" + ) + } else { + format!( + "SELECT token, epoch, {ADDR_PID} AS backend \ + FROM replica_heartbeat WHERE id = 1" + ) }; - let new_fence = lower - - chrono::Duration::seconds(CREATED_AT_FLOOR_SECS) - - chrono::Duration::seconds(FENCE_CLOCK_MARGIN_SECS); - fence.advance(new_fence); - Ok(Some(new_fence)) + let row = sqlx::query(sqlx::AssertSqlSafe(sql)) + .fetch_optional(&mut *conn) + .await?; + Ok(row.map(|r| HeartbeatObservation { + token: r.get("token"), + epoch: r.get("epoch"), + backend: r.get("backend"), + })) } -/// Background probe loop: sample every `PROBE_INTERVAL`, close the fence on -/// any error. Runs for the life of the process. -pub async fn run_probe(writer: PgPool, replica: PgPool, fence: Arc) { +/// One reader-session heartbeat observation (see [`observe_heartbeat`]). +#[derive(Debug, Clone)] +pub struct HeartbeatObservation { + /// The token the session has replayed through. + pub token: i64, + /// The epoch the session observes — must match the ring's. + pub epoch: Uuid, + /// Backend identity of the observed session, so live evidence records + /// which reader served both proof and page. + pub backend: String, +} + +/// Background probe loop: commit a heartbeat token every `PROBE_INTERVAL`; +/// close the fence on any error. Runs for the life of the process. +pub async fn run_probe(writer: PgPool, fence: Arc) { let mut interval = tokio::time::interval(PROBE_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; - match probe_once(&writer, &replica, &fence).await { - Ok(Some(_)) => {} - Ok(None) => { - // Replica behind the sample: leave the fence; staleness - // closes it if the replica stays behind. - tracing::debug!("replica fence: replay behind writer sample"); - } + match probe_once(&writer, &fence).await { + Ok(_) => {} Err(e) => { fence.close(); tracing::warn!(error = %e, "replica fence probe failed; fence closed"); @@ -515,14 +785,50 @@ mod tests { std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) } + /// A private scratch database with migrations applied: the probe tests + /// mutate the singleton heartbeat row (rewind/rotate), which must never + /// race the shared dev database or each other. + async fn scratch_db() -> (PgPool, PgPool, String) { + let admin = PgPool::connect(&test_db_url()) + .await + .expect("connect admin"); + let name = format!("fence_probe_{}", uuid::Uuid::new_v4().simple()); + sqlx::query(sqlx::AssertSqlSafe(format!("CREATE DATABASE {name}"))) + .execute(&admin) + .await + .expect("create scratch db"); + let base = test_db_url(); + let idx = base.rfind('/').expect("db url has a path segment"); + let pool = PgPool::connect(&format!("{}/{}", &base[..idx], name)) + .await + .expect("connect scratch db"); + crate::migration::run_migrations(&pool) + .await + .expect("migrate scratch db"); + (admin, pool, name) + } + + async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { + pool.close().await; + let _ = sqlx::query(sqlx::AssertSqlSafe(format!( + "DROP DATABASE IF EXISTS {name} WITH (FORCE)" + ))) + .execute(admin) + .await; + } + #[test] - fn fence_starts_closed_and_opens_on_advance() { + fn fence_starts_closed_and_opens_on_record() { let fence = ReplicaFence::new(); assert!(fence.verified_through().is_none(), "must start closed"); assert!(!fence.covers(Utc::now() - chrono::Duration::days(365))); let ts = Utc::now(); - fence.advance(ts); + let epoch = Uuid::new_v4(); + assert_eq!( + fence.record(1, epoch, Instant::now(), ts), + RecordOutcome::Recorded + ); assert_eq!(fence.verified_through(), Some(ts)); assert!(fence.covers(ts - chrono::Duration::seconds(1))); assert!(fence.covers(ts), "boundary is inclusive"); @@ -537,19 +843,116 @@ mod tests { fn stale_fence_reads_as_closed() { let fence = ReplicaFence::new(); let ts = Utc::now(); - fence - .fence_micros - .store(ts.timestamp_micros(), Ordering::Relaxed); - // Last update older than the staleness budget. - let stale = (Utc::now() - - chrono::Duration::from_std(FENCE_STALENESS).expect("duration") - - chrono::Duration::seconds(1)) - .timestamp_micros(); - fence.updated_micros.store(stale, Ordering::Relaxed); + // Newest entry committed longer ago than the staleness budget. + let stale_instant = Instant::now() - (FENCE_STALENESS + Duration::from_secs(1)); + fence.record(1, Uuid::new_v4(), stale_instant, ts); assert!( fence.verified_through().is_none(), "a fence the probe stopped confirming must read as closed" ); + // heartbeat_age is deliberately ungated (observability). + assert!(fence.heartbeat_age().expect("entry retained") > FENCE_STALENESS); + } + + /// Resolve picks the greatest retained entry <= the observed token — + /// a lagged reader proves from an older wall, never from thin air. + #[test] + fn resolve_picks_greatest_retained_token_at_or_below_observation() { + let fence = ReplicaFence::new(); + let epoch = Uuid::new_v4(); + let base = Utc::now(); + for (token, secs) in [(10i64, 0i64), (20, 10), (30, 20)] { + fence.record( + token, + epoch, + Instant::now(), + base + chrono::Duration::seconds(secs), + ); + } + + // Exact hit. + assert_eq!(fence.resolve(20, epoch).proved().expect("proof").token, 20); + // Between entries: prove from the older one. + assert_eq!(fence.resolve(25, epoch).proved().expect("proof").token, 20); + // Ahead of everything retained: newest. + assert_eq!( + fence.resolve(1000, epoch).proved().expect("proof").token, + 30 + ); + // Behind everything retained: no proof. + assert_eq!( + fence.resolve(9, epoch), + ResolveOutcome::TokenBehind, + "token below ring fails closed" + ); + // Wrong epoch: no proof, regardless of token. + assert_eq!( + fence.resolve(1000, Uuid::new_v4()), + ResolveOutcome::EpochMismatch, + "epoch mismatch fails closed" + ); + } + + /// An epoch change clears the ring and starts a new one; a same-epoch + /// token regression clears the ring and reports the fault. + #[test] + fn record_epoch_change_resets_and_same_epoch_regression_fails() { + let fence = ReplicaFence::new(); + let epoch_a = Uuid::new_v4(); + let ts = Utc::now(); + fence.record(10, epoch_a, Instant::now(), ts); + fence.record(11, epoch_a, Instant::now(), ts); + + // New epoch, lower token: fine — new timeline, old proofs dropped. + let epoch_b = Uuid::new_v4(); + assert_eq!( + fence.record(3, epoch_b, Instant::now(), ts), + RecordOutcome::Recorded + ); + assert_eq!( + fence.resolve(11, epoch_a), + ResolveOutcome::EpochMismatch, + "entries from the old epoch must be gone" + ); + assert_eq!(fence.resolve(3, epoch_b).proved().expect("proof").token, 3); + + // Same epoch, non-increasing token: regression → cleared + reported. + assert_eq!( + fence.record(3, epoch_b, Instant::now(), ts), + RecordOutcome::TokenRegression + ); + assert!( + fence.verified_through().is_none(), + "ring cleared on regression" + ); + assert_eq!( + fence.resolve(i64::MAX, epoch_b), + ResolveOutcome::TokenBehind + ); + } + + /// The ring is bounded: old entries fall off and stop proving coverage. + #[test] + fn ring_capacity_evicts_oldest_entries() { + let fence = ReplicaFence::new(); + let epoch = Uuid::new_v4(); + let ts = Utc::now(); + for token in 0..(RING_CAPACITY as i64 + 10) { + fence.record(token, epoch, Instant::now(), ts); + } + assert_eq!( + fence.resolve(5, epoch), + ResolveOutcome::TokenBehind, + "evicted tokens must no longer prove coverage" + ); + assert_eq!( + fence + .resolve(i64::MAX, epoch) + .proved() + .expect("proof") + .token, + RING_CAPACITY as i64 + 9 + ); } /// The activity scan must (a) represent another session's open @@ -582,9 +985,14 @@ mod tests { oldest <= during.sampled_at, "xact_start precedes the sample that observed it" ); - // S is captured before the activity scan, L after: the sample's - // ordering invariant. + // S is captured before the activity scan, the token commit after: + // the sample's ordering invariant. assert!(during.sampled_at >= before.sampled_at); + assert!( + during.token > before.token, + "each sample must commit a strictly newer token" + ); + assert_eq!(during.epoch, before.epoch, "epoch is stable across samples"); tx.rollback().await.expect("rollback"); } @@ -641,21 +1049,140 @@ mod tests { .expect("drop role"); } - /// A primary (non-replica) database returns NULL from - /// `pg_last_wal_replay_lsn()`; the probe must fail closed, never - /// synthesize freshness. This is also the Aurora-observability guard: - /// if the reader endpoint hides replay LSNs, routing stays writer-only. + /// The Aurora PostgreSQL identity function name is exact — the + /// MySQL-family near-miss (`aurora_server_id`) would make the + /// capability probe cache a permanent false on real Aurora and + /// silently strip the instance id from canary evidence (Wren, delta + /// review of a472327). AWS reference: aurora_db_instance_identifier() + /// (Aurora PostgreSQL user guide; also awslabs/pg-collector). + #[test] + fn aurora_identity_function_name_is_the_postgres_one() { + assert_eq!(AURORA_IDENTITY_FN, "aurora_db_instance_identifier"); + } + + /// The Aurora identity capability probe must answer a definitive + /// `false` on plain Postgres (undefined_function), not error — and the + /// error path must not poison the connection for later statements. #[tokio::test] #[ignore = "requires Postgres"] - async fn probe_fails_closed_when_replica_lsn_unavailable() { + async fn aurora_identity_probe_reports_false_on_plain_postgres() { let pool = PgPool::connect(&test_db_url()).await.expect("connect"); + let mut conn = pool.acquire().await.expect("conn"); + assert!( + !reader_supports_aurora_identity(&mut conn) + .await + .expect("probe must not error on plain postgres"), + "plain postgres must report no aurora identity support" + ); + // The failed function lookup must not have wedged the session. + let one: i32 = sqlx::query_scalar("SELECT 1") + .fetch_one(&mut *conn) + .await + .expect("connection usable after probe"); + assert_eq!(one, 1); + } + + /// End-to-end probe against a real database: each probe commits a + /// strictly newer token, records a retained entry, and a session on the + /// same database observes a token/epoch that resolves that entry. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn probe_commits_tokens_and_sessions_prove_coverage() { + let (admin, pool, name) = scratch_db().await; + let fence = ReplicaFence::new(); + + let first = probe_once(&pool, &fence).await.expect("first probe"); + let second = probe_once(&pool, &fence).await.expect("second probe"); + assert!(second.token > first.token, "tokens strictly increase"); + assert!( + second.fence_wall >= first.fence_wall + || second.fence_wall + > first.fence_wall - chrono::Duration::seconds(FENCE_CLOCK_MARGIN_SECS), + "walls advance with the clock (modulo an open transaction)" + ); + + // A "reader" session on the same database observes at least the + // second token and proves the newest retained entry. + let mut conn = pool.acquire().await.expect("reader conn"); + let obs = observe_heartbeat(&mut conn, false) + .await + .expect("observe") + .expect("heartbeat row present"); + assert!(obs.token >= second.token); + assert!( + obs.backend.contains(" pid="), + "backend identity must carry the backend pid, got {:?}", + obs.backend + ); + // TCP fixtures also carry addr:port; unix-socket fixtures read 'local'. + assert!( + obs.backend.starts_with("local pid=") || obs.backend.contains(':'), + "backend identity must carry addr:port or 'local', got {:?}", + obs.backend + ); + let proof = fence + .resolve(obs.token, obs.epoch) + .proved() + .expect("proof resolves"); + assert_eq!(proof.token, second.token, "newest retained entry cited"); + + // An epoch nobody committed proves nothing. + assert_eq!( + fence.resolve(obs.token, Uuid::new_v4()), + ResolveOutcome::EpochMismatch + ); + + drop(conn); + drop_scratch_db(&admin, pool, &name).await; + } + + /// A same-epoch token rewind on the writer (restore adversary) must not + /// leave proofs standing: the probe rotates the epoch, so a reader still + /// on the pre-rewind timeline — observing a *higher* token under the old + /// epoch — fails the epoch check instead of proving stale coverage. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn probe_rotates_epoch_on_same_epoch_token_regression() { + let (admin, pool, name) = scratch_db().await; let fence = ReplicaFence::new(); - fence.advance(Utc::now()); // pretend a previous handshake succeeded - // Using the primary as its own "replica": replay LSN is NULL. - let err = probe_once(&pool, &pool, &fence) + let before = probe_once(&pool, &fence).await.expect("probe"); + let mut conn = pool.acquire().await.expect("conn"); + let old_epoch = observe_heartbeat(&mut conn, false) + .await + .expect("observe") + .expect("row") + .epoch; + + // Rewind the token in place, keeping the epoch: the restore shape. + sqlx::query("UPDATE replica_heartbeat SET token = 0 WHERE id = 1") + .execute(&pool) + .await + .expect("rewind token"); + + let after = probe_once(&pool, &fence).await.expect("recovery probe"); + // The pre-rewind observation must no longer prove anything. + assert_eq!( + fence.resolve(before.token, old_epoch), + ResolveOutcome::EpochMismatch, + "old-epoch observations must fail closed after rotation" + ); + // A fresh observation on the new timeline proves the rotated entry. + let obs = observe_heartbeat(&mut conn, false) .await - .expect_err("NULL replay LSN must be an error"); - assert!(matches!(err, ProbeError::ReplicaLsnUnavailable)); + .expect("observe") + .expect("row"); + assert_ne!(obs.epoch, old_epoch, "epoch rotated"); + assert_eq!( + fence + .resolve(obs.token, obs.epoch) + .proved() + .expect("proof") + .token, + after.token + ); + + drop(conn); + drop_scratch_db(&admin, pool, &name).await; } } diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 3f92212dd5..007677e258 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -349,6 +349,30 @@ pub async fn get_thread_replies( depth_limit: Option, limit: u32, cursor: Option<&[u8]>, +) -> Result> { + let mut conn = pool.acquire().await?; + get_thread_replies_on( + &mut conn, + community_id, + root_event_id, + depth_limit, + limit, + cursor, + ) + .await +} + +/// [`get_thread_replies`] on a specific session — the replica-routing path +/// runs the page on the exact reader connection whose heartbeat observation +/// proved coverage (the proof is connection-local; a different pooled +/// session may sit at a different replay position). +pub(crate) async fn get_thread_replies_on( + conn: &mut sqlx::PgConnection, + community_id: CommunityId, + root_event_id: &[u8], + depth_limit: Option, + limit: u32, + cursor: Option<&[u8]>, ) -> Result> { // Decode cursor bytes -> keyset (timestamp, optional event_id) for the // WHERE condition. Layout: 8-byte BE i64 seconds, then the raw event_id. @@ -445,7 +469,7 @@ pub async fn get_thread_replies( } q = q.bind(limit as i32); - let rows = q.fetch_all(pool).await?; + let rows = q.fetch_all(&mut *conn).await?; let mut replies = Vec::with_capacity(rows.len()); for row in rows { @@ -569,6 +593,31 @@ pub async fn get_channel_window( limit: u32, cursor: Option<(DateTime, Vec)>, kind_filter: Option<&[u32]>, +) -> Result { + let mut conn = pool.acquire().await?; + get_channel_window_on( + &mut conn, + community_id, + channel_id, + limit, + cursor, + kind_filter, + ) + .await +} + +/// [`get_channel_window`] on a specific session — the replica-routing path +/// runs the page (and its participants batch) on the exact reader connection +/// whose heartbeat observation proved coverage (the proof is +/// connection-local; a different pooled session may sit at a different +/// replay position). +pub(crate) async fn get_channel_window_on( + conn: &mut sqlx::PgConnection, + community_id: CommunityId, + channel_id: Uuid, + limit: u32, + cursor: Option<(DateTime, Vec)>, + kind_filter: Option<&[u32]>, ) -> Result { let mut param_idx = 3u32; // $1 is community_id, $2 is channel_id let mut sql = String::from( @@ -637,7 +686,7 @@ pub async fn get_channel_window( // The +1 probe row is the server-internal has_more evidence. q = q.bind(limit as i64 + 1); - let mut db_rows = q.fetch_all(pool).await?; + let mut db_rows = q.fetch_all(&mut *conn).await?; let has_more = db_rows.len() > limit as usize; db_rows.truncate(limit as usize); @@ -722,7 +771,7 @@ pub async fn get_channel_window( ) .bind(community_id.as_uuid()) .bind(&roots) - .fetch_all(pool) + .fetch_all(&mut *conn) .await?; let mut by_root: std::collections::HashMap, Vec>> = diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 2e00d6bd2f..10461d8d46 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -462,9 +462,9 @@ async fn handle_channel_window_filter( .as_ref() .map(|ks| ks.iter().map(|k| k.as_u16() as u32).collect()); - let window = state + let (window, mut session) = state .db - .get_channel_window( + .get_channel_window_with_session( tenant.community(), ch_id, limit, @@ -485,7 +485,12 @@ async fn handle_channel_window_filter( // 2. Aux closure: reactions/deletions/edits targeting retained rows, plus // deletions targeting those aux events (the transitive second hop). - // One round trip for the client instead of an #e fan-out. + // One round trip for the client instead of an #e fan-out. Runs in the + // SAME request transaction that served the window: when the page came + // from a proved replica session, the heartbeat observation anchored a + // REPEATABLE READ snapshot, so the aux hops see exactly the state the + // proof covered — another pooled session (or even another autocommit + // statement) could sit at a different replay position. if extension_flag(raw, "include_aux") && !row_ids_hex.is_empty() { let mut seen_aux: std::collections::HashSet = std::collections::HashSet::new(); @@ -495,8 +500,7 @@ async fn handle_channel_window_filter( aux_query.kinds = Some(hop_kinds.iter().map(|k| *k as i32).collect()); aux_query.e_tags = Some(std::mem::take(&mut hop_ids)); aux_query.limit = Some(1000); - let aux_events = state - .db + let aux_events = session .query_events(&aux_query) .await .map_err(|e| internal_error(&format!("window aux error: {e}")))?; @@ -1083,7 +1087,8 @@ async fn query_events_authed( let type_events = match canonical { "mentions" => state .db - .query_feed_mentions( + .query_feed_mentions_routed( + "bridge_feed", tenant.community(), &pubkey_bytes, &accessible_channels, @@ -1094,7 +1099,8 @@ async fn query_events_authed( .map_err(|e| internal_error(&format!("feed mentions error: {e}")))?, "needs_action" => state .db - .query_feed_needs_action( + .query_feed_needs_action_routed( + "bridge_feed", tenant.community(), &pubkey_bytes, &accessible_channels, @@ -1105,7 +1111,13 @@ async fn query_events_authed( .map_err(|e| internal_error(&format!("feed needs_action error: {e}")))?, "activity" => state .db - .query_feed_activity(tenant.community(), &accessible_channels, since, remaining) + .query_feed_activity_routed( + "bridge_feed", + tenant.community(), + &accessible_channels, + since, + remaining, + ) .await .map_err(|e| internal_error(&format!("feed activity error: {e}")))?, _ => continue, @@ -1271,7 +1283,7 @@ async fn query_events_authed( let db = state.db.clone(); let mut catchall_results = stream::iter(catchall_queries.into_iter().map(|(idx, query)| { let db = db.clone(); - async move { (idx, db.query_events(&query).await) } + async move { (idx, db.query_events_routed("bridge_query", &query).await) } })) .buffered(crate::handlers::req::FILTER_QUERY_CONCURRENCY); @@ -1476,7 +1488,7 @@ async fn count_events_authed( && !needs_result_gated_filtering && !needs_persona_filtering { - match state.db.count_events(&query).await { + match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, Err(e) => { return Err(internal_error(&format!("count error: {e}"))); @@ -1486,7 +1498,11 @@ async fn count_events_authed( // Fallback: query + post-filter for non-pushable constraints. let mut q = query; crate::handlers::req::apply_count_fallback_limit(&mut q); - match state.db.query_events(&q).await { + match state + .db + .query_events_routed_bounded("bridge_count_fallback", &q) + .await + { Ok(stored_events) => { if crate::handlers::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); @@ -1543,7 +1559,7 @@ async fn count_events_authed( && !needs_persona_filtering { query.limit = None; - match state.db.count_events(&query).await { + match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, Err(e) => { return Err(internal_error(&format!("count error: {e}"))); @@ -1552,7 +1568,11 @@ async fn count_events_authed( } else { // Fallback: query a bounded candidate set + post-filter. crate::handlers::req::apply_count_fallback_limit(&mut query); - match state.db.query_events(&query).await { + match state + .db + .query_events_routed_bounded("bridge_count_fallback", &query) + .await + { Ok(stored_events) => { if crate::handlers::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); @@ -1725,7 +1745,7 @@ async fn handle_bridge_search( let id_refs: Vec<&[u8]> = hit_ids.iter().map(|b| b.as_slice()).collect(); let stored_events = state .db - .get_events_by_ids(tenant.community(), &id_refs) + .get_events_by_ids_routed("bridge_search_hydrate", tenant.community(), &id_refs) .await .map_err(|e| internal_error(&format!("search fetch error: {e}")))?; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index e494355736..85a0ca2efe 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -56,6 +56,10 @@ pub struct Config { /// Optional read-replica connection URL (e.g. an Aurora `cluster-ro-` /// endpoint). Unset means all reads stay on the writer. pub read_database_url: Option, + /// Replica read budget `B` in milliseconds (`BUZZ_REPLICA_READ_MAX_AGE_MS`). + /// `0` (the default) disables bounded-staleness replica routing; see + /// [`buzz_db::DbConfig::replica_read_max_age_ms`]. + pub replica_read_max_age_ms: u64, /// Redis connection URL used by the pub/sub manager. pub redis_url: String, /// Maximum connections in the shared Redis pool. Defaults to 16. @@ -72,6 +76,11 @@ pub struct Config { /// the per-pod pool and requests fail on acquire timeout while the /// database sits idle. pub db_pool_size: u32, + /// Maximum connections in the Postgres read-replica pool + /// (`BUZZ_DB_READ_POOL_SIZE`). Defaults to `db_pool_size`. Sized + /// independently so reader capacity can be tuned against the replica's + /// headroom without touching the writer pool. + pub db_read_pool_size: Option, /// Public WebSocket URL of this relay, advertised in NIP-11. pub relay_url: String, /// Public WebSocket URL of the dedicated device-pairing relay, when configured. @@ -423,6 +432,27 @@ impl Config { .map(|v| v.trim().to_string()) .filter(|v| !v.is_empty()); + // The old seconds-denominated name is a hard startup error, not an + // alias: silently honouring it would mean 1000x the intended budget. + if std::env::var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS").is_ok() { + return Err(ConfigError::InvalidValue( + "BUZZ_REPLICA_HEAD_MAX_AGE_SECS was renamed to BUZZ_REPLICA_READ_MAX_AGE_MS \ + (note: milliseconds, not seconds); refusing to start" + .to_string(), + )); + } + + // Replica read budget: 0 = off (the rollout default), so this is a + // non-negative parse, unlike `positive_u64_from_env`. + let replica_read_max_age_ms = match std::env::var("BUZZ_REPLICA_READ_MAX_AGE_MS") { + Ok(raw) => raw.trim().parse::().map_err(|_| { + ConfigError::InvalidValue( + "BUZZ_REPLICA_READ_MAX_AGE_MS must be a non-negative integer".to_string(), + ) + })?, + Err(_) => 0, + }; + let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); @@ -438,6 +468,11 @@ impl Config { .filter(|&v| v > 0) .unwrap_or(50); + let db_read_pool_size = std::env::var("BUZZ_DB_READ_POOL_SIZE") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0); + let relay_url = std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); @@ -898,9 +933,11 @@ impl Config { bind_addr, database_url, read_database_url, + replica_read_max_age_ms, redis_url, redis_pool_size, db_pool_size, + db_read_pool_size, relay_url, pairing_relay_url, max_connections, @@ -1121,6 +1158,35 @@ mod tests { assert_eq!(junk, 50, "unparsable value must fall back to the default"); } + #[test] + fn db_read_pool_size_env_override_and_invalid_fallback() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_DB_READ_POOL_SIZE"); + + std::env::remove_var("BUZZ_DB_READ_POOL_SIZE"); + let unset = Config::from_env().expect("config").db_read_pool_size; + + std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "40"); + let overridden = Config::from_env().expect("config").db_read_pool_size; + + std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "0"); + let zero = Config::from_env().expect("config").db_read_pool_size; + + std::env::set_var("BUZZ_DB_READ_POOL_SIZE", "not-a-number"); + let junk = Config::from_env().expect("config").db_read_pool_size; + + if let Some(value) = previous { + std::env::set_var("BUZZ_DB_READ_POOL_SIZE", value); + } else { + std::env::remove_var("BUZZ_DB_READ_POOL_SIZE"); + } + + assert_eq!(unset, None, "unset must inherit the writer pool sizing"); + assert_eq!(overridden, Some(40)); + assert_eq!(zero, None, "zero must fall back to inheriting"); + assert_eq!(junk, None, "unparsable value must fall back to inheriting"); + } + #[test] fn read_database_url_unset_or_blank_is_none() { let _guard = ENV_MUTEX.lock().unwrap(); @@ -1149,6 +1215,58 @@ mod tests { ); } + #[test] + fn replica_read_max_age_defaults_off_and_rejects_junk() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_REPLICA_READ_MAX_AGE_MS"); + let previous_old = std::env::var_os("BUZZ_REPLICA_HEAD_MAX_AGE_SECS"); + std::env::remove_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS"); + + std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS"); + let unset = Config::from_env().expect("config").replica_read_max_age_ms; + + std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "1000"); + let set = Config::from_env().expect("config").replica_read_max_age_ms; + + std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "0"); + let zero = Config::from_env().expect("config").replica_read_max_age_ms; + + std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "soon"); + let junk = Config::from_env(); + + // The retired seconds-denominated name must be a hard startup + // error even alongside a valid new-name value: silently ignoring + // it (or honouring it) would mean 1000x the intended budget. + std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", "1000"); + std::env::set_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS", "5"); + let old_name = Config::from_env(); + + std::env::remove_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS"); + if let Some(value) = previous { + std::env::set_var("BUZZ_REPLICA_READ_MAX_AGE_MS", value); + } else { + std::env::remove_var("BUZZ_REPLICA_READ_MAX_AGE_MS"); + } + if let Some(value) = previous_old { + std::env::set_var("BUZZ_REPLICA_HEAD_MAX_AGE_SECS", value); + } + + assert_eq!(unset, 0, "replica read routing must default off"); + assert_eq!(set, 1000); + assert_eq!(zero, 0, "explicit 0 is off"); + assert!( + junk.is_err(), + "an unparsable budget must fail loudly, not silently disable" + ); + match old_name { + Err(ConfigError::InvalidValue(message)) => assert!( + message.contains("BUZZ_REPLICA_READ_MAX_AGE_MS"), + "the error must name the replacement env var, got: {message}" + ), + other => panic!("old env name must hard-fail startup, got {other:?}"), + } + } + #[test] fn audit_logging_defaults_on_and_accepts_explicit_off() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 614e54d7a0..dfb44e152f 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -173,7 +173,7 @@ pub async fn handle_count( && !needs_result_gated_filtering && !needs_persona_filtering { - match state.db.count_events(&query).await { + match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); @@ -184,7 +184,11 @@ pub async fn handle_count( // Fallback: query + post-filter for non-pushable constraints. let mut q = query; super::req::apply_count_fallback_limit(&mut q); - match state.db.query_events(&q).await { + match state + .db + .query_events_routed_bounded("count_req_fallback", &q) + .await + { Ok(stored_events) => { if super::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); @@ -243,7 +247,7 @@ pub async fn handle_count( && !needs_persona_filtering { query.limit = None; // COUNT doesn't need a row limit - match state.db.count_events(&query).await { + match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, Err(e) => { conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); @@ -253,7 +257,11 @@ pub async fn handle_count( } else { // Fallback: query a bounded candidate set + post-filter. super::req::apply_count_fallback_limit(&mut query); - match state.db.query_events(&query).await { + match state + .db + .query_events_routed_bounded("count_req_fallback", &query) + .await + { Ok(stored_events) => { if super::req::count_fallback_exceeded(stored_events.len()) { metrics::counter!("buzz_count_fallback_rejections_total").increment(1); diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index d3ddd3e5d3..51400452d7 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -310,7 +310,7 @@ pub async fn handle_req( |(idx, per_filter_channel, params)| { let db = db.clone(); async move { - let filter_events = db.query_events(¶ms).await; + let filter_events = db.query_events_routed("req_historical", ¶ms).await; (idx, per_filter_channel, filter_events) } }, @@ -629,7 +629,7 @@ async fn handle_search_req( let id_refs: Vec<&[u8]> = hit_ids.iter().map(|b| b.as_slice()).collect(); let events = match state .db - .get_events_by_ids(tenant.community(), &id_refs) + .get_events_by_ids_routed("req_search_hydrate", tenant.community(), &id_refs) .await { Ok(evs) => evs, diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 9e6ca828e0..799cf9cf60 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -166,7 +166,9 @@ async fn main() -> anyhow::Result<()> { let db_config = DbConfig { database_url: config.database_url.clone(), read_database_url: config.read_database_url.clone(), + replica_read_max_age_ms: config.replica_read_max_age_ms, max_connections: config.db_pool_size, + read_max_connections: config.db_read_pool_size, ..DbConfig::default() }; let db = Db::new(&db_config).await.map_err(|e| { @@ -174,7 +176,11 @@ async fn main() -> anyhow::Result<()> { anyhow::anyhow!("DB connection failed: {e}") })?; if db.has_read_pool() { - info!("Postgres connected (writer + read replica)"); + info!("Postgres connected (writer + lazy read replica pool)"); + // Reader-down at boot must not crash or block the relay; this warn-only + // ping is the sole boot-time visibility that the replica is unreachable + // (the lazy pool with min_connections=0 dials nothing until first use). + db.spawn_read_pool_boot_ping(); } else { info!("Postgres connected"); } @@ -999,6 +1005,12 @@ async fn main() -> anyhow::Result<()> { metrics::gauge!("buzz_db_replica_fence_open").set(0.0); } } + // Probe liveness, ungated by staleness: how long since + // the probe last committed a heartbeat token. + if let Some(age) = pool_state.db.fence().heartbeat_age() { + metrics::gauge!("buzz_db_replica_heartbeat_age_seconds") + .set(age.as_secs_f64()); + } } let rs = pool_state.redis_pool.status(); diff --git a/migrations/0026_replica_heartbeat.sql b/migrations/0026_replica_heartbeat.sql new file mode 100644 index 0000000000..278be4d87a --- /dev/null +++ b/migrations/0026_replica_heartbeat.sql @@ -0,0 +1,38 @@ +-- Replica heartbeat: a portable read-side freshness observation for the +-- replica fence (see crates/buzz-db/src/replica_fence.rs). +-- +-- Why: the fence's ordered writer-side proof previously ended in a WAL-LSN +-- comparison (`pg_last_wal_replay_lsn() >= L`), which Aurora's reader +-- endpoints do not expose — the fence therefore never opened on Aurora, by +-- design (fail closed). This table replaces only that read-side observation: +-- the probe commits a monotonically increasing `token` AFTER the ordered +-- writer scan (clock sample -> oldest-xact guard), and a reader session that +-- observes token >= M has, by WAL/storage replay order, also replayed every +-- commit that preceded M. The commit-time floor guard (migration 0021) and +-- the writer-side scan remain load-bearing and unchanged. +-- +-- Shape: exactly one row, enforced by the CHECK'd primary key. Every relay +-- pod's probe increments the same row; the single-row UPDATE is the +-- serialization point that makes tokens globally commit-ordered, which is +-- what lets a pod prove coverage from the greatest token it retained that is +-- <= the token a reader session observes (multi-pod safety). +-- +-- `epoch` detects resets: a restore/re-seed that rolls `token` backwards +-- must never let a stale retained token masquerade as fresh coverage. +-- Readers validate the observed epoch against the epoch retained with each +-- token; a mismatch fails closed (route to writer). +-- +-- Not an events row: exempt from the created_at floor guard by construction, +-- and deliberately deployment-global (no community_id) — it describes the +-- replication topology, not tenant data. + +CREATE TABLE replica_heartbeat ( + id smallint PRIMARY KEY CHECK (id = 1), + epoch uuid NOT NULL DEFAULT gen_random_uuid(), + token bigint NOT NULL DEFAULT 0 +); + +INSERT INTO replica_heartbeat (id) VALUES (1); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data'); diff --git a/schema/schema.sql b/schema/schema.sql index f5f32cc3e3..5980695d32 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1051,3 +1051,23 @@ INSERT INTO _operator_global_tables (table_name, reason) VALUES ('push_gateway_endpoint_quotas', 'public gateway endpoint abuse ceilings span relay communities'), ('push_gateway_delivery_auth_replays', 'public gateway signed-event replay admission spans relay communities'), ('push_gateway_delivery_request_replays', 'public gateway stable request-id admission spans relay communities'); + +-- ── Replica heartbeat (read-replica freshness fence) ───────────────────────── +-- Portable read-side freshness observation for the replica fence (see +-- crates/buzz-db/src/replica_fence.rs and migrations/0026). Exactly one row; +-- the single-row token UPDATE is the serialization point that makes tokens +-- globally commit-ordered across relay pods. `epoch` detects token resets +-- (restore/re-seed) so a stale retained token can never masquerade as fresh +-- coverage. Deployment-global by design: describes replication topology, +-- never tenant data. + +CREATE TABLE replica_heartbeat ( + id smallint PRIMARY KEY CHECK (id = 1), + epoch uuid NOT NULL DEFAULT gen_random_uuid(), + token bigint NOT NULL DEFAULT 0 +); + +INSERT INTO replica_heartbeat (id) VALUES (1); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('replica_heartbeat', 'single-row replication freshness token; describes deployment topology, never tenant data'); From 3b8567a05d4c40e667d061666feb7aa7bc38212d Mon Sep 17 00:00:00 2001 From: thomaspblock Date: Thu, 30 Jul 2026 15:04:03 +0200 Subject: [PATCH 49/99] fix(desktop): remove Projects overview card fills (#3416) ## Summary The Projects overview now lets the page surface flow through its metrics and activity cards instead of stacking filled panels. Borders and hover feedback remain, preserving grouping and interaction cues without the heavy nested background. ### Related issue None found. ### Testing - `pnpm exec biome check src/features/projects/ui/ProjectsOverviewPanel.tsx src/features/projects/ui/ProjectsActivityFeed.tsx tests/e2e/project-pr-review.spec.ts` - `pnpm build:e2e` - Focused Playwright smoke test: `project overview does not paint a background behind its cards` (passed) - Relevant desktop pre-push checks passed; the unrelated integration gate was blocked by a stale local checksum for migration 25 Signed-off-by: Thomas Petersen --- .../projects/ui/ProjectsActivityFeed.tsx | 3 +- .../projects/ui/ProjectsOverviewPanel.tsx | 5 +-- desktop/tests/e2e/project-pr-review.spec.ts | 35 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index ba0c15b1c7..011b50a735 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -257,9 +257,10 @@ function ActivityCard({ return (
diff --git a/desktop/src/features/agents/ui/agentDialogRouting.test.mjs b/desktop/src/features/agents/ui/agentDialogRouting.test.mjs index 196dceeef0..15b6b7cf87 100644 --- a/desktop/src/features/agents/ui/agentDialogRouting.test.mjs +++ b/desktop/src/features/agents/ui/agentDialogRouting.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { AgentDialog } from "./AgentDialog.tsx"; import { AgentDefinitionDialog } from "./AgentDefinitionDialog.tsx"; import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog.tsx"; +import { AgentRunLocationProvider } from "./AgentRunLocationContext.tsx"; // ── Phase 1B.3c routing pinning ───────────────────────────────────────────── // @@ -54,8 +55,13 @@ test("instance-edit routes to AgentInstanceEditDialog with its contract props", open: true, }); - assert.equal(element.type, AgentInstanceEditDialog); - assert.deepEqual(element.props, { + // The arm wraps the form in the run-location provider so the respond-to + // warning can name the machine without the value being threaded as a prop + // through AgentInstanceEditDialog (see AgentRunLocationContext for why). + assert.equal(element.type, AgentRunLocationProvider); + const form = element.props.children; + assert.equal(form.type, AgentInstanceEditDialog); + assert.deepEqual(form.props, { agent, onEditLinkedPersona: undefined, onOpenChange, @@ -65,6 +71,25 @@ test("instance-edit routes to AgentInstanceEditDialog with its contract props", }); }); +test("instance-edit publishes the run location resolved from the agent backend", () => { + const routeWithBackend = (backend) => + AgentDialog({ + mode: "instance-edit", + agent: { pubkey: "abc", name: "test-agent", backend }, + onOpenChange: noop, + onUpdated: noop, + open: true, + }).props.runLocation; + + assert.equal(routeWithBackend({ type: "local" }), "local"); + assert.equal( + routeWithBackend({ type: "provider", id: "blox", config: {} }), + "remote", + ); + // An agent with no backend record has an unknown location — never a guess. + assert.equal(routeWithBackend(undefined), null); +}); + test("create mode routes to the internal create router, not a form directly", () => { const element = AgentDialog({ mode: "definition", diff --git a/desktop/src/features/agents/ui/respondToFieldContract.test.mjs b/desktop/src/features/agents/ui/respondToFieldContract.test.mjs new file mode 100644 index 0000000000..c3efd34650 --- /dev/null +++ b/desktop/src/features/agents/ui/respondToFieldContract.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +const respondToFieldSource = await readFile( + new URL("./RespondToField.tsx", import.meta.url), + "utf8", +); + +/** + * Copy assertions run against this rather than the raw source: JSX text wraps + * wherever the formatter decides, and a sentence split across lines should not + * fail a copy test. + */ +const collapsedSource = respondToFieldSource.replace(/\s+/g, " "); + +for (const label of ["Only me (default)", "Selected people", "Anyone"]) { + test(`respond-to control uses the plain-language label: ${label}`, () => { + assert.ok(respondToFieldSource.includes(`label: "${label}"`)); + }); +} + +test("native and persona controls share one option list", () => { + assert.match( + respondToFieldSource, + / \([\s\S]*
diff --git a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx index a332cb8500..b375649292 100644 --- a/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx +++ b/desktop/src/features/channels/ui/MembersSidebarMemberCard.tsx @@ -109,9 +109,9 @@ function formatRespondToLabel(agent: ManagedAgent) { case "anyone": return "Anyone"; case "allowlist": - return `Allowlist (${agent.respondToAllowlist.length})`; + return `Selected people (${agent.respondToAllowlist.length})`; default: - return "Owner only"; + return "Only me"; } } @@ -379,7 +379,7 @@ function MemberActionsMenu({ onClick={() => onEditRespondTo(managedAgent)} > - Edit respond-to... + Manage agent access... ) : null} {canRemoveMember || showChangeRole ? ( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index afcb3e863c..42c12d6123 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { AlertTriangle } from "lucide-react"; import { depthGuideActionsEqual, @@ -383,12 +384,8 @@ export const MessageRow = React.memo( const guideBleedRem = isThreadReplyLayout ? 0.25 : 0; const avatarButtonRadiusClass = "rounded-full"; - const respondToDotColor = - message.respondTo === "anyone" - ? "bg-emerald-500" - : message.respondTo === "allowlist" - ? "bg-amber-500" - : null; + const showRespondToIndicator = + message.respondTo === "anyone" || message.respondTo === "allowlist"; const avatarNode = (
@@ -399,18 +396,31 @@ export const MessageRow = React.memo( displayName={message.author} testId="message-avatar" /> - {respondToDotColor && !isThreadReplyLayout ? ( + {showRespondToIndicator && !isThreadReplyLayout ? ( - + {message.respondTo === "anyone" ? ( +
diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx index 62f4123c95..cf88919ce6 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -56,7 +56,7 @@ const AGENT_INFO_LABELS = new Set([ const AGENT_SETTINGS_LABELS = new Set([ "Runtime", "Agent profile", - "Respond to", + "Who can send instructions", "ACP command", "MCP command", "Start on launch", @@ -246,9 +246,13 @@ export function buildOwnerFields({ const fields: ProfileField[] = []; const respondTo = managedAgent?.respondTo ?? relayAgent?.respondTo ?? null; const respondToDisplayValue = respondTo - ? respondTo === "owner-only" && ownerDisplayName + ? respondTo === "owner-only" ? ownerDisplayName - : respondTo.replace(/-/g, " ") + ? `Only ${ownerDisplayName} (owner)` + : "Only the owner" + : respondTo === "allowlist" + ? "Selected people" + : "Anyone" : null; const ownerClickable = Boolean(onOpenProfile && ownerProfilePubkey); @@ -386,7 +390,7 @@ export function buildOwnerFields({ fields.push({ displayValue: respondToDisplayValue, icon: Ear, - label: "Respond to", + label: "Who can send instructions", testId: "user-profile-respond-to", }); } diff --git a/desktop/src/features/settings/ui/SendFeedbackDialog.tsx b/desktop/src/features/settings/ui/SendFeedbackDialog.tsx index a0a64da8a9..fa81c34f3a 100644 --- a/desktop/src/features/settings/ui/SendFeedbackDialog.tsx +++ b/desktop/src/features/settings/ui/SendFeedbackDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; +import { useMediaProxyPort } from "@/shared/lib/useMediaProxyPort"; import { Button } from "@/shared/ui/button"; import { Checkbox } from "@/shared/ui/checkbox"; import { @@ -83,6 +84,7 @@ export function SendFeedbackDialog({ open: boolean; }) { const { burstEmoji } = useEmojiBurst(); + useMediaProxyPort(); const resolvedAttachedImageUrl = attachedImageUrl ? rewriteRelayUrl(attachedImageUrl) : null; diff --git a/desktop/src/shared/lib/mediaUrl.test.mjs b/desktop/src/shared/lib/mediaUrl.test.mjs index 9a293e8b3e..96c004ec56 100644 --- a/desktop/src/shared/lib/mediaUrl.test.mjs +++ b/desktop/src/shared/lib/mediaUrl.test.mjs @@ -27,6 +27,42 @@ test("mediaProxyUrl: uses the IPv4 loopback literal for the localhost proxy", () ); }); +test("media-proxy port store: resolved port publishes and reset notifies subscribers", async () => { + const previousWindow = globalThis.window; + let notifications = 0; + + globalThis.window = { + __TAURI_INTERNALS__: { + invoke(command) { + if (command === "get_media_proxy_port") return Promise.resolve(54321); + if (command === "get_relay_http_url") { + return Promise.resolve("https://relay.example"); + } + return Promise.reject(new Error(`Unexpected command: ${command}`)); + }, + }, + }; + + try { + const mediaUrl = await import(`./mediaUrl.ts?portStore=${Date.now()}`); + const unsubscribe = mediaUrl.subscribeMediaProxyPort(() => notifications++); + + mediaUrl.rewriteRelayUrl(`https://relay.example/media/${HASH}.png`); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(mediaUrl.getCachedMediaProxyPort(), 54321); + assert.equal(notifications, 1); + + mediaUrl.resetMediaCaches(); + assert.equal(mediaUrl.getCachedMediaProxyPort(), null); + assert.equal(notifications, 2); + + unsubscribe(); + } finally { + globalThis.window = previousWindow; + } +}); + test("relay-origin store: publishes are canonicalized at the store boundary", () => { // The store must hold the invariant that `cachedRelayOrigin` is always a // canonical URL origin — consumers compare `new URL(src).origin` against it diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index 8875920ef2..238ef403eb 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -63,10 +63,17 @@ let cacheGeneration = 0; /** `useSyncExternalStore` listeners for relay-origin changes. */ const relayOriginListeners = new Set<() => void>(); +/** `useSyncExternalStore` listeners for media-proxy port changes. */ +const mediaProxyPortListeners = new Set<() => void>(); + function notifyRelayOriginListeners(): void { for (const listener of relayOriginListeners) listener(); } +function notifyMediaProxyPortListeners(): void { + for (const listener of mediaProxyPortListeners) listener(); +} + /** * Publish a resolved relay origin, but only if `generation` is still current * (the fetch wasn't superseded by a workspace switch). The stored snapshot is @@ -108,6 +115,18 @@ export function subscribeRelayOrigin(listener: () => void): () => void { }; } +/** + * Subscribe to media-proxy port changes. Returns a stable unsubscribe function + * for `useSyncExternalStore` consumers that need to re-run `rewriteRelayUrl` + * once the async Tauri proxy lookup has resolved. + */ +export function subscribeMediaProxyPort(listener: () => void): () => void { + mediaProxyPortListeners.add(listener); + return () => { + mediaProxyPortListeners.delete(listener); + }; +} + const POLL_INTERVAL_MS = 100; const POLL_TIMEOUT_MS = 5000; @@ -188,7 +207,10 @@ async function fetchProxyPort(): Promise { deadline, ); if (port !== null && port > 0 && generation === cacheGeneration) { - cachedPort = port; + if (cachedPort !== port) { + cachedPort = port; + notifyMediaProxyPortListeners(); + } } } catch { // invoke failed (e.g. Tauri IPC not ready yet) — keep retrying @@ -227,8 +249,12 @@ if (typeof window !== "undefined") { */ export function resetMediaCaches(): void { cacheGeneration += 1; + const hadCachedPort = cachedPort !== null; cachedPort = null; portPromise = null; + if (hadCachedPort) { + notifyMediaProxyPortListeners(); + } if (cachedRelayOrigin !== null) { cachedRelayOrigin = null; notifyRelayOriginListeners(); @@ -246,6 +272,14 @@ export function getCachedRelayOrigin(): string | null { return cachedRelayOrigin; } +/** + * The localhost proxy port if it has been resolved, else `null`. Synchronous + * best-effort read of the same cache `rewriteRelayUrl` uses. + */ +export function getCachedMediaProxyPort(): number | null { + return cachedPort; +} + /** * Build the local proxy URL with an IPv4 literal. The Rust proxy binds * `127.0.0.1:0`, not `::1`, and some WebViews resolve `localhost` to IPv6 diff --git a/desktop/src/shared/lib/useMediaProxyPort.ts b/desktop/src/shared/lib/useMediaProxyPort.ts new file mode 100644 index 0000000000..4f4e756984 --- /dev/null +++ b/desktop/src/shared/lib/useMediaProxyPort.ts @@ -0,0 +1,17 @@ +import * as React from "react"; + +import { getCachedMediaProxyPort, subscribeMediaProxyPort } from "./mediaUrl"; + +/** + * The resolved localhost media-proxy port, re-rendering when it resolves or + * changes. Components that call `rewriteRelayUrl` during render can subscribe + * to this so an initial `buzz-media://` fallback is replaced by the loopback + * proxy URL as soon as the Tauri backend reports the port. + */ +export function useMediaProxyPort(): number | null { + return React.useSyncExternalStore( + subscribeMediaProxyPort, + getCachedMediaProxyPort, + () => null, + ); +} diff --git a/desktop/tests/e2e/agent-access-warning.spec.ts b/desktop/tests/e2e/agent-access-warning.spec.ts new file mode 100644 index 0000000000..709c5fbbc6 --- /dev/null +++ b/desktop/tests/e2e/agent-access-warning.spec.ts @@ -0,0 +1,205 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/agent-access-warning"; + +async function choosePersonaAccess( + page: import("@playwright/test").Page, + optionName: string, +) { + await page.locator("#agent-respond-to").click(); + await page.getByRole("menuitemradio", { name: optionName }).click(); +} + +async function openAgentAccessDialog( + page: import("@playwright/test").Page, + agentPubkey: string, +) { + if (!(await page.getByTestId("members-sidebar").isVisible())) { + await page.getByTestId("channel-general").click(); + await page.getByTestId("channel-members-trigger").click(); + await expect(page.getByTestId("members-sidebar")).toBeVisible(); + } + + const row = page.getByTestId(`sidebar-member-${agentPubkey}`); + const menu = page.getByTestId(`sidebar-member-menu-${agentPubkey}`); + await row.hover(); + await menu.focus(); + await menu.press("Enter"); + await page.getByTestId(`sidebar-edit-respond-to-${agentPubkey}`).click(); + + await expect( + page.getByRole("dialog", { name: "Manage agent access" }), + ).toBeVisible(); +} + +test("open agent access explains the available access before save", async ({ + page, +}) => { + const agent = TEST_IDENTITIES.charlie; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Hack Day Helper", + status: "running", + channelNames: ["general"], + respondTo: "owner-only", + }, + ], + }); + await page.goto("/"); + await openAgentAccessDialog(page, agent.pubkey); + + const accessSelect = page.getByTestId("agent-respond-to-select"); + await expect(accessSelect).toHaveValue("owner-only"); + await expect(page.getByTestId("agent-access-warning")).toHaveCount(0); + const saveAccess = page.getByRole("button", { name: "Save access" }); + await expect(saveAccess).toBeVisible(); + + const commandsBeforeSave = await page.evaluate( + () => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0, + ); + await accessSelect.selectOption("anyone"); + const warning = page.getByTestId("agent-access-warning"); + await expect(warning).toBeVisible(); + await expect(warning).toContainText( + "Anyone can use this agent to access your computer, including files, accounts, and connected tools.", + ); + + await waitForAnimations(page); + await page + .getByRole("dialog", { name: "Manage agent access" }) + .screenshot({ path: `${SHOTS}/open-access-warning.png` }); + + await saveAccess.click(); + await expect( + page.getByRole("dialog", { name: "Manage agent access" }), + ).not.toBeVisible(); + await expect + .poll(async () => + page.evaluate((start) => { + const commands = window.__BUZZ_E2E_COMMAND_LOG__ ?? []; + return commands + .slice(start) + .some( + (entry) => + entry.command === "update_managed_agent" && + (entry.payload as { input?: { respondTo?: string } })?.input + ?.respondTo === "anyone", + ); + }, commandsBeforeSave), + ) + .toBe(true); + + await openAgentAccessDialog(page, agent.pubkey); + await expect(accessSelect).toHaveValue("anyone"); + // Selected people narrows the audience but not the access, so the warning + // persists with its own audience phrase. + await accessSelect.selectOption("allowlist"); + await expect(warning).toBeVisible(); + await expect(warning).toContainText( + "Selected people can use this agent to access your computer, including files, accounts, and connected tools.", + ); + const picker = page.getByTestId("agent-respond-to-allowlist"); + await expect( + picker.getByText("Selected people", { exact: true }), + ).toBeVisible(); + + // The warning sits below the picker so it never blocks the selection the + // user came here to make. + await waitForAnimations(page); + const pickerBox = await picker.boundingBox(); + const warningBox = await warning.boundingBox(); + expect(pickerBox?.y).toBeDefined(); + expect(warningBox?.y).toBeGreaterThan(pickerBox?.y ?? 0); + await page + .getByRole("dialog", { name: "Manage agent access" }) + .screenshot({ path: `${SHOTS}/selected-people-warning.png` }); + + // Only me shares nothing, so the warning goes away entirely. + await accessSelect.selectOption("owner-only"); + await expect(warning).toHaveCount(0); +}); + +test("a provider-backed agent's warning names the server, not this computer", async ({ + page, +}) => { + const agent = TEST_IDENTITIES.charlie; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Remote Helper", + status: "running", + channelNames: ["general"], + respondTo: "owner-only", + backend: { type: "provider", id: "blox", config: {} }, + }, + ], + }); + await page.goto("/"); + await openAgentAccessDialog(page, agent.pubkey); + + await page.getByTestId("agent-respond-to-select").selectOption("anyone"); + const warning = page.getByTestId("agent-access-warning"); + await expect(warning).toContainText( + "Anyone can use this agent to access the server it runs on, including any accounts and tools available there.", + ); + // The local wording must not leak into a remote-backed agent. + await expect(warning).not.toContainText("your computer"); +}); + +test("persona-backed edit warns before saving open access", async ({ + page, +}) => { + const agent = TEST_IDENTITIES.tyler; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Tyler Agent", + status: "stopped", + channelNames: ["agents"], + respondTo: "owner-only", + }, + ], + }); + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + await page.getByRole("button", { name: "Tyler Agent agent profile" }).click(); + await page.getByTestId("user-profile-edit-agent").click(); + + const dialog = page.getByTestId("edit-agent-dialog"); + await expect(dialog).toBeVisible(); + await expect(page.locator("#agent-respond-to")).toHaveText( + "Only me (default)", + ); + await choosePersonaAccess(page, "Anyone"); + await expect(dialog.getByTestId("agent-access-warning")).toContainText( + "Anyone can use this agent to access your computer, including files, accounts, and connected tools.", + ); + + const commandsBeforeSave = await page.evaluate( + () => window.__BUZZ_E2E_COMMAND_LOG__?.length ?? 0, + ); + await page.getByTestId("edit-agent-dialog-submit").click(); + await expect(dialog).not.toBeVisible(); + await expect + .poll(async () => + page.evaluate((start) => { + const commands = window.__BUZZ_E2E_COMMAND_LOG__ ?? []; + return commands + .slice(start) + .some( + (entry) => + entry.command === "update_managed_agent" && + (entry.payload as { input?: { respondTo?: string } })?.input + ?.respondTo === "anyone", + ); + }, commandsBeforeSave), + ) + .toBe(true); +}); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 596f9b0d40..3b71ff9dd8 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1021,7 +1021,7 @@ test("declared owner sees runtime tab for a remote relay agent", async ({ "Goose", ); await expect(panel.getByTestId("user-profile-respond-to")).toContainText( - "anyone", + "Anyone", ); // Declared ownership grants read visibility only; local-management write UI From 310df2ec33fbb075edf226ba18bf9a96d90ba81b Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 08:04:49 -0600 Subject: [PATCH 52/99] desktop: restore direct community member adds (#3634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - restore direct community member adds in **Settings → Invites** - keep one consolidated entry point: the dialog now supports both adding someone directly and sharing an invite link - accept npub or 64-character hex keys while preserving role hierarchy (owners: Member/Admin; admins: Member only) - verify an npub direct-add publishes the decoded hex key in a kind `9030` NIP-IA event ## Why The Invites consolidation left `AddMemberDialog` without a live mount point, so the existing direct-add capability disappeared even though its mutation path still existed. This reuses that implementation rather than introducing a second one. ## Before and after | Before | After | | --- | --- | | The consolidated dialog only offered a share link; there was no direct-add path. | The same dialog now presents direct add and share-link controls as one invitation flow. | | ![Before: invite dialog with share-link controls only](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3634/before-invite-dialog.png) | ![After: polished community invite dialog with direct-add and share-link sections](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3634/invite-dialog-polished.png) |
Before: Invites page entry point ![Before: Invites settings page](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3634/before-invites-page.png)
## Verification - `pnpm --dir desktop build:e2e` - `pnpm exec playwright test tests/e2e/invites-settings-screenshots.spec.ts --project=smoke` — 4 passed - targeted Biome check on modified files - push hook: Desktop check and 3,783 Desktop tests passed - GitHub CI green except Desktop E2E Relay still running at last status snapshot --------- Signed-off-by: Joah Gerstenberg Signed-off-by: Wes Signed-off-by: kenny lopez Co-authored-by: Joah Gerstenberg Co-authored-by: Amp Co-authored-by: Carl Co-authored-by: kenny lopez --- .../channels/ui/ChannelMemberInviteCard.tsx | 65 +- .../community-members/ui/AddMemberDialog.tsx | 562 ++++++++++++++---- .../ui/CommunityInviteDialog.tsx | 22 +- .../ui/CommunityMembersSettingsCard.tsx | 1 + .../ui/InviteLinkSection.tsx | 4 +- .../profile/ui/SelectedRecipientChip.tsx | 2 +- desktop/src/shared/lib/nostrUtils.ts | 28 + .../src/shared/lib/parsePubkeyInput.test.mjs | 50 ++ desktop/tests/e2e/community-rail.spec.ts | 1 + desktop/tests/e2e/invite-link-copy.spec.ts | 3 + .../e2e/invites-settings-screenshots.spec.ts | 152 ++++- 11 files changed, 729 insertions(+), 161 deletions(-) create mode 100644 desktop/src/shared/lib/parsePubkeyInput.test.mjs diff --git a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx index 63ab164ea4..dd370e8c61 100644 --- a/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx +++ b/desktop/src/features/channels/ui/ChannelMemberInviteCard.tsx @@ -1,6 +1,7 @@ import { Search, UserPlus, X } from "lucide-react"; import * as React from "react"; +import { parsePubkeyInput } from "@/shared/lib/nostrUtils"; import { truncatePubkey } from "@/shared/lib/pubkey"; import { PubKey } from "@/shared/ui/PubKey"; import { useIsArchivedPredicate } from "@/features/identity-archive/hooks"; @@ -105,6 +106,35 @@ export function ChannelMemberInviteCard({ ], ); + // Someone without a kind:0 profile on the relay is invisible to user + // search — let the caller paste their npub or hex pubkey directly instead. + const directInvitee = React.useMemo(() => { + const pubkey = parsePubkeyInput(deferredInviteQuery); + if ( + pubkey === null || + memberPubkeys.has(pubkey) || + selectedInviteePubkeys.has(pubkey) || + (userSearchQuery.data ?? []).some( + (user) => user.pubkey.toLowerCase() === pubkey, + ) + ) { + return null; + } + return { + pubkey, + displayName: null, + avatarUrl: null, + nip05Handle: null, + ownerPubkey: null, + isAgent: false, + }; + }, [ + deferredInviteQuery, + memberPubkeys, + selectedInviteePubkeys, + userSearchQuery.data, + ]); + React.useEffect(() => { if (!open) { setInviteQuery(""); @@ -161,7 +191,7 @@ export function ChannelMemberInviteCard({ disabled={isPending} id="channel-management-search-users" onChange={(event) => setInviteQuery(event.target.value)} - placeholder="Search people and agents" + placeholder="Search people, or paste a public key" value={inviteQuery} /> @@ -217,12 +247,41 @@ export function ChannelMemberInviteCard({ ) : null} {deferredInviteQuery.length > 0 ? (
- {userSearchQuery.isLoading ? ( + {userSearchQuery.isLoading && !directInvitee ? (

Searching…

- ) : inviteSearchResults.length > 0 ? ( + ) : inviteSearchResults.length > 0 || directInvitee ? (
+ {directInvitee ? ( + + ) : null} {inviteSearchResults.map((result) => ( + + event.preventDefault()} + sideOffset={4} + style={{ minWidth: "13rem" }} + > + + setRole(value as RelayMemberRole) + } + value={role} + > + {roleOptions.map((option) => ( + + {option.label} + + ))} + + + + + ) : null} + +
+
+ + event.preventDefault()} + onOpenAutoFocus={(event) => event.preventDefault()} + sideOffset={6} + > +
+ {userSearchQuery.isLoading ? ( +

+ Searching… +

+ ) : searchResults.length > 0 || directResult ? ( + <> + {directResult ? ( + selectUser(directResult)} + user={directResult} + /> + ) : null} + {searchResults.map((user) => ( + selectUser(user)} + user={user} + /> + ))} + + ) : ( +

+ No people found. Paste a full npub or hex public key to add + someone directly. +

+ )} +
+
+ + + {selectedUsers.length > 0 ? ( + + + + ) : null} + + + {isAlreadyMember ? ( +

+ This person is already a community member. +

+ ) : null} + {userSearchQuery.error instanceof Error ? ( +

+ {userSearchQuery.error.message} +

+ ) : null} + + + {addMutation.error instanceof Error ? ( +

+ {addMutation.error.message} +

+ ) : null} + + ); +} + +function SearchResult({ + onSelect, + user, +}: { + onSelect: () => void; + user: UserSearchResult; +}) { + const name = formatSearchUserName(user); + const isDirectPubkey = user.displayName === null && user.nip05Handle === null; + + return ( + + ); +} + +export function AddMemberDialog({ + isOwner, + open, + onOpenChange, +}: { + isOwner: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + Add member - Add a user to this relay by their public key. + Add a person to this community by their public key. - -
{ - e.preventDefault(); - handleAdd(); - }} - > -
-
-
- - setPubkey(e.target.value)} - placeholder="64-character hex pubkey" - spellCheck={false} - value={pubkey} - /> - {pubkey.trim().length > 0 && !isValidPubkey ? ( -

- Must be exactly 64 lowercase hex characters. -

- ) : null} - {isAlreadyMember ? ( -

- This pubkey is already a relay member. -

- ) : null} -
- -
-

Role

-
- {ROLE_OPTIONS.filter( - (opt) => isOwner || opt.value === "member", - ).map((opt) => ( - - ))} -
-
- - {addMutation.error instanceof Error ? ( -

- {addMutation.error.message} -

- ) : null} -
-
- -
- - -
-
+
+ onOpenChange(false)} + /> +
diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx index 9daca590f4..c5cabc26a6 100644 --- a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx +++ b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx @@ -7,20 +7,21 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { DirectAddMemberForm } from "./AddMemberDialog"; import { DEFAULT_INVITE_TTL_SECS, InviteLinkSection, } from "./InviteLinkSection"; export function CommunityInviteDialog({ + isOwner, onOpenChange, open, }: { + isOwner: boolean; onOpenChange: (open: boolean) => void; open: boolean; }) { - // Email delivery is not available yet, so the modal only mints shareable - // invite links through the relay's existing invite flow. const [ttlSecs, setTtlSecs] = React.useState(DEFAULT_INVITE_TTL_SECS); React.useEffect(() => { @@ -36,11 +37,24 @@ export function CommunityInviteDialog({ Invite to community - Anyone with this link can join this community. + Add someone directly or share a link they can use to join. - +
+ +
+ +
+

+ Link settings +

+ +
); diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 9097f35387..9a24171316 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -370,6 +370,7 @@ export function CommunityMembersSettingsCard({ diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index dc0735c85e..05b4687289 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -84,8 +84,8 @@ export function InviteLinkSection({ } return ( -
-
+
+
Expires after diff --git a/desktop/src/features/profile/ui/SelectedRecipientChip.tsx b/desktop/src/features/profile/ui/SelectedRecipientChip.tsx index 18bf6d3b8b..bfc2f43167 100644 --- a/desktop/src/features/profile/ui/SelectedRecipientChip.tsx +++ b/desktop/src/features/profile/ui/SelectedRecipientChip.tsx @@ -46,7 +46,7 @@ export function SelectedRecipientChip({ user: UserSearchResult; }) { return ( -
+
- {runningAgentCount > 0 ? ( + <> +
- ) : null} -
+ {runningAgentCount > 0 ? ( + + ) : null} +
+ + + + + + + { + openAiDefaults(compactActionsTriggerRef.current); + }} + > + + {hasSavedAgentDefaults + ? "Agent defaults" + : "Set agent defaults"} + + {runningAgentCount > 0 ? ( + { + void agents.handleBulkStopRunning(); + }} + > + + Stop running agents + + ) : null} + + + } description="Set up and manage your agents." title="Agents" /> -
+
diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index f974081b7d..c5a7a078b3 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -20,9 +20,9 @@ import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { SectionHeader } from "@/shared/ui/PageHeader"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { TeamIdentityCard } from "./TeamIdentityCard"; +import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection"; const TEAM_CARD_COLUMN_CLASS = "w-full"; -const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; type TeamsSectionProps = { teams: AgentTeam[]; @@ -63,7 +63,7 @@ export function TeamsSection({
{isLoading ? ( -
+
+
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); const missingPersonaCount = resolution.missingPersonaCount; diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 9bbe3feef7..19a5ef1171 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -68,7 +68,7 @@ type UnifiedAgentsSectionProps = { const AGENT_CARD_COLUMN_CLASS = "w-full"; export const AGENT_CARD_GRID_COLUMNS_CLASS = "grid-cols-[repeat(auto-fill,minmax(220px,240px))]"; -const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3`; +export const IDENTITY_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3 [@container(max-width:40rem)]:justify-center`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { @@ -153,7 +153,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { {!isLoading ? (
-
+
{groups.map((group) => { const profileAgent = pickProfileAgent(group.agents); return ( @@ -479,7 +479,7 @@ function NewAgentCard({ function LoadingSkeleton() { return ( -
+
({agents.length}) {!isCollapsed ? ( -
+
{agents.map((agent) => ( cards.map((card) => { const box = card.getBoundingClientRect(); - return { right: box.right, top: box.top }; + return { left: box.left, right: box.right, top: box.top }; }), ); const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top)); @@ -425,12 +425,16 @@ test("the new agent card offers create, discover, and import", async ({ .filter(({ top }) => Math.abs(top - firstRowTop) < 1) .map(({ right }) => right), ); + const leftmostFirstRowCard = Math.min( + ...cardBoxes + .filter(({ top }) => Math.abs(top - firstRowTop) < 1) + .map(({ left }) => left), + ); expect(headerBox).not.toBeNull(); - expect( - Math.abs( - (headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard, - ), - ).toBeLessThan(1); + expect(Math.abs((headerBox?.x ?? 0) - leftmostFirstRowCard)).toBeLessThan(1); + expect(rightmostFirstRowCard).toBeLessThanOrEqual( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0) + 1, + ); await newAgentCard.click(); await expect( @@ -492,6 +496,63 @@ test("the new team card offers create and import", async ({ page }) => { ).toBeVisible(); }); +test("team cards follow the agents grid alignment at compact widths", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:team-layout", + displayName: "Team layout agent", + systemPrompt: "A test agent for team layout alignment.", + }, + ], + teams: [ + { + id: "team-layout", + name: "Team layout", + personaIds: ["custom:team-layout"], + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const agentsContent = page.getByTestId("agents-page-content"); + const firstAgentCard = page.getByTestId( + "persona-agent-row-custom:team-layout", + ); + const firstTeamCard = page.getByTestId("team-card-team-layout"); + const agentGrid = firstAgentCard.locator("xpath=.."); + const teamGrid = firstTeamCard.locator("xpath=.."); + const firstAgentGridCard = agentGrid.locator(":scope > *").first(); + const firstTeamGridCard = teamGrid.locator(":scope > *").first(); + + await agentsContent.evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + const wideAgentBox = await firstAgentGridCard.boundingBox(); + const wideTeamBox = await firstTeamGridCard.boundingBox(); + expect(wideAgentBox).not.toBeNull(); + expect(wideTeamBox).not.toBeNull(); + expect(Math.abs((wideAgentBox?.x ?? 0) - (wideTeamBox?.x ?? 0))).toBeLessThan( + 1, + ); + + await agentsContent.evaluate((element) => { + (element as HTMLElement).style.width = "600px"; + }); + await expect + .poll(async () => (await firstAgentGridCard.boundingBox())?.x ?? 0) + .toBeGreaterThan(wideAgentBox?.x ?? 0); + + const compactAgentBox = await firstAgentGridCard.boundingBox(); + const compactTeamBox = await firstTeamGridCard.boundingBox(); + expect( + Math.abs((compactAgentBox?.x ?? 0) - (compactTeamBox?.x ?? 0)), + ).toBeLessThan(1); +}); + test("team cards use the thread-style overlapping avatar stack", async ({ page, }) => { @@ -634,6 +695,62 @@ test("unconfigured agent defaults use the setup label", async ({ page }) => { ); }); +test("moves agent actions into an overflow menu in a narrow view", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:compact-actions", + displayName: "Compact actions agent", + isActive: true, + systemPrompt: "A test agent for compact header actions.", + }, + ], + managedAgents: [ + { + name: "Compact actions instance", + personaId: "custom:compact-actions", + pubkey: "cd".repeat(32), + status: "running", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + + await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + await expect( + page.getByText("Set up and manage your agents.", { exact: true }), + ).toHaveJSProperty("scrollHeight", 24); + + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "600px"; + }); + await expect(page.getByTestId("agent-defaults-button")).toBeHidden(); + await page.getByTestId("agent-actions-menu-trigger").click(); + await expect( + page.getByRole("menuitem", { name: "Set agent defaults" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Stop running agents" }), + ).toBeVisible(); + + await page.getByRole("menuitem", { name: "Set agent defaults" }).click(); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toBeVisible(); + + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toHaveCount(0); + await expect(page.getByTestId("agent-defaults-button")).toBeFocused(); +}); + test("agent catalog chooser order stays stable when selection changes", async ({ page, }) => { From c9aa55505c544c608ff71648bbfd21b235637f19 Mon Sep 17 00:00:00 2001 From: Matthew Beckley Date: Thu, 30 Jul 2026 11:19:42 -0400 Subject: [PATCH 58/99] desktop: enable getUserMedia in the Linux WebKitGTK webview (#3607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microphone/camera capture works on macOS (WKWebView) and Windows (WebView2) but fails on Linux with `NotAllowedError`. WebKitGTK ships with `enable-media-stream` off and a default `permission-request` handler that denies every request. This reaches the underlying `webkit2gtk::WebView` from `on_webview_ready` and enables `enable-media-stream`, then installs a **deny-by-default** `permission-request` handler: a `UserMedia` request is allowed only from a trusted app origin (`tauri://localhost` in prod, the Vite dev origin in debug) **and** when it targets an audio/video device — everything else is denied. No-op on macOS/Windows. - `webkit2gtk` is pinned to the version wry already uses (`=2.0.2`) so there's a single shared copy of the native binding. --------- Signed-off-by: Beckley --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 4 + desktop/src-tauri/src/lib.rs | 6 ++ desktop/src-tauri/src/linux_media.rs | 141 +++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 desktop/src-tauri/src/linux_media.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d1b11b2896..7e23289f53 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1087,6 +1087,7 @@ dependencies = [ "url", "user-idle", "uuid", + "webkit2gtk", "window-vibrancy", "windows-sys 0.61.2", "zeroize", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 90f90870f6..b5b1191852 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -40,6 +40,10 @@ keyring = { version = "3.6.3", default-features = false, features = ["sync-secre # connection is dropped, which the plugin does immediately. Default features # keep the pure-Rust zbus backend, matching the plugin (no libdbus needed). notify-rust = "4" +# Enable getUserMedia in the WebKitGTK webview (see src/linux_media.rs). Pinned +# to the exact version wry links so both resolve to one webkit2gtk-sys and we +# don't get duplicate symbols; bump in lockstep with wry. +webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.4", default-features = false } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35ba41bad4..c005d511e6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod deep_link; mod event_sync; mod events; mod huddle; +mod linux_media; mod managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -199,6 +200,11 @@ pub fn run() { return; } + // Linux/WebKitGTK needs media-stream settings and a + // permission-request handler for getUserMedia; no-op + // on macOS/Windows. + linux_media::enable_media_capture(&webview); + // macOS applies the restored geometry asynchronously. Wait // for several identical outer bounds and for React to // commit the startup surface before revealing it. diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs new file mode 100644 index 0000000000..c768e15422 --- /dev/null +++ b/desktop/src-tauri/src/linux_media.rs @@ -0,0 +1,141 @@ +//! Linux-only: enable media capture (`getUserMedia`) in the WebKitGTK webview. +//! +//! On macOS (WKWebView) and Windows (WebView2) the media-permission prompt is +//! routed to the OS automatically, so microphone/camera capture "just works". +//! WebKitGTK is different on two counts, and both must be handled or capture +//! fails on Linux only: +//! +//! * `enable-media-stream` is **off by default**, so `navigator.mediaDevices` +//! never exposes a working `getUserMedia`; and +//! * the default `permission-request` handler **denies every request**, so even +//! with media-stream on, the call rejects with `NotAllowedError`. +//! +//! This module reaches the underlying `webkit2gtk::WebView` via +//! [`tauri::Webview::with_webview`], enables media-stream, and installs a +//! `permission-request` handler that is **deny-by-default**: a `UserMedia` +//! request is allowed only when it comes from a trusted app origin and asks for +//! an audio and/or video device. Tauri does not restrict navigation by default, +//! so without the origin check any document that ended up in this webview would +//! inherit silent mic/camera access for the process lifetime. +//! +//! Buzz's AppImage pins `GDK_BACKEND=x11` (see [`crate::webkit_rendering`]), +//! which is the backend WebKitGTK media capture is reliable on. + +/// The origin Tauri serves the packaged app from on Linux. +const PROD_ORIGIN: &str = "tauri://localhost"; + +/// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort` +/// 1420 in `vite.config.ts`). Only trusted in debug builds. +#[cfg(debug_assertions)] +const DEV_ORIGIN: &str = "http://localhost:1420"; + +/// Whether `uri` (the webview's current document URI) is a trusted app origin +/// allowed to use mic/camera. Matches the origin exactly or as a path prefix so +/// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip +/// through. Pure and platform-independent so it can be unit-tested everywhere. +fn is_trusted_media_origin(uri: &str) -> bool { + fn matches(uri: &str, origin: &str) -> bool { + uri == origin + || uri + .strip_prefix(origin) + .is_some_and(|rest| rest.starts_with('/')) + } + + if matches(uri, PROD_ORIGIN) { + return true; + } + #[cfg(debug_assertions)] + if matches(uri, DEV_ORIGIN) { + return true; + } + false +} + +/// Enable microphone/camera capture for `webview` if it is running on +/// WebKitGTK. A no-op on every non-Linux target, so callers can invoke it +/// unconditionally from shared startup code. +#[cfg(target_os = "linux")] +pub fn enable_media_capture(webview: &tauri::Webview) { + use webkit2gtk::{ + glib::prelude::Cast, PermissionRequestExt, SettingsExt, UserMediaPermissionRequest, + UserMediaPermissionRequestExt, WebViewExt, + }; + + // `with_webview` runs the closure on the UI thread, which GTK calls + // require. It errors only if the platform webview is unavailable. + let result = webview.with_webview(|platform_webview| { + // On Linux this is the underlying `webkit2gtk::WebView`. + let webview = platform_webview.inner(); + + if let Some(settings) = WebViewExt::settings(&webview) { + settings.set_enable_media_stream(true); + } + + // Deny-by-default: allow only mic/camera requests from a trusted app + // origin; deny everything else (still returning `true` so WebKit's + // auto-deny default does not also run). Non-`UserMedia` requests return + // `false` and keep their default handling. + webview.connect_permission_request(|wv, request| { + let Some(request) = request.downcast_ref::() else { + return false; + }; + + let uri = wv.uri().map(|u| u.to_string()).unwrap_or_default(); + let for_device = request.is_for_audio_device() || request.is_for_video_device(); + + if for_device && is_trusted_media_origin(&uri) { + request.allow(); + } else { + request.deny(); + } + true + }); + }); + + if let Err(error) = result { + eprintln!("buzz-desktop: could not enable WebKitGTK media capture: {error}"); + } +} + +/// No-op stub so shared startup code can call [`enable_media_capture`] on every +/// platform. macOS and Windows route media permissions through the OS. +#[cfg(not(target_os = "linux"))] +pub fn enable_media_capture(_webview: &tauri::Webview) {} + +#[cfg(test)] +mod tests { + use super::is_trusted_media_origin; + + #[test] + fn allows_production_app_origin() { + assert!(is_trusted_media_origin("tauri://localhost")); + assert!(is_trusted_media_origin( + "tauri://localhost/channels/general" + )); + } + + #[test] + fn denies_untrusted_origins() { + assert!(!is_trusted_media_origin("")); + assert!(!is_trusted_media_origin("https://evil.example.com")); + // Prefix look-alikes must not slip through. + assert!(!is_trusted_media_origin("tauri://localhost.evil.com")); + assert!(!is_trusted_media_origin("tauri://localhostfoo")); + } + + #[cfg(debug_assertions)] + #[test] + fn allows_dev_origin_in_debug_only() { + assert!(is_trusted_media_origin("http://localhost:1420")); + assert!(is_trusted_media_origin("http://localhost:1420/")); + // A different localhost port is still untrusted. + assert!(!is_trusted_media_origin("http://localhost:14200")); + assert!(!is_trusted_media_origin("http://localhost:3000")); + } + + #[cfg(not(debug_assertions))] + #[test] + fn denies_dev_origin_in_release() { + assert!(!is_trusted_media_origin("http://localhost:1420")); + } +} From 9a386a0defbf2b355ee17646c7c11817a535b85f Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 16:23:16 +0100 Subject: [PATCH 59/99] Refine agent sharing dialog (#3699) ## Summary - Refine the agent share dialog around recipient sharing, link copying, catalog sharing, and export. - Show memory settings only when a linked agent has memories to include. - Use a catalog toggle for custom agents and keep built-in agents out of the catalog flow. ## Validation - `pnpm typecheck` - Focused Playwright share and catalog flows --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- .../features/agents/ui/PersonaShareDialog.tsx | 149 +++++------- desktop/tests/e2e/agents.spec.ts | 214 +++++++----------- 2 files changed, 139 insertions(+), 224 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index c641de9c70..5cf4f9ea3b 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -37,10 +37,13 @@ import { Dialog, DialogClose, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; import { formatShareRecipientName, @@ -63,7 +66,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { - afterLink?: React.ReactNode; + beforeExport?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -198,7 +201,6 @@ function MemoryShareConfirmation({ function ShareLevelControl({ ariaLabel, disabled, - hasMemoryOptions, testId, value, options, @@ -206,27 +208,11 @@ function ShareLevelControl({ }: { ariaLabel: string; disabled: boolean; - hasMemoryOptions: boolean; testId: string; value: SnapshotMemoryLevel; options: { value: SnapshotMemoryLevel; label: string }[]; onChange: (level: SnapshotMemoryLevel) => void; }) { - if (!hasMemoryOptions) { - // Nothing to choose from, so there is no dropdown to open. State the - // outcome rather than naming the sole option: the memory-level labels - // ("Agent only", "+ core memory", …) are comparative and only make sense - // when the alternatives are actually offered. - return ( - - No memories included - - ); - } - return ( - + Share {displayName} + + Anyone you share this {itemLabel} with will receive a copy they + can add and use. Changes you make later won’t sync. +
-

- They’ll receive a copy they can add and use. Changes you make - later won’t sync. -

+ {hasMemoryOptions ? ( +
+

+ Share settings +

+
+

+ What’s included +

+ +
+
+ ) : null} + + +
- - - -
-

Share with a link

-

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

-
-
-

- What’s included -

- -
- {showMemoryWarning ? ( ) : null} - - {afterLink}
+ {beforeExport}
) } diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 8c1c407df1..42d4aac114 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -93,10 +93,10 @@ async function sharePersonaToCatalog( ) { await page.getByLabel(`Open actions for ${displayName}`).click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await page.getByTestId("persona-share-catalog-access").click(); - await page - .getByRole("menuitemradio", { name: "Shared", exact: true }) - .click(); + const catalogToggle = page.getByTestId("persona-share-catalog-access"); + await expect(catalogToggle).not.toBeChecked(); + await catalogToggle.click(); + await expect(catalogToggle).toBeChecked(); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -979,21 +979,27 @@ test("custom personas share with people and keep export separate", async ({ page.getByRole("heading", { name: "Share Animation Auditor" }), ).toBeVisible(); await expect(shareDialog.getByText("Added by You")).toHaveCount(0); - const sendDescription = shareDialog.getByTestId( - "persona-share-send-description", - ); - await expect(sendDescription).toHaveText( - "They’ll receive a copy they can add and use. Changes you make later won’t sync.", - ); - await expect(sendDescription).toHaveClass( - /text-xs.*text-secondary-foreground\/75/, + const shareDescription = shareDialog.getByTestId( + "persona-share-share-description", + ); + await expect(shareDescription).toHaveText( + "Anyone you share this agent with will receive a copy they can add and use. Changes you make later won’t sync.", + ); + await expect(shareDescription).toHaveClass(/text-sm.*text-muted-foreground/); + const shareDescriptionId = await shareDescription.getAttribute("id"); + expect(shareDescriptionId).toBeTruthy(); + await expect(shareDialog).toHaveAttribute( + "aria-describedby", + shareDescriptionId ?? "", + ); + const shareDescriptionMetrics = await shareDescription.evaluate( + (element) => ({ + height: element.getBoundingClientRect().height, + lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), + }), ); - const sendDescriptionMetrics = await sendDescription.evaluate((element) => ({ - height: element.getBoundingClientRect().height, - lineHeight: Number.parseFloat(getComputedStyle(element).lineHeight), - })); - expect(sendDescriptionMetrics.height).toBeLessThanOrEqual( - sendDescriptionMetrics.lineHeight + 1, + expect(shareDescriptionMetrics.height).toBeLessThanOrEqual( + shareDescriptionMetrics.lineHeight * 2 + 1, ); await expect( shareDialog.getByRole("heading", { name: "Who has access" }), @@ -1001,57 +1007,49 @@ test("custom personas share with people and keep export separate", async ({ await expect(shareDialog.getByText("Owner", { exact: true })).toHaveCount(0); await expect(shareDialog.getByText("(You)", { exact: true })).toHaveCount(0); const linkRow = page.getByTestId("persona-share-link-row"); + await expect(page.getByTestId("persona-share-copy-link")).toBeVisible(); await expect( - linkRow.getByRole("heading", { name: "Share with a link" }), - ).toBeVisible(); + shareDialog.getByRole("heading", { name: "Share with a link" }), + ).toHaveCount(0); await expect( - linkRow.getByText("Anyone with the link can add and use a copy."), - ).toHaveClass(/text-xs.*text-secondary-foreground\/75/); + shareDialog.getByText("Anyone with the link can add and use a copy."), + ).toHaveCount(0); await expect(page.getByTestId("persona-share-send")).toHaveCount(0); const copyLinkButton = page.getByTestId("persona-share-copy-link"); - const linkIcon = page.getByTestId("persona-share-link-icon"); - const linkCopy = page.getByTestId("persona-share-link-copy"); const catalogSection = page.getByTestId("persona-share-catalog"); - const staticShareLevel = page.getByTestId("persona-share-share-level"); - const shareLevelRow = page.getByTestId("persona-share-share-level-row"); + const shareMainCard = page.getByTestId("persona-share-main-card"); + const exportAgentRow = page.getByTestId("persona-share-export"); await waitForAnimations(page); const [ linkRowBox, initialCopyLinkButtonBox, - linkIconBox, - linkCopyBox, catalogSectionBox, - staticShareLevelBox, - shareLevelRowBox, + shareMainCardBox, + exportAgentRowBox, ] = await Promise.all([ linkRow.boundingBox(), copyLinkButton.boundingBox(), - linkIcon.boundingBox(), - linkCopy.boundingBox(), catalogSection.boundingBox(), - staticShareLevel.boundingBox(), - shareLevelRow.boundingBox(), + shareMainCard.boundingBox(), + exportAgentRow.boundingBox(), ]); - const sendDescriptionBox = await sendDescription.boundingBox(); + const shareDescriptionBox = await shareDescription.boundingBox(); const recipientFieldBox = await page .getByTestId("persona-share-recipient-field") .boundingBox(); - // Reading order: who → how it goes out → what's included → catalog. - expect(sendDescriptionBox?.y ?? 0).toBeGreaterThanOrEqual( - (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), - ); - expect(linkRowBox?.y ?? 0).toBeGreaterThanOrEqual( - (sendDescriptionBox?.y ?? 0) + (sendDescriptionBox?.height ?? 0), + // Without memories, the recipient field flows directly into the link action. + expect(recipientFieldBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareDescriptionBox?.y ?? 0) + (shareDescriptionBox?.height ?? 0), ); - expect(shareLevelRowBox?.y ?? 0).toBeGreaterThanOrEqual( - (linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0), + await expect(page.getByTestId("persona-share-link-settings")).toHaveCount(0); + await expect(page.getByTestId("persona-share-share-level-row")).toHaveCount( + 0, ); - expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( - (shareLevelRowBox?.y ?? 0) + (shareLevelRowBox?.height ?? 0), + expect(linkRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), ); - // Copy link is the link row's own action, not a stranded footer button, so - // it rides on that row, vertically centred with the link icon and flush to - // the row's right edge. + // Copy link stays inside the main card, after the shared settings divider, while catalog and + // export are separate rows below it. expect(initialCopyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( linkRowBox?.y ?? 0, ); @@ -1059,40 +1057,15 @@ test("custom personas share with people and keep export separate", async ({ (initialCopyLinkButtonBox?.y ?? 0) + (initialCopyLinkButtonBox?.height ?? 0), ).toBeLessThanOrEqual((linkRowBox?.y ?? 0) + (linkRowBox?.height ?? 0) + 1); - expect( - Math.abs( - (initialCopyLinkButtonBox?.y ?? 0) + - (initialCopyLinkButtonBox?.height ?? 0) / 2 - - ((linkIconBox?.y ?? 0) + (linkIconBox?.height ?? 0) / 2), - ), - ).toBeLessThanOrEqual(1); - expect( - Math.abs( - (linkRowBox?.x ?? 0) + - (linkRowBox?.width ?? 0) - - ((initialCopyLinkButtonBox?.x ?? 0) + - (initialCopyLinkButtonBox?.width ?? 0)), - ), - ).toBeLessThanOrEqual(1); - expect( - Math.abs( - (linkCopyBox?.y ?? 0) + - (linkCopyBox?.height ?? 0) / 2 - - ((linkIconBox?.y ?? 0) + (linkIconBox?.height ?? 0) / 2), - ), - ).toBeLessThanOrEqual(1); - await expect(page.getByTestId("persona-share-link-divider")).toHaveCount(0); - await expect(page.getByTestId("persona-share-copy-link-footer")).toHaveCount( - 0, + await expect(page.getByTestId("persona-share-link-divider")).toHaveClass( + /bg-input\/40/, + ); + expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0) + 12, + ); + expect(exportAgentRowBox?.y ?? 0).toBeGreaterThanOrEqual( + (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0) + 12, ); - expect( - Math.abs( - (shareLevelRowBox?.y ?? 0) + - (shareLevelRowBox?.height ?? 0) / 2 - - ((staticShareLevelBox?.y ?? 0) + - (staticShareLevelBox?.height ?? 0) / 2), - ), - ).toBeLessThanOrEqual(1); await expect(copyLinkButton).toHaveClass( /border.*bg-background.*border-border/, ); @@ -1109,12 +1082,7 @@ test("custom personas share with people and keep export separate", async ({ await expect.poll(copyLinkHasVisibleShadow).toBe(false); await copyLinkButton.hover(); await expect.poll(copyLinkHasVisibleShadow).toBe(false); - await expect(page.getByTestId("persona-share-share-level")).toHaveText( - "No memories included", - ); - await expect( - shareDialog.getByText("No memories included", { exact: true }), - ).toHaveCount(1); + await expect(page.getByTestId("persona-share-share-level")).toHaveCount(0); await expect(page.getByTestId("persona-share-recipient-access")).toHaveCount( 0, ); @@ -1131,20 +1099,10 @@ test("custom personas share with people and keep export separate", async ({ await expect( shareDialog.getByText("File format", { exact: true }), ).toHaveCount(0); - const shareMainCard = page.getByTestId("persona-share-main-card"); - const exportAgentRow = page.getByTestId("persona-share-export"); await expect(exportAgentRow).toHaveText("Export agent"); await expect(shareMainCard.getByTestId("persona-share-export")).toHaveCount( 0, ); - await waitForAnimations(page); - const shareMainCardBox = await shareMainCard.boundingBox(); - const exportAgentRowBox = await exportAgentRow.boundingBox(); - const shareCardGap = - (exportAgentRowBox?.y ?? 0) - - ((shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0)); - expect(shareCardGap).toBeGreaterThanOrEqual(12); - expect(shareCardGap).toBeLessThan(16); const [shareMainCardStyles, exportAgentRowStyles] = await Promise.all([ shareMainCard.evaluate((element) => ({ borderRadius: getComputedStyle(element).borderRadius, @@ -1528,9 +1486,9 @@ This deliberately long fenced-code example must not establish the minimum width const shareMainCard = shareDialog.getByTestId("persona-share-main-card"); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); const catalogSection = shareDialog.getByTestId("persona-share-catalog"); - await expect( - shareMainCard.getByTestId("persona-share-catalog"), - ).toBeVisible(); + await expect(shareMainCard.getByTestId("persona-share-catalog")).toHaveCount( + 0, + ); await expect(catalogSection).toContainText("Share to catalog"); await expect(catalogSection).toContainText( "Anyone in this community can find and use a copy.", @@ -1544,26 +1502,18 @@ This deliberately long fenced-code example must not establish the minimum width catalogSection.boundingBox(), shareMainCard.boundingBox(), ]); - // Copy link belongs to the link row above, so the catalog is the section - // that closes the card rather than trailing an orphaned button. + // Catalog is its own card below the sharing controls. expect( (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), - ).toBeLessThanOrEqual(catalogSectionBox?.y ?? 0); - expect( - (catalogSectionBox?.y ?? 0) + (catalogSectionBox?.height ?? 0), ).toBeLessThanOrEqual( (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), ); - await expect(catalogAccess).toHaveText("Not shared"); + expect(catalogSectionBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareMainCardBox?.y ?? 0) + (shareMainCardBox?.height ?? 0), + ); + await expect(catalogAccess).not.toBeChecked(); await catalogAccess.click(); - await expect(page.getByRole("menuitemradio")).toHaveText([ - "Not shared", - "Shared", - ]); - await page - .getByRole("menuitemradio", { name: "Shared", exact: true }) - .click(); - await expect(catalogAccess).toHaveText("Shared"); + await expect(catalogAccess).toBeChecked(); const storedPersonas = await invokeTauri< Array<{ id: string; shared: boolean }> >(page, "list_personas"); @@ -1651,11 +1601,9 @@ This deliberately long fenced-code example must not establish the minimum width await page.getByLabel("Open actions for Catalog Analyst").click(); await page.getByRole("menuitem", { name: "Share" }).click(); - await expect(catalogAccess).toHaveText("Shared"); + await expect(catalogAccess).toBeChecked(); await catalogAccess.click(); - await page - .getByRole("menuitemradio", { name: "Not shared", exact: true }) - .click(); + await expect(catalogAccess).not.toBeChecked(); await page .getByTestId("persona-share-dialog") .getByRole("button", { name: "Close" }) @@ -1687,9 +1635,6 @@ test("a queued catalog share is not presented as relay-published", async ({ await page.getByLabel("Open actions for Queued Catalog Agent").click(); await page.getByRole("menuitem", { name: "Share" }).click(); await page.getByTestId("persona-share-catalog-access").click(); - await page - .getByRole("menuitemradio", { name: "Shared", exact: true }) - .click(); await expect( page.getByText( @@ -1918,7 +1863,7 @@ test("one share level selector drives both the link and send paths", async ({ const shareLevel = shareDialog.getByLabel("What to include", { exact: true, }); - const catalogAccess = shareDialog.getByLabel("What to share in the catalog"); + const catalogAccess = shareDialog.getByLabel("Share to catalog"); const recipientField = page.getByTestId("persona-share-recipient-field"); const emptyRecipientFieldBox = await recipientField.boundingBox(); await expect(shareDialog.getByTestId("persona-share-send")).toHaveCount(0); @@ -1930,13 +1875,14 @@ test("one share level selector drives both the link and send paths", async ({ await expect(shareLevel).toHaveCSS("text-decoration-line", "none"); await expect(shareLevel).toHaveCSS("padding-left", "8px"); await expect(shareLevel).toHaveCSS("padding-right", "8px"); - await expect(catalogAccess).toHaveText("Not shared"); - await catalogAccess.click(); - await expect(page.getByRole("menuitemradio")).toHaveText([ - "Not shared", - "Shared", - ]); - await page.keyboard.press("Escape"); + await expect(catalogAccess).not.toBeChecked(); + await expect(catalogAccess).toHaveCSS("cursor", "default"); + await expect( + shareDialog.getByTestId("persona-share-link-settings"), + ).toContainText("Share settings"); + await expect( + shareDialog.getByText("Share settings", { exact: true }), + ).toHaveClass(/text-xs.*text-secondary-foreground\/75/); const copyLinkButton = shareDialog.getByTestId("persona-share-copy-link"); const recipientFieldBox = await recipientField.boundingBox(); const [shareLevelBox, copyLinkButtonBox, catalogAccessBox] = @@ -1945,15 +1891,15 @@ test("one share level selector drives both the link and send paths", async ({ copyLinkButton.boundingBox(), catalogAccess.boundingBox(), ]); - // Reading order: who → how it goes out → what's included → catalog. - expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( + // Reading order: who → what's included → copy link → catalog. + expect(shareLevelBox?.y ?? 0).toBeGreaterThanOrEqual( (recipientFieldBox?.y ?? 0) + (recipientFieldBox?.height ?? 0), ); - expect(shareLevelBox?.y ?? 0).toBeGreaterThanOrEqual( - (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), + expect(copyLinkButtonBox?.y ?? 0).toBeGreaterThanOrEqual( + (shareLevelBox?.y ?? 0) + (shareLevelBox?.height ?? 0), ); expect(catalogAccessBox?.y ?? 0).toBeGreaterThanOrEqual( - (shareLevelBox?.y ?? 0) + (shareLevelBox?.height ?? 0), + (copyLinkButtonBox?.y ?? 0) + (copyLinkButtonBox?.height ?? 0), ); // The memory choice is stated once, governing both delivery actions — // neither the recipients row nor the link row carries its own copy. From 33bf7caa6ea474ccde2932c1ed05a90d7345c6e0 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 30 Jul 2026 11:28:49 -0400 Subject: [PATCH 60/99] docs(nips): specify kind:30621 multi-repo projects (NIP-MP) (#3163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz renders one card per `kind:30617`, so a project spanning several repositories has no representation — the relay, desktop app, and mobile app look like three unrelated things. This adds the spec for the container event that fixes that, plus the two shared fixture files that make it machine-checkable. Docs only; no code changes. Membership cannot live in the repository announcements themselves. A project spanning Alice's and Bob's repositories would need *both* of them to publish a tag naming the group, and Alice cannot sign for Bob's key. A project's own name, description, and channel binding likewise have no single writer when scattered across per-repository tags, and no deletion story. That is why multi-repo grouping is the one forge concept in Buzz that warrants a custom kind. ## `docs/nips/NIP-MP.md` `kind:30621`, an addressable event per NIP-01, addressed by `(pubkey, 30621, d)`. Members are `a` tags holding canonical `30617::` coordinates, following NIP-01's 2-or-3-element grammar where the optional third element is a relay hint clients MAY use and whose content ingest does not parse. Metadata is `name`, `description`, `buzz-channel`, `buzz-visibility`. - **Authority stops at the container.** The signer can replace their own project and nothing else — no edit, delete, push, or admin over any member. Deletion additionally admits the signer's registered NIP-OA owner, because `validate_standard_deletion_event` (`crates/buzz-relay/src/handlers/side_effects.rs`) grants that platform-wide so a human can clean up events published by an agent they own; the spec documents it as a Buzz extension to NIP-09 rather than carving `kind:30621` out of it. `buzz-channel` on a project is metadata only; git push policy reads the repository's own `kind:30617` (`crates/buzz-relay/src/api/git/policy.rs`) and a project never becomes an input to it. - **Ingest validation contract**, with named rules the fixtures reference: `d-cardinality`, `d-empty`, `member-cap` (64, counting every `a` tag), `member-tag-arity`, `member-coordinate-malformed`, `member-duplicate`, `metadata-cardinality`, `metadata-length`. Arity is its own rule rather than part of coordinate parsing, because a four-element member tag can carry a valid coordinate — the tag's shape is what is wrong, and ignoring elements past the relay hint would admit unvalidated data no consumer reads. Duplicates are rejected rather than normalized — a relay cannot rewrite tags inside a signed event without invalidating its id and signature. - **Metadata interpretation is normative, not left to the reader.** Ingest bounds cardinality and length and interprets nothing; clients resolve absent `name` to the `d` value, any unrecognized `buzz-visibility` token to `listed` (a typo is not a privacy signal), and an unresolvable `buzz-channel` to a project rendered without a channel rather than dropped. `content` carries no meaning: writers SHOULD emit `""`, and readers and relays MUST ignore any value rather than reject it. - **Claim authority.** A project suppresses a member's standalone card only when it is listing eligible *and* its signer is that repository's owner or appears in the repository's own `maintainers` tag. Without this, anyone could publish a project naming your repository and pull it out of the collection into a container you never consented to. An unauthorized project still renders, and still renders its members — it just cannot remove a repository from where its owner expects to find it. - **Deterministic client fold**, seven steps, with a table of required cases: exhaustive enumeration (a fixed `limit: 200` makes repository 201 vanish), multiple membership, fallback to a standalone card, unresolvable members marked unavailable rather than dropped, and local hide of a container never hiding repositories. On a relay that provides no exhaustive mode, the conformant behavior is a persistently marked possibly-incomplete collection — not a violation of the enumeration requirement. - **Pagination is specified in two modes**, because exhaustive enumeration is not universally achievable. Both modes share an explicit three-condition relay contract: a relay must (1) apply the complete filter before enforcing any limit, (2) expose the exact effective page limit it enforces, and (3) saturate pages — return `min(effective limit, remaining matches)`, so a short page proves all remaining matches were returned. A relay satisfying any proper subset does not provide the guarantee, and absent it a client MUST mark the collection possibly incomplete. On a relay exposing a composite `(created_at, event id)` keyset cursor — Buzz does on its authenticated HTTP bridge endpoint, via `until` + `before_id`; the NIP-01 websocket REQ path silently discards `before_id`, so a websocket client against Buzz is in mode 2 — clients MUST page by it; within the relay contract the cursor's uniqueness means no skips or re-reads and a short page is an unambiguous end signal, but cursor uniqueness alone does not substitute for the relay contract. A vanilla NIP-01 filter has no id tiebreak, so `until` alone either skips a second's unread events or never advances; there a client MUST drain the boundary second explicitly. The spec also adds normative guidance on query shapes: a client MUST use only query shapes the relay applies completely before limiting, and where a needed constraint (such as `#a`) is post-applied, MUST widen to a pushable shape and match the rest client-side. - **Kind allocation** recorded with the checks performed: `30621` is unassigned in the upstream nostr NIPs kind table and has no nostrbook.dev entry, and it is the one free number between `30620` and `30622` locally. ## `docs/nips/NIP-MP.fixtures.json` The ingest contract: 31 cases — 11 accept, 20 reject — as unsigned templates consumers sign with their own test key. Coverage includes minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-`d`-different-owner members, colon-bearing repository `d` values, relay hints, non-empty `content`, and every rejection rule. Each of the two 256-byte `buzz-` bounds gets its own reject case so neither can hide behind the other's rejection, and duplicate detection is pinned to the coordinate alone by a case whose two identical coordinates carry different relay hints. A four-element member tag carrying an otherwise valid coordinate pins arity separately from coordinate parsing. Every rejection case names the rules that may fire, so an implementation cannot pass by rejecting a bad event for an unrelated reason. ## `docs/nips/NIP-MP.fold-fixtures.json` The fold oracle: 12 cases covering every row of the required-fold-cases table, including the discriminating case where one authorized and one unauthorized project list the same repository — an implementation that requires every listing project to be authorized emits a spurious implicit card, and one that lets any listing project suppress drops a card it owes the owner. Inputs are semantic rather than signed envelopes: a repository or project is named by its coordinate plus only what the fold reads — signer, members, `maintainers`, visibility, viewer-hidden, deletion. Every collection in `expect` is compared as a set, including each container's `members`, since the fold fixes placement and not order. Signing would re-test the ingest contract and obscure what is under test. The fold is where claim authority lives, so without a shared oracle two clients could each satisfy the prose and still render different collections from identical heads. ## `VISION_PROJECTS.md` Line 41's "zero custom kinds" now reads "no custom kind for the repo itself", with a new "One Project, Many Repos" section recording why the one exception is warranted. `30621` rows added to the kind and status tables. Related: #3171 (the `KIND_PROJECT` constant, relay ingest validation of this contract, and the inclusive `created_at <= tombstone` bound this spec's coordinate-deletion rule cites). Independent — either can merge first. --------- Signed-off-by: Will Pfleger --- VISION_PROJECTS.md | 34 +- docs/nips/NIP-MP.fixtures.json | 1462 +++++++++++++++++++++++++++ docs/nips/NIP-MP.fold-fixtures.json | 527 ++++++++++ docs/nips/NIP-MP.md | 331 ++++++ 4 files changed, 2353 insertions(+), 1 deletion(-) create mode 100644 docs/nips/NIP-MP.fixtures.json create mode 100644 docs/nips/NIP-MP.fold-fixtures.json create mode 100644 docs/nips/NIP-MP.md diff --git a/VISION_PROJECTS.md b/VISION_PROJECTS.md index a44d7e05f7..8601b87829 100644 --- a/VISION_PROJECTS.md +++ b/VISION_PROJECTS.md @@ -38,12 +38,42 @@ Branch protections live in the same event — `buzz-protect` tags. The relay enf Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly. -Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, zero custom kinds. +Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Buzz clients read the `buzz-` tags and wire up the channel and project UI. One event, two audiences, no custom kind for the repo itself. NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable. --- +## One Project, Many Repos + +Real work spans repositories. The platform is a relay, a desktop app, and a mobile app — three repos, one project. Render one card per repo and they look like three unrelated things. + +Grouping is the one forge semantic that per-repo tags cannot express, and it's worth being precise about why, because everything else here deliberately avoids a custom kind. + +Put membership in each `kind:30617` and a project spanning Alice's and Bob's repos needs *both* of them to publish a tag naming the group. Alice can't enroll Bob's repo — she can't sign for his key. Cross-owner grouping becomes impossible, and the project's own name, description, and channel end up scattered across events with no single writer and no deletion story: dropping a repo from the group would mean editing an event you don't control. + +So there is exactly one custom kind — [NIP-MP](docs/nips/NIP-MP.md), `kind:30621`. One signer, one replaceable event, all group state in one place: + +```json +{ + "kind": 30621, + "tags": [ + ["d", "platform"], + ["name", "Platform"], + ["a", "30617::buzz"], + ["a", "30617::buzz-infra"], + ["buzz-channel", ""], + ["buzz-visibility", "listed"] + ] +} +``` + +A project points at repos. That's all it does. The signer gets no authority over any member — no edit, no delete, no push, no admin. Adding Bob's repo to your project is your signed assertion that the two belong together, and it changes nothing about Bob's repo or who can push to it. Push policy reads the repo's own event, never the project's. + +The cost is stated plainly: a third-party NIP-34 client sees the member repos individually and ignores the grouping. Nothing degrades — the repos are still standard, portable `kind:30617` events. And a repo in no project still renders on its own, exactly as before. + +--- + ## Branches as Channels A feature branch is a conversation. @@ -205,6 +235,7 @@ Standard kinds as substrate. Custom kinds only where genuinely novel. | **Workflows** | — | 46001-46012 | No NIP equivalent | | **Job dispatch** | — | 43001-43006 | Delegation trees | | **Project binding** | 30617 (NIP-34) | `buzz-` tags | Channel, visibility | +| **Multi-repo projects** | — | 30621 ([NIP-MP](docs/nips/NIP-MP.md)) | Cross-owner grouping is unexpressible in per-repo tags | | **Audit** | — | 48001 | Hash-chain tamper-evident log | If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol. @@ -221,6 +252,7 @@ If Buzz disappears tomorrow, your repos still work on gitworkshop.dev, your patc | Blossom media storage (SHA-256, S3) | ✅ Ships today | | Approval gates | 🚧 Infrastructure exists; executor wiring in progress | | Project binding (kind:30617 + `buzz-` tags) | 📋 Designed | +| Multi-repo projects (kind:30621, [NIP-MP](docs/nips/NIP-MP.md)) | 📋 Designed | | Git hosting (smart HTTP + NIP-34) | ✅ Ships today | | Merge coordinator | 📋 Designed | | NIP-34 issues (kind:1621) | 📋 Designed | diff --git a/docs/nips/NIP-MP.fixtures.json b/docs/nips/NIP-MP.fixtures.json new file mode 100644 index 0000000000..cfc570ec65 --- /dev/null +++ b/docs/nips/NIP-MP.fixtures.json @@ -0,0 +1,1462 @@ +{ + "$comment": "NIP-MP conformance fixtures \u2014 the shared ingest contract for the relay validator, the Rust event builder, and the TypeScript event builder. Each case carries an UNSIGNED template: consumers sign it with their own test key, because signing fixes the event id and signature and a stored literal would not survive re-serialization. `expect` is the ingest outcome. `reject_rules` lists the validation rules that may fire; an implementation must reject for one of them, so it cannot pass by rejecting for an unrelated reason. This file covers single-event accept/reject only; the client-side fold has its own oracle in NIP-MP.fold-fixtures.json. Spec: docs/nips/NIP-MP.md.", + "version": 1, + "kind": 30621, + "member_cap": 64, + "owners": { + "a": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "b": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "cases": [ + { + "name": "valid_minimal", + "expect": "accept", + "note": "Only the identity tag. A project needs nothing but a `d` tag to be a valid addressable container.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ] + ] + } + }, + { + "name": "valid_full", + "expect": "accept", + "note": "Every specified tag present, with two members owned by two different pubkeys \u2014 the cross-owner grouping that motivates the kind.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ], + [ + "description", + "Relay, desktop, and mobile for the platform team." + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-infra" + ], + [ + "buzz-channel", + "3580ca9b-47b4-4af9-b22a-1068778f26c6" + ], + [ + "buzz-visibility", + "listed" + ] + ] + } + }, + { + "name": "valid_zero_members", + "expect": "accept", + "note": "Zero-member project. Legal at the protocol layer: it is the natural state after removing a final member and carries only bounded metadata.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ] + ] + } + }, + { + "name": "valid_unlisted", + "expect": "accept", + "note": "Explicitly unlisted container. Accepted at ingest; the fold excludes it from listing eligibility, so its member keeps its implicit card.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "skunkworks" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "buzz-visibility", + "unlisted" + ] + ] + } + }, + { + "name": "valid_member_cap_boundary", + "expect": "accept", + "note": "Exactly 64 distinct member `a` tags \u2014 the cap is inclusive. Paired with `invalid_member_cap_exceeded` to pin the boundary from both sides.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63" + ] + ] + } + }, + { + "name": "valid_same_dtag_two_owners", + "expect": "accept", + "note": "Two members share a repo `d` segment under different owners (the NIP-34 fork case). Identity is the whole coordinate, so these are not duplicates.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "forks" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz" + ] + ] + } + }, + { + "name": "valid_member_dtag_contains_colon", + "expect": "accept", + "note": "Repo `d` segment contains a colon. The coordinate splits into at most three parts, so the third part is the repo `d` value verbatim \u2014 `buzz:infra` here. Splitting on every colon instead would make any repo with a colon in its `d` tag unaddressable by a project.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz:infra" + ] + ] + } + }, + { + "name": "valid_uninterpreted_metadata_values", + "expect": "accept", + "note": "`buzz-channel` is not a UUID and `buzz-visibility` is an unrecognized token. Ingest bounds metadata cardinality and length but does not interpret these values \u2014 matching how kind:30617 carries the same two tags. Client-side fallbacks for both are normative in the spec's Metadata interpretation section: an unresolvable channel renders the project without one, and an unrecognized visibility is treated as `listed`.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "not-a-uuid" + ], + [ + "buzz-visibility", + "chartreuse" + ] + ] + } + }, + { + "name": "valid_unknown_tag_ignored", + "expect": "accept", + "note": "An unrecognized tag is neither rejected nor interpreted \u2014 unknown tags are ignored, not fatal.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "future-metadata", + "preserve-me" + ] + ] + } + }, + { + "name": "valid_member_relay_hint", + "expect": "accept", + "note": "Member `a` tag carries NIP-01's optional third element, a relay hint. Ingest validates the tag's arity and the coordinate in element 1, so the hint's content is neither parsed nor a rejection cause; a client MAY use it to resolve an otherwise unavailable member.", + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay.example.com" + ] + ] + } + }, + { + "name": "valid_non_empty_content", + "expect": "accept", + "note": "`content` holds a value. Writers should emit the empty string, but readers and relays must ignore whatever is there \u2014 a non-empty `content` is not a rejection cause and carries no semantics.", + "template": { + "kind": 30621, + "content": "ignored by every consumer", + "tags": [ + [ + "d", + "platform" + ] + ] + } + }, + { + "name": "invalid_d_missing", + "expect": "reject", + "note": "No `d` tag. Without one the event collapses into the `(pubkey, 30621, \"\")` slot and silently last-write-wins over an unrelated project.", + "reject_rules": [ + "d-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "name", + "Platform" + ] + ] + } + }, + { + "name": "invalid_d_multiple", + "expect": "reject", + "note": "Two `d` tags. Which one addresses the event is reader-dependent; reject rather than pick.", + "reject_rules": [ + "d-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "d", + "infra" + ] + ] + } + }, + { + "name": "invalid_d_empty", + "expect": "reject", + "note": "Present but empty `d`. Same slot-collapse hazard as a missing `d`.", + "reject_rules": [ + "d-empty" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "" + ] + ] + } + }, + { + "name": "invalid_member_duplicate", + "expect": "reject", + "note": "The same canonical coordinate twice. A signed event cannot be normalized in place, so the relay refuses it rather than store a head every consumer must defensively dedupe.", + "reject_rules": [ + "member-duplicate" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_duplicate_differing_relay_hints", + "expect": "reject", + "note": "The same coordinate twice under different relay hints. Duplicate detection compares the coordinate alone, so differing hints do not make these distinct members; a validator that compares whole tag arrays would wrongly accept this.", + "reject_rules": [ + "member-duplicate" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay-one.example.com" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay-two.example.com" + ] + ] + } + }, + { + "name": "invalid_member_cap_exceeded", + "expect": "reject", + "note": "65 distinct member `a` tags \u2014 one past the inclusive cap of 64.", + "reject_rules": [ + "member-cap" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-33" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-34" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-35" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-36" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-37" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-38" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-39" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-40" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-41" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-42" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-43" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-44" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-45" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-46" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-47" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-48" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-49" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-50" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-51" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-52" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-53" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-54" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-55" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-56" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-57" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-58" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-59" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-60" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-61" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-62" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-63" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-64" + ] + ] + } + }, + { + "name": "invalid_member_cap_exceeded_by_duplicates", + "expect": "reject", + "note": "65 member `a` tags naming 33 distinct coordinates. The spec evaluates the cap before the duplicate set is built, so `member-cap` is the required rule even though the event also carries duplicates; a validator that reports `member-duplicate` here has built a set whose size is bounded only by the frame limit.", + "reject_rules": [ + "member-cap" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "wide" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-00" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-01" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-02" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-03" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-04" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-05" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-06" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-07" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-08" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-09" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-10" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-11" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-12" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-13" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-14" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-15" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-16" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-17" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-18" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-19" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-20" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-21" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-22" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-23" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-24" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-25" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-26" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-27" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-28" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-29" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-30" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-31" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:repo-32" + ] + ] + } + }, + { + "name": "invalid_member_tag_arity_four_elements", + "expect": "reject", + "note": "Member `a` tag carries a fourth element past the relay hint. Its coordinate is valid, so this rejects on tag shape rather than on the coordinate: NIP-01's `a` grammar is two or three elements, and a validator that reads element 1 and ignores the rest would accept unbounded unvalidated data in a position no consumer reads.", + "reject_rules": [ + "member-tag-arity" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "wss://relay.example.com", + "unexpected" + ] + ] + } + }, + { + "name": "invalid_member_kind_prefix", + "expect": "reject", + "note": "Coordinate names kind 30618 (repo state) rather than 30617 (repo announcement). A project groups repositories, not their ref state.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30618:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_not_hex", + "expect": "reject", + "note": "Owner segment is 64 characters but not hex.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_uppercase", + "expect": "reject", + "note": "Owner segment is uppercase hex. `#a` tag matching is byte-exact, so an uppercase head is invisible to the lowercase-coordinate queries every reader issues.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz" + ] + ] + } + }, + { + "name": "invalid_member_owner_wrong_length", + "expect": "reject", + "note": "Owner segment is 63 hex characters.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + ] + } + }, + { + "name": "invalid_member_dtag_empty", + "expect": "reject", + "note": "Coordinate has an empty repo `d` segment \u2014 it addresses no announceable repository.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:" + ] + ] + } + }, + { + "name": "invalid_member_missing_segment", + "expect": "reject", + "note": "Coordinate has two segments instead of three.", + "reject_rules": [ + "member-coordinate-malformed" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "a", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + ] + ] + } + }, + { + "name": "invalid_metadata_duplicate_name", + "expect": "reject", + "note": "Two `name` tags. Reader-dependent display name; reject rather than pick.", + "reject_rules": [ + "metadata-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "Platform" + ], + [ + "name", + "Infra" + ] + ] + } + }, + { + "name": "invalid_metadata_duplicate_channel", + "expect": "reject", + "note": "Two `buzz-channel` tags. Which channel the project links to would be reader-dependent.", + "reject_rules": [ + "metadata-cardinality" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "3580ca9b-47b4-4af9-b22a-1068778f26c6" + ], + [ + "buzz-channel", + "00000000-0000-0000-0000-000000000000" + ] + ] + } + }, + { + "name": "invalid_metadata_name_too_long", + "expect": "reject", + "note": "`name` value exceeds 256 bytes.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "name", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ] + } + }, + { + "name": "invalid_metadata_description_too_long", + "expect": "reject", + "note": "`description` value exceeds 2048 bytes.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "description", + "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + ] + ] + } + }, + { + "name": "invalid_metadata_channel_too_long", + "expect": "reject", + "note": "`buzz-channel` value exceeds 256 bytes. Paired with `invalid_metadata_visibility_too_long` so each bound is exercised by an event whose other metadata is valid \u2014 neither check can hide behind the other's rejection.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-channel", + "ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + ] + } + }, + { + "name": "invalid_metadata_visibility_too_long", + "expect": "reject", + "note": "`buzz-visibility` value exceeds 256 bytes. Ingest does not interpret the token, but it must still bound its length.", + "reject_rules": [ + "metadata-length" + ], + "template": { + "kind": 30621, + "content": "", + "tags": [ + [ + "d", + "platform" + ], + [ + "buzz-visibility", + "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv" + ] + ] + } + } + ] +} diff --git a/docs/nips/NIP-MP.fold-fixtures.json b/docs/nips/NIP-MP.fold-fixtures.json new file mode 100644 index 0000000000..c036646c34 --- /dev/null +++ b/docs/nips/NIP-MP.fold-fixtures.json @@ -0,0 +1,527 @@ +{ + "$comment": "NIP-MP fold fixtures \u2014 the shared oracle for the client-side fold in docs/nips/NIP-MP.md (\"The fold\" and \"Required fold cases\"). Every case in the spec's required-cases table appears here, keyed by `spec_case`. Inputs are SEMANTIC, not signed envelopes: a repository or project is named by its coordinate plus the fold's inputs (signer, members, `maintainers`, visibility, viewer-hidden, deletion). Signing and envelope validation are the ingest contract and live in NIP-MP.fixtures.json; this file assumes every input is a valid, accepted head and tests only the placement the fold derives from it. `expect.containers` lists each rendered project with the members rendered inside it; `expect.implicit_cards` lists the repositories that additionally render as their own single-repository cards. EVERY collection in `expect` is compared as a set \u2014 the containers, each container's `members`, and the implicit cards alike \u2014 because the fold fixes placement, not order. `render` is `resolved` when the member coordinate resolves to a live head and `unavailable` when it does not. `state` is `live` or `deleted`; `visibility` is `listed` or `unlisted`; a value of `viewer_hidden` is a local, per-viewer decision, not event state.", + "version": 1, + "project_kind": 30621, + "repository_kind": 30617, + "pubkeys": { + "alice": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bob": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "carol": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + }, + "cases": [ + { + "name": "owner_project_claims_own_repository", + "spec_case": "Owner's own project lists their repository", + "note": "The signer is the member coordinate's owner, so the project claims the repository and step 3 suppresses its implicit card.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "stranger_project_does_not_claim", + "spec_case": "Stranger's project lists someone else's repository", + "note": "Bob is neither the owner nor a maintainer, so his project renders the member but claims nothing. The repository keeps its own card: an unendorsed grouping cannot pull a repository out of where its owner expects to find it.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed", + "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "maintainer_signer_claims", + "spec_case": "Project signer is in the member repository's `maintainers` tag", + "note": "Claim authority is read from the member repository's own head, not from ownership alone. Carol is listed in `maintainers`, so her project claims the repository exactly as the owner's would.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false, + "maintainers": [ + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + ] + } + ], + "projects": [ + { + "coordinate": "30621:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:platform", + "signer": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "two_claiming_projects_both_render_member", + "spec_case": "Repository is a member of two projects that both claim it", + "note": "Multiple membership is not a move. The repository renders inside both containers and has no implicit card, and it appears once per container rather than twice in either.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + }, + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:infra", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + }, + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:infra", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "repository_removed_from_every_project", + "spec_case": "Repository removed from every project", + "note": "A live project that no longer lists the repository. The container renders empty rather than being hidden, and the unclaimed repository falls back to its own card.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [] + } + ], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "unlisted_project_absent_member_falls_back", + "spec_case": "Project is `unlisted`, or locally hidden", + "note": "An unlisted project is not listing eligible, so it claims nothing even though its signer owns the member. The container that would hold the repository is not on screen, so the repository must render as its own card or vanish.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:skunkworks", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "unlisted", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "locally_hidden_project_absent_member_falls_back", + "spec_case": "Project is `unlisted`, or locally hidden", + "note": "The second half of the same rule, reached by a different input: hiding a container is a statement about the grouping only. The viewer-hidden project claims nothing and its member returns as an implicit card.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": true + } + ], + "expect": { + "containers": [], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "viewer_hidden_repository_absent_everywhere", + "spec_case": "Viewer has hidden a member repository", + "note": "Hiding a repository hides it everywhere, including inside every project that lists it \u2014 otherwise someone else's grouping could undo the viewer's decision. The sibling member is unaffected.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": true + }, + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "unresolvable_member_renders_unavailable", + "spec_case": "Member coordinate resolves to nothing", + "note": "No repository answers the coordinate \u2014 never announced, deleted, or absent from this relay. It renders inside its project as explicitly unavailable: dropping it silently would make the project look smaller than its author declared, and promoting it to a standalone card would invent a repository.", + "repositories": [], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:ghost" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:ghost", + "render": "unavailable" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "deleted_project_absent_member_falls_back", + "spec_case": "Project head deleted", + "note": "Deletion does not cascade. The container is gone; the member repository, its refs and its channel survive and it falls back to an implicit card.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "deleted", + "viewer_hidden": false + } + ], + "expect": { + "containers": [], + "implicit_cards": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ] + } + }, + { + "name": "authorized_and_unauthorized_projects_share_member", + "spec_case": "One authorized and one unauthorized project both list the same repository", + "note": "The discriminating case for step 3's \"at least one\": Alice's claim suppresses the implicit card, and Bob's unauthorized project still renders the member. An implementation that requires every listing project to be authorized would wrongly emit an implicit card here; one that lets any listing project suppress would wrongly emit none in `stranger_project_does_not_claim`.", + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + }, + { + "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed", + "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + }, + { + "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:borrowed", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + }, + { + "name": "exhaustive_enumeration_across_pages_with_tied_timestamps", + "spec_case": "More repositories and projects than one page holds, with several sharing one `created_at`", + "note": "Exercises step 1 rather than the placement rules: the inputs exceed `enumeration.page_size` and three entities share one `created_at`, so a timestamp-only cursor loses events at the page boundary. Every repository and project must still render. `created_at` is present only on this case, and only because the cursor is what is under test.", + "enumeration": { + "page_size": 2, + "cursor": "composite", + "note": "The harness must serve inputs in pages of `page_size`, ordered `(created_at DESC, coordinate ASC)`, and the client under test must page with a composite `(created_at, id)` cursor per the Pagination section. Ordering by coordinate stands in for event id, which unsigned semantic fixtures do not carry." + }, + "repositories": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "state": "live", + "viewer_hidden": false, + "created_at": 1000 + }, + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra", + "state": "live", + "viewer_hidden": false, + "created_at": 1000 + }, + { + "coordinate": "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile", + "state": "live", + "viewer_hidden": false, + "created_at": 1000 + } + ], + "projects": [ + { + "coordinate": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "signer": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "members": [ + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false, + "created_at": 900 + }, + { + "coordinate": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:mobile", + "signer": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "members": [ + "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile" + ], + "visibility": "listed", + "state": "live", + "viewer_hidden": false, + "created_at": 900 + } + ], + "expect": { + "containers": [ + { + "project": "30621:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:platform", + "members": [ + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz", + "render": "resolved" + }, + { + "coordinate": "30617:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:buzz-infra", + "render": "resolved" + } + ] + }, + { + "project": "30621:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:mobile", + "members": [ + { + "coordinate": "30617:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb:buzz-mobile", + "render": "resolved" + } + ] + } + ], + "implicit_cards": [] + } + } + ] +} diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md new file mode 100644 index 0000000000..6ca1ec4749 --- /dev/null +++ b/docs/nips/NIP-MP.md @@ -0,0 +1,331 @@ +NIP-MP +====== + +Multi-Repository Projects +------------------------- + +`draft` `optional` `relay` + +**Depends on**: NIP-01 (basic event format, addressable events), NIP-34 (git repositories), NIP-09 (event deletion). Interacts with NIP-29 (the channel a project links to) and NIP-OA (owner attestation, for how agents inherit repo push access). + +## Abstract + +This NIP defines `kind:30621`, an addressable **project** event: a signed, named grouping of NIP-34 repository announcements (`kind:30617`). A project references its member repositories by coordinate, so one project may span repositories owned by different pubkeys, and one repository may belong to several projects. + +A project is metadata only. Its signer gains no authority over any member repository — not to edit it, delete it, push to it, or administer it. Membership is an assertion about grouping, not a grant of permission. + +## Motivation + +Buzz renders one card per `kind:30617`, so "the platform" — a relay, a desktop app, and a mobile app — appears as three unrelated repositories. Real work spans repositories; the model does not. + +[VISION_PROJECTS.md](../../VISION_PROJECTS.md) sets the bar as "standard kinds as substrate, custom kinds only where genuinely novel," and every other forge concept in Buzz clears it: repositories, patches, issues, statuses, and ref state are all standard NIP-34 kinds. Multi-repository grouping is the one semantic that cannot be: + +- **Per-repository tags cannot express cross-owner grouping.** If membership lived in each `kind:30617`, a project spanning Alice's and Bob's repositories would require *both* Alice and Bob to publish a tag naming the group. Alice cannot enroll Bob's repository; she cannot sign for his key. Grouping would be possible only within a single owner's repositories, and would break the moment a repository changed hands or a fork joined. +- **Project-level metadata has no owner.** A project name, description, and linked channel describe the *group*, not any one repository. Scattered across per-repository tags they have no single writer, no replacement semantics, and no deletion story: removing a repository from the group means editing an event you may not control. +- **Existing list kinds do not fit.** NIP-51 sets (`kind:30004` curation sets and friends) are private-or-public user bookmarks over arbitrary content, not a shared, named, addressable container for a forge collection with its own channel binding and visibility. Overloading a curation set would make every project indistinguishable from a user's reading list. + +One custom kind, held by one signer, with all group state in one replaceable event, resolves all three. The cost is bounded and stated plainly: `kind:30621` is Buzz-specific, so a third-party NIP-34 client sees the member repositories individually and ignores the grouping. Nothing degrades — the repositories remain standard, portable `kind:30617` events, discoverable and renderable exactly as before. + +## Non-Goals + +This NIP does not define shared or delegated project editing — a project is replaceable only by its own signer (see [Authority](#authority)). +This NIP does not define any authorization over member repositories. Membership is not a permission grant, and a project is never consulted by git push policy. +This NIP does not define project-level branch protection, CI, or workflow configuration. +This NIP does not define nested projects. A project's members are repositories, never other projects. +This NIP does not require relays to verify that a member coordinate resolves to an existing repository — a project may reference a repository that does not exist yet, or no longer does. + +## Terminology + +This document uses MUST, MUST NOT, SHOULD, SHOULD NOT, MAY, and RECOMMENDED as defined in RFC 2119. + +- **project**: A `kind:30621` event. Also called the *container*. +- **member**: A repository referenced by a project, named by an `a` tag holding a repository coordinate. +- **coordinate**: The NIP-01 address of a repository announcement, `30617::`. +- **explicit project**: A project that exists as a `kind:30621` event. +- **implicit project**: The single-repository card a client renders for a `kind:30617` that no listing-eligible explicit project claims. Not an event — a rendering fallback. +- **listing eligible**: A project a client is currently rendering in its project collection. See [Listing eligibility](#listing-eligibility). + +## Kinds + +| Kind | Name | Signer | Class | Purpose | +|------|------|--------|-------|---------| +| `30621` | Project | user | addressable | A named grouping of `kind:30617` repository announcements | + +`kind:30621` is an addressable event per NIP-01 (`30000 <= n < 40000`), addressed by `(pubkey, 30621, d)`. Two signers may use the same `d` value; those are two distinct projects. Addressable events were formerly specified as "parameterized replaceable events" in NIP-33, which upstream has since folded into NIP-01; this document cites NIP-01 throughout. + +### Kind allocation + +`30621` sits in the NIP-34 git block (`30617` repository announcement, `30618` repository state), which is where a reader looks for a forge concept. Checks performed before freezing the number: + +| Registry | Checked | Result | +|----------|---------|--------| +| Upstream nostr NIPs event-kind table (`nostr-protocol/nips` `README.md`, at commit `6d2979b3f503a8539c983efbcdcf901bbcf9ed23`) | `30610`–`30629` | Only `30617` and `30618` are assigned. `30621` is unassigned. | +| nostrbook.dev kind registry (`https://nostrbook.dev/kinds/`) | `30617`, `30618`, `30620`, `30621`, `30622` | `30617` and `30618` documented (HTTP 200). `30620`, `30621`, `30622` all HTTP 404 — no entry. | +| This repository (`crates/buzz-core/src/kind.rs`) | full range | `30620` is `KIND_WORKFLOW_DEF`, `30622` is `KIND_DM_VISIBILITY` (NIP-DV). `30621` is the one free number between them. | + +Both external registries are advisory, not authoritative allocators: neither reserves numbers, and an unregistered kind may still be in use by an unpublished client. A future upstream assignment of `30621` would be a collision Buzz absorbs the same way it already does for its other custom kinds — the number is Buzz-specific, and interoperability rests on the member `kind:30617` events, which remain standard. + +## Event Format + +```jsonc +{ + "kind": 30621, + "pubkey": "", + "content": "", + "tags": [ + ["d", "platform"], + ["name", "Platform"], + ["description", "Relay, desktop, and mobile for the platform team."], + ["a", "30617::buzz"], + ["a", "30617::buzz-infra"], + ["buzz-channel", ""], + ["buzz-visibility", "listed"] + ] +} +``` + +| Tag | Cardinality | Meaning | +|-----|-------------|---------| +| `d` | exactly 1, non-empty | Project slug. The NIP-01 addressable identifier. | +| `name` | 0 or 1 | Human-readable display name. Clients fall back to `d` when absent. | +| `description` | 0 or 1 | Free text describing the project. | +| `a` | 0 to 64 | One member repository coordinate each. Order is not significant. | +| `buzz-channel` | 0 or 1 | UUID of the channel this project's discussion lives in. Metadata only — see [Authority](#authority). At most 256 bytes. | +| `buzz-visibility` | 0 or 1 | `listed` (default) or `unlisted`. Feeds [listing eligibility](#listing-eligibility). At most 256 bytes. | + +`content` carries no meaning. Writers SHOULD emit the empty string. Readers and relays MUST ignore whatever it holds: a non-empty `content` is not a rejection cause, and no consumer may parse semantics from it. Reserving it costs nothing and keeps a future writer that fills it from invalidating its events for today's readers. + +Unrecognized tags MUST be ignored rather than rejected, so a newer writer can add metadata without invalidating its events for older readers. + +### Metadata interpretation + +Ingest bounds metadata cardinality and length; it interprets no metadata value. `buzz-channel` and `buzz-visibility` are opaque strings to a relay, exactly as they are on `kind:30617`. Interpretation is a client concern, and every client MUST resolve it the same way: + +- `name` absent → clients display the `d` value. +- `buzz-visibility` absent or holding any value other than `listed` or `unlisted` → treated as `listed`. An unrecognized token MUST NOT hide a project: a typo in a metadata field is not a privacy signal, and treating it as one would make a project vanish for reasons its author cannot see. +- `buzz-channel` absent, or naming a channel the viewer cannot resolve or read → the project renders without a channel link. It MUST NOT be dropped from the collection, and the unresolvable value MUST NOT be surfaced as a broken link. + +### Member coordinates + +A member `a` tag follows NIP-01's `a` tag grammar: `["a", ""]` or `["a", "", ""]`. Ingest validates the tag's arity — exactly two or three elements — and the coordinate in element 1. The relay URL is opaque: it is never parsed and never a rejection cause by content. A fourth element has no meaning in this grammar and is rejected rather than ignored, so a writer cannot smuggle unbounded data into a position no consumer reads. + +The optional third element is a **relay hint**: a recommended relay where the member announcement may be found. Clients MAY use it when resolving a member that step 6 of [the fold](#the-fold) would otherwise mark unavailable, and MUST treat it as advice rather than authority — a hint is unauthenticated, supplied by the project signer rather than the repository owner, so a resolution through it MUST still verify that the retrieved event is the coordinate's own signed `kind:30617`. A hint MUST NOT be required: a project whose members are all on the reading relay resolves fully without one, and a client that ignores hints entirely is conformant. + +A member `a` tag coordinate MUST be exactly `30617::` where: + +- the kind segment is the literal `30617`. A project groups repository *announcements*; a coordinate naming any other kind (notably `30618` repository state) is malformed. +- `` is 64 lowercase hex characters. Uppercase is rejected: `#a` filter matching is byte-exact, so an uppercase-owner head would be invisible to the lowercase-coordinate queries every reader issues. +- `` is non-empty and is the `d` tag of the member repository announcement, taken **verbatim**. + +Parsing splits on the first two colons only; everything after the second colon is ``. A repository whose `d` tag contains a colon is therefore addressable. Splitting on every colon would make such a repository permanently unaddressable by any project. + +Buzz-hosted repositories cannot currently produce such a coordinate: their `d` values are validated as `[a-zA-Z0-9._-]{1,64}` (`crates/buzz-relay/src/handlers/side_effects.rs`, `crates/buzz-sdk/src/builders.rs`). The tolerance is for the repositories this NIP does not control — NIP-34 announcements from other clients, and any future relaxation of Buzz's own rule — and it matches how Buzz already parses coordinates in NIP-09 deletion handling, so a project coordinate and a deletion coordinate can never disagree about where a repository's `d` value begins. + +Coordinate identity is the whole string. Two members sharing a `` under different owners — the NIP-34 fork case — are distinct members, not duplicates. + +A project MAY reference a coordinate that resolves to nothing: a repository not yet announced, deleted, or announced on another relay. Clients render those members as explicitly unavailable ([Client Behavior](#client-behavior), step 6). + +## Semantics + +### Authority + +The project signer's authority begins and ends at the container. + +- **Over the container**: total. Only the signer can replace their `(pubkey, 30621, d)` coordinate. Deletion additionally admits the signer's registered NIP-OA owner — see [Deletion](#deletion). +- **Over member repositories**: none. No edit, no delete, no push, no administration, no ability to change a member repository's own metadata or protections. Adding Bob's repository to Alice's project changes nothing about Bob's repository or who may push to it. It is Alice's signed assertion that the two belong together, and it is attributable to her key. + +Clients MUST preserve each member repository's own owner provenance in the UI. A repository rendered inside a project must not appear to be owned or governed by the project signer. + +`buzz-channel` on a project is **metadata only**. Git push policy reads the `buzz-channel` of the repository's own `kind:30617` (`crates/buzz-relay/src/api/git/policy.rs`); a project neither overrides that binding nor supplies one to a member that lacks it. A project's channel binding therefore cannot widen or narrow push access to anything. + +### Editing model + +Editing is **owner-only**: publish a replacement `kind:30621` with the same `d` and a newer `created_at`. Adding, removing, or reordering members and changing metadata are all one operation — replacing the container. This falls out of the addressable-event model with no relay-side permission machinery; NIP-01 replacement already refuses to let one pubkey overwrite another's coordinate. + +Delegated or maintainer editing is deliberately out of scope for this version. Adding it later needs no change to this event shape — only a new rule about who may replace a coordinate. + +### Zero-member projects + +A project with no `a` tags is valid. It is the natural state after removing a final member, and it carries only bounded metadata either way. Deleting the container — with its name, description, and channel binding — because its last repository was removed would be a destructive surprise for a reversible action. + +Clients SHOULD require at least one member when *creating* a project, since an empty new project is almost always a mistake, and MUST render an existing empty project as an empty container rather than hiding it or treating it as malformed. + +### Multiple membership + +A repository may be a member of any number of projects. It renders inside each ([Client Behavior](#client-behavior), step 4). Membership is not exclusive and not a move: nothing about the repository event changes when it joins or leaves a project. + +### Deletion + +Deleting a project (NIP-09 `kind:5` naming the project coordinate) deletes the `kind:30621` only. Member repositories are untouched — their `kind:30617` events, refs, channels, and protections all survive, and each falls back to an implicit card unless another listing-eligible project claims it. + +**Who may delete.** The project signer always may. On the Buzz relay, so may the signer's registered NIP-OA owner: `validate_standard_deletion_event` resolves the deletion's effective author and accepts it when that actor is the target pubkey's registered owner (`crates/buzz-relay/src/handlers/side_effects.rs`). This is a **Buzz relay extension to NIP-09**, applied uniformly to every kind rather than specially to projects — it is what lets a human clean up events published by an agent they own. Vanilla NIP-09 relays accept only the signer, so a project deleted through the owner path on Buzz will still be live on a relay that lacks the extension. + +Replacement admits no such widening: it is signer-only on every relay, because NIP-01 keys the coordinate on the pubkey itself rather than on a permission check. + +A deletion whose `created_at` precedes the live head does not remove it — see [Relay Processing Algorithm](#relay-processing-algorithm). + +There is no cascade, in either direction. Deleting a member repository does not modify the project; the project keeps a coordinate that no longer resolves, and clients render it as unavailable. + +## Relay Processing Algorithm + +A relay accepting `kind:30621` MUST validate the envelope at ingest. The rule names below are the identifiers the shared fixtures use. + +1. **`d-cardinality`** — exactly one `d` tag. Zero or several is rejected. Under NIP-01 a missing `d` is treated as empty, which collapses every such event into the `(pubkey, 30621, "")` slot where unrelated projects silently overwrite each other; several `d` tags make the address reader-dependent. +2. **`d-empty`** — the `d` value is non-empty. Same collapse hazard. Its length is bounded by the relay's existing generic `d`-tag limit (`buzz_db::event::D_TAG_MAX_LEN`, 1024 bytes); this NIP adds no second bound. +3. **`member-cap`** — at most 64 member `a` tags, counting **every** `a` tag rather than distinct coordinates. Counting distinct coordinates would leave parse volume bounded only by the relay frame limit (512 KiB by default, `crates/buzz-relay/src/config.rs`), since a duplicate-heavy event could carry thousands of tags naming one coordinate. The cap is inclusive: 64 is accepted, 65 is not. +4. **`member-tag-arity`** — every member `a` tag has exactly two or three elements, per NIP-01's `a` tag grammar. A one-element tag names no coordinate; a fourth element has no defined meaning, and ignoring it would let a writer park unbounded unvalidated data in a position no consumer reads. This is a separate rule from the next one because the failure is different: the tag's shape is wrong, not the coordinate it holds. +5. **`member-coordinate-malformed`** — every member `a` tag's coordinate (element 1) parses per [Member coordinates](#member-coordinates). The relay hint in element 3 is not parsed and MUST NOT be a rejection cause by its content. +6. **`member-duplicate`** — no two member `a` tags hold the same coordinate, compared as exact strings on the canonical form. Comparison is on the coordinate alone, so two tags naming one coordinate with different relay hints are duplicates. +7. **`metadata-cardinality`** — at most one each of `name`, `description`, `buzz-channel`, `buzz-visibility`. Duplicates would make the effective value reader-dependent. +8. **`metadata-length`** — `name` at most 256 bytes; `description` at most 2048 bytes; `buzz-channel` at most 256 bytes; `buzz-visibility` at most 256 bytes. The two `buzz-` bounds are generous by design: neither value has a semantic length, and the bound exists only so an unbounded string cannot ride into storage on a tag ingest does not interpret. + +Rules 3 through 6 are evaluated in that order, so an oversized tag list is refused on count before any per-tag parse or set proportional to it is built. + +Three checks land in the Buzz validator together with the fixture wiring that exercises them: the `buzz-channel` and `buzz-visibility` bounds in rule 8, and rule 4's arity. The validator bounds `name` and `description` today, and reads element 1 of each member `a` tag while ignoring any element past it. + +**Duplicates are rejected, never normalized.** A relay cannot dedupe tags inside a signed event: rewriting the tag array changes the event id and invalidates the signature. The choices are reject, or accept and require every present and future consumer to apply a first-wins interpretation rule. Rejecting keeps every stored head canonical and spares all consumers a defensive parse. + +**No membership authorization.** The relay MUST NOT check whether the signer owns, maintains, or has any relationship to a member repository. Referencing another owner's repository is legal and is the point of the kind. Because membership grants nothing ([Authority](#authority)), there is nothing to authorize. + +**Routing.** `kind:30621` is global-only, like every other NIP-34 kind in Buzz: it is addressed by `(pubkey, kind, d)` and is never channel-scoped. A stray `h` tag MUST NOT scope it to a channel — the `buzz-channel` tag is a metadata reference, not a routing directive. + +**Scope.** Writes require the `repos:write` scope, matching `kind:30617` and `kind:30618`. A project is repository metadata; a client authorized to announce repositories is authorized to group them. + +**Replacement** follows NIP-01 with no special cases: newest `created_at` wins per `(pubkey, 30621, d)`, and one pubkey can never overwrite another's coordinate. + +**Deletion** follows NIP-09 with two Buzz-wide behaviors that are not project-specific: + +- A `kind:5` naming the coordinate deletes it when signed by the project signer **or** by that signer's registered NIP-OA owner ([Deletion](#deletion)). +- The deletion applies only to versions whose `created_at` is at or before the deletion's own, per NIP-09. A delayed or replayed tombstone signed before the current head MUST NOT remove it; the relay MUST compare timestamps at the coordinate (`soft_delete_by_coordinate`, `crates/buzz-db/src/event.rs`, whose inclusive `created_at <= ` bound is introduced alongside this specification in [#3171](https://github.com/block/buzz/pull/3171)). + +## Client Behavior + +### Listing eligibility + +A project is **listing eligible** for a client when that client is currently rendering it in its project collection. A project is not listing eligible when: + +- its `buzz-visibility` is `unlisted`, or +- the viewer has hidden it locally, or +- it has been deleted, or its latest head is otherwise not being rendered. + +Only listing-eligible projects claim members. This keeps visibility deterministic in the case that otherwise breaks: an unlisted project must not make a repository the viewer can plainly see disappear from the collection, because the container that claims it is not on screen to hold it. + +### Claim authority + +A project **claims** a member — suppressing that repository's implicit card, per step 3 of the fold — only when the project is listing eligible *and* its signer is authorized by the member repository itself: the signer is the repository's owner (the pubkey in the member coordinate), or is listed in a `maintainers` tag on the repository's own live `kind:30617`. + +Authority is therefore read from the member repository's *content*, not merely its existence: a client that has resolved only a coordinate, and not the head it names, cannot yet decide whether a project claims it. `maintainers` is the standard NIP-34 multi-value tag; Buzz's own announcement builder does not emit it today, so in practice every current claim reduces to signer-is-owner, and the `maintainers` clause is what keeps a co-maintained repository working the day that changes. + +Without this rule, membership would carry exactly the authority [Authority](#authority) says it does not. Anyone may publish a project naming anyone's repository, so an unauthorized project that suppressed implicit cards would let a stranger pull someone else's repository out of the collection and into a container the owner never consented to — a signed assertion silently becoming control over another owner's discovery surface. + +An unauthorized project still renders, and still renders its members inside itself: cross-owner grouping works, which is the entire point of the kind. What it cannot do is *remove* a repository from where its owner expects to find it. The visible consequence is that a repository in a stranger's project renders in both places — inside that project and as its own card — which is the correct reading of an unendorsed grouping claim. + +### The fold + +Given the set of repositories and projects to render, a client MUST derive the collection as follows. + +1. **Enumerate exhaustively when possible.** Retrieve the latest live head of every `kind:30621` and `kind:30617` coordinate, plus the `kind:5` deletions bearing on them, using paginated queries. A fixed `limit` MUST NOT be used: with a limit of 200, repository 201 vanishes from the collection, which is precisely the compatibility guarantee this NIP owes existing repositories. What "to exhaustion" means depends on the cursor the relay offers — see [Pagination](#pagination). On a relay that does not provide an exhaustive mode, a client MUST mark the collection possibly incomplete rather than present a partial result as complete. +2. **Resolve members.** For each project, resolve each member coordinate to its repository head, and determine whether the project [claims](#claim-authority) each one. +3. **Suppress claimed implicit cards.** A live repository claimed by at least one project does not also render as an implicit single-repository card. +4. **Render multiple membership.** A repository belonging to several listing-eligible projects renders inside each of them, claimed or not. +5. **Fall back.** A repository claimed by no project renders as an implicit single-repository card — including when an unauthorized project also renders it as a member. +6. **Mark unresolvable members.** A member coordinate that resolves to nothing — never announced, deleted, or not present on this relay — renders inside its project as explicitly unavailable. It MUST NOT become a phantom standalone card, and it MUST NOT be silently dropped: silence makes a project look smaller than its author declared. +7. **Hiding a container never hides repositories.** Locally hiding a project makes it not listing eligible, so it claims nothing and by step 5 its members return as implicit cards. Hiding a grouping is a statement about the grouping. A repository disappears from the collection only when the viewer hides that repository or it is deleted — and a repository the viewer has hidden is hidden everywhere, including inside every project that lists it, so hiding one cannot be undone by someone else's grouping. + +The fold is deterministic: same heads in, same collection out, independent of arrival order or query shape. **Placement, not order, is what the fold fixes** — the collection of containers, the members rendered inside each container, and the implicit cards are all compared as sets, since member order is not significant in the event ([Event Format](#event-format)) and a client is free to sort its own presentation. Every live, unhidden repository renders in at least one place — inside a project that claims it, or as its own card — and no repository renders twice within one container. + +### Required fold cases + +The fold cannot be expressed as accept/reject of a single event, so it has its own fixture file rather than living in the ingest [conformance fixtures](#conformance-fixtures). A client implementing the fold MUST cover at least these cases, each of which is a distinct branch above: + +| Case | Expected collection | +|------|---------------------| +| Owner's own project lists their repository | Repository renders inside the project only | +| Stranger's project lists someone else's repository | Repository renders inside that project *and* as its own card | +| Project signer is in the member repository's `maintainers` tag | Repository renders inside the project only | +| Repository is a member of two projects that both claim it | Repository renders inside both; no implicit card | +| Repository removed from every project | Repository renders as an implicit card | +| Project is `unlisted`, or locally hidden | Project absent from the collection; its members render as implicit cards | +| Viewer has hidden a member repository | Repository absent from the collection *and* from inside every project listing it | +| Member coordinate resolves to nothing | Member renders inside its project as unavailable; no standalone card | +| Project head deleted | Project absent; its members render as implicit cards | +| One authorized and one unauthorized project both list the same repository | Repository renders inside both projects; no implicit card, because one claim suffices to suppress it | +| More repositories and projects than one page holds, with several sharing one `created_at` | Every repository and project renders | + +[`NIP-MP.fold-fixtures.json`](NIP-MP.fold-fixtures.json) mechanizes this table — see [Conformance Fixtures](#conformance-fixtures). + +### Pagination + +Step 1's "to exhaustion" describes the target result, not a single algorithm: what a client must do — and whether it can fully reach it — depends on the cursor its relay offers. Both modes below are conformant; a client MUST implement whichever its relay supports, MUST NOT present a mode-1 loop's output as complete on a mode-2 relay, and on a relay that provides neither mode 1 nor the relay contract below, MUST mark the collection possibly incomplete — presenting that marked partial collection is conformant, not a violation of step 1's enumeration requirement. + +**The relay contract both modes rest on.** Every "short response = done" inference — whether from a composite cursor or a drained bucket — is a property of the relay, not of NIP-01, where `limit` is advisory: relays "SHOULD use the `limit` value to guide how many events are returned in the initial response. Returning fewer events is acceptable" (NIP-01). A conforming relay may answer a request for 100 with 50 events and no indication that it withheld the rest, and the client cannot tell that from exhaustion. Exhaustive enumeration is possible only on a relay that satisfies **all three** of these conditions, which a client can evaluate independently: + +1. The relay **applies the complete filter before enforcing any limit.** A relay that post-filters after limiting can return a short (even empty) response while older matching events sit beyond the limited window, so short responses carry no exhaustion signal on such a relay. +2. The relay **exposes the exact effective page limit it enforces.** The effective page limit is the smaller of the requested `limit` and any relay-imposed cap, since a clamped request answered in full is short without being exhausted. If the advertised cap differs from the enforced one, "shorter than the effective limit" is undecidable by the client. +3. The relay **saturates pages**: after applying the complete filter and cursor, it returns `min(effective page limit, remaining matching events)` events — equivalently, whenever at least the effective limit's worth of matches remain, the page is full, so a short page contains all remaining matches. A cap bounds from above; without saturation, a relay may return fewer than the cap even when matches are still available, and a short page proves nothing. An authoritative relay-provided continuation or end signal computed after complete filtering is an equivalent substitute for this response-length inference. + +A relay satisfying any proper subset of these conditions does not provide the guarantee. Absent the guarantee, a client MUST mark the collection possibly incomplete regardless of any response sizes; the modes below serve to reduce silent loss rather than eliminate it. `limit` below means the effective page limit. + +**Mode 1 — composite cursor (exhaustive under the relay contract).** On a relay that exposes a keyset cursor over `(created_at, event id)`, a client MUST page by it. As an example of the cursor mechanics, Buzz implements the keyset as `created_at < until OR (created_at = until AND id > before_id)` (`crates/buzz-db/src/event.rs:48-52`), resolving the sort to `(created_at DESC, id ASC)`. Buzz exposes this cursor on its authenticated HTTP bridge endpoint (`crates/buzz-relay/src/api/bridge.rs`); it is not available on the NIP-01 websocket REQ path, where `before_id` is silently discarded — `protocol.rs` deserializes each REQ filter into a standard `nostr::Filter`, whose deserializer drops unknown fields, so a client sending `before_id` on a REQ receives no error and falls back to `until`-only paging without knowing it. A NIP-01 websocket client reading `kind:30621` from Buzz is therefore in mode 2, not mode 1; mode selection requires evaluating the relay contract per transport. Within the relay contract, the uniqueness of the `(created_at, id)` pair means each page resumes exactly where the last ended with no skips or re-reads, and a short page is an unambiguous end signal. Cursor uniqueness adds tie-safety; it does not substitute for the relay contract — a relay that post-filters after limiting can return an empty page under this cursor while older matching events remain beyond the candidate window. + +**Mode 2 — `until` only (boundary-bucket drain; exhaustive only under the relay contract).** A vanilla NIP-01 filter offers no id tiebreak, so the only cursor is `until`. Neither naive step is safe: `until = oldest_seen_created_at - 1` skips every unread event in that second, and `until = oldest_seen_created_at` re-requests the whole bucket, which never advances once one `created_at` bucket exceeds the relay's page size. A mode-2 client MUST therefore drain the boundary second explicitly before stepping past it. + +1. A page returning fewer than `limit` events means the query is exhausted — stop. +2. After a **full** page, let `oldest` be the smallest `created_at` it returned. Query that second exactly — `since = until = oldest` — and merge the result into what is already held, deduplicating by event id. That single bucket query has two outcomes. +3. If it returns `limit` events, second `oldest` may hold more than the relay will return in one response, so the collection MUST be marked possibly incomplete. Count `limit` inclusively: a bucket holding exactly `limit` events is indistinguishable from a larger one, and over-reporting a doubt is the safe direction. +4. If instead it returns fewer than `limit` events, the second is fully drained. Set `until = oldest - 1` and continue from step 1. + +A client that cannot drain a bucket has lost exhaustiveness for that second and MUST keep the collection marked possibly incomplete; it MAY still set `until = oldest - 1` to gather the older events rather than stall, but MUST NOT clear the mark by doing so. + +The naive form fails on a page whose oldest second is only partly returned, which a same-`created_at` test on the page as a whole does not see. With `limit = 3` over `(100,a) (99,b) (99,c) (99,d) (98,e)`, the first page is `(100,a) (99,b) (99,c)` — two distinct timestamps, so no all-tied heuristic fires — and advancing to `until = 98` silently drops `(99,d)`. Draining second `99` first retrieves it. + +Enumeration is therefore exhaustive when the relay satisfies the contract above and every equal-`created_at` bucket fits in one response; under those conditions truncation is detected exactly rather than guessed at. On detecting it — or on any relay that does not meet the contract — a client MUST mark the collection as possibly incomplete rather than present a partial collection as complete. Silently presenting a truncated collection is the failure this NIP exists to prevent: a repository missing from the list is indistinguishable from one that was never announced. + +**Query shapes.** The relay contract applies only where the relay can apply it — and that depends on the query shape. A relay that post-filters some constraints (such as `#a` tag matching applied after the SQL `LIMIT`) cannot guarantee short-response exhaustion for queries that use those constraints. A client MUST therefore issue fold queries in shapes whose full filter the relay applies before limiting. Where a needed constraint is not applied pre-limit on the target relay, the client MUST widen the query to constraints that are — for example, enumerating all `kind:5` events by `kinds` alone, or `kinds` + `authors`, rather than adding an `#a` filter the relay post-applies — and match the remaining criteria client-side. This keeps the relay contract's short-response guarantee intact for every query the fold issues. + +### Collection growth + +Step 1's exhaustive enumeration is a correctness floor, not a scaling strategy: it says a client MUST NOT silently truncate its collection, because a repository absent from the list is indistinguishable from one that does not exist. It is not a mandate to hold the relay's entire repository set in memory on every load. + +At Buzz's current scale (hundreds of repositories per community) exhaustive enumeration is the whole story. Past that, the way out is a narrower question — a server-side collection query, a scoped or searched subset, or resolving a project's members on demand — not a fixed client-side `limit`. Any such surface MUST report its own truncation so a client can say "showing N of M" rather than quietly presenting a partial collection as complete. + +### Route resolution + +A project route resolves to a container; a repository route resolves to a repository. Every repository-scoped operation — clone, fetch, issues, pull requests, activity, mutation, deletion — MUST take an explicit repository coordinate. None may infer its target from container state, or a two-repository project will silently operate on the wrong member. + +Legacy `:` repository routes remain valid and resolve to that repository, presented as a single-repository container. + +## Conformance Fixtures + +Two fixture files carry the machine-checkable contract. Neither has consumers yet; each states what its consumers are required to do. + +### Ingest + +[`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) holds the shared valid/invalid case set: 11 accepted and 20 rejected events covering minimal and full projects, zero members, the 64-member boundary from both sides, cross-owner and same-`d`-different-owner members, colon-bearing repository `d` values, relay hints, non-empty `content`, and each rejection rule above. + +The relay validator, the Rust builder, and the TypeScript builder are required to test against this one file, so a divergence between them is a test failure rather than a production surprise. + +Each case carries an **unsigned** template — `kind`, `content`, `tags`. Consumers sign it with their own test key. Signed literals would be inert: the id and signature are fixed by the exact serialization, so any consumer that re-serializes would need to recompute both anyway. Rejection cases name their `reject_rules`, so an implementation cannot pass by rejecting a bad event for an unrelated reason. + +### Fold + +[`NIP-MP.fold-fixtures.json`](NIP-MP.fold-fixtures.json) holds the oracle for [the fold](#the-fold): 12 cases covering every row of the [required fold cases](#required-fold-cases) table. Every client implementing the fold is required to test against this one file. The fold is where the [claim authority](#claim-authority) rule lives, so without a shared oracle two clients could each satisfy the prose and still render different collections from identical heads. + +Its cases are **semantic, not signed envelopes**. A repository or project is named by its coordinate plus the inputs the fold actually reads — signer, members, `maintainers`, visibility, viewer-hidden, deletion. Signing would test the ingest contract a second time and obscure what is under test: this file assumes every input is an already-accepted head and pins only the placement derived from it. Each case gives `expect.containers` (each rendered project with the members rendered inside it) and `expect.implicit_cards` (the repositories that additionally render as their own cards). Every collection in `expect` is compared as a set — the containers, each container's `members`, and the implicit cards alike — because the fold fixes placement and not order. + +## Security Considerations + +**Unauthorized grouping claims are the accepted trade.** Anyone may publish a project referencing anyone's repositories. That claim is a signed statement attributable to its author and grants nothing ([Authority](#authority)) — the same trust model as NIP-51 lists, which likewise reference content their author does not own. A client MUST NOT present membership in a stranger's project as endorsement by, or authority over, the member repository's owner, and MUST show the project signer alongside a project it did not author. + +**Resolution fan-out is bounded.** Each project resolves at most 64 coordinates, and the cap counts raw tags, so no single event can force unbounded resolution work regardless of how its tag list is shaped. + +**Push policy is untouched.** A project cannot grant, widen, or narrow push access to any repository. Push policy reads only the repository's own `kind:30617`. This is a design invariant, not an implementation detail: if a project ever became an input to push authorization, publishing a project naming someone else's repository would become a privilege-escalation primitive. + +## Relation to Other NIPs + +- **NIP-34**: Supplies the member repositories. Members are `kind:30617` announcements referenced by coordinate; a NIP-34 client that does not know `kind:30621` still discovers and renders each repository normally. +- **NIP-01**: Supplies the addressable-event class, the `a` tag grammar, addressing, replacement, and the owner-only editing model. Owner-only editing is not enforcement code in Buzz — it is what NIP-01 replacement already means. +- **NIP-09**: Supplies container deletion, which deletes the container only. Buzz extends it in two ways that are not project-specific: an agent's registered NIP-OA owner may also delete, and a tombstone applies only at or before its own `created_at` ([Deletion](#deletion)). +- **NIP-29**: Supplies the channel a project's `buzz-channel` names. The reference is metadata; project state is never channel-scoped. +- **NIP-51**: The closest existing precedent — a signed, addressable list referencing content the author need not own. Not reused because a project is a shared named forge container with its own channel binding and visibility, not a user's private-or-public bookmark set. +- **NIP-OA**: Consulted for container deletion only — an agent's registered owner may delete the agent's project ([Deletion](#deletion)). Push access is unaffected: agents inherit repository push access from their owner through the repository's own protections, and a project is never consulted. From 4933672eb4589e7208b312829ebddcd10dfa9dd3 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:37:27 -0400 Subject: [PATCH 61/99] feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) (#3741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This is **part 1 of 2** split out from #3467 (per Tyler's request), carrying only the mesh-scoped changes. The agent/ACP response-behavior changes and the new `send_message` tool stay in #3467 as part 2. All commits are @michaelneale's work, cherry-picked with authorship preserved. - Upgrade embedded Mesh to v0.74.0 (tag-pinned instead of commit rev) and use canonical Gemma model IDs. - Keep shared compute serving through member joins, roster changes, app recovery, and community switching. - Wait for actual model readiness and avoid resuming incomplete downloads after quit. - Leave `BUZZ_AGENT_THINKING_EFFORT` unset by default so each model's chat template picks its own thinking default (`none` suppressed Gemma tool-calling entirely; pinning `low` made Qwen3 burn ~4x output budget). Explicit agent/persona/global values still win. ## Relationship to #3467 Contains the mesh commits from #3467 (`2cd640b23`, `0ad81c341`, `ad13ed841`) rebased onto current main, with one deliberate exclusion: the `crates/buzz-agent/src/llm.rs` reasoning→text parser change from `2cd640b23` is **not** here. That change unconditionally affects every OpenAI-compat/Responses provider, so it belongs with the reply-behavior work in part 2, where it can be reviewed as what it is. Not included (remaining in #3467 / part 2): - typed `send_message` tool in dev-mcp + `BUZZ_ACP_SEND_MESSAGE_TOOL` gating - plain-reply delivery fallback in buzz-acp (`BUZZ_ACP_DELIVER_PLAIN_REPLIES`) - the mesh_agent_e2e P5/P6 rewrite (exists to prove the reply path) - the two `env.insert` preset opt-ins in `relay_mesh.rs` for the flags above - the llm.rs parser change This PR is independently mergeable; part 2's flags are all off by default so it can land before or after. ## Testing - `cargo test -p buzz-relay --locked` — 780 passed (one telemetry test is order-sensitive under parallel default settings; passes in the pre-push suite and standalone, unrelated to this diff — files untouched here). - `just desktop-tauri-test` (default features) — 1877 passed. - `cargo test --locked --features mesh-llm` in `desktop/src-tauri` — 1961 passed, including the new relay-mesh preset and coordinator/recovery tests. - Both `Cargo.lock`s resolve with `--locked` against the v0.74.0 tag. - Full pre-push hook suite green (rust-tests, desktop-check/test, tauri checks). Live validation of the mesh v0.74 upgrade itself is documented on #3467 (two-Mac cross-version test). --------- Signed-off-by: Michael Neale Signed-off-by: Tyler Longwell Co-authored-by: Michael Neale Co-authored-by: Tyler Longwell --- Cargo.lock | 193 +++++++++-------- crates/buzz-relay/Cargo.toml | 4 +- crates/buzz-relay/examples/mesh_agent_e2e.rs | 4 +- desktop/src-tauri/Cargo.lock | 201 ++++++++++-------- desktop/src-tauri/Cargo.toml | 12 +- desktop/src-tauri/src/commands/mesh_llm.rs | 172 ++++++++++++--- .../src-tauri/src/commands/mesh_llm_tests.rs | 69 ++++++ .../src/managed_agents/relay_mesh.rs | 113 ++++++++-- .../src-tauri/src/managed_agents/runtime.rs | 7 +- desktop/src-tauri/src/mesh_llm/catalog.rs | 42 +++- desktop/src-tauri/src/mesh_llm/coordinator.rs | 159 +++++++------- desktop/src-tauri/src/mesh_llm/mod.rs | 23 +- desktop/src-tauri/src/mesh_llm/mod_tests.rs | 1 + desktop/src-tauri/src/mesh_llm/recovery.rs | 83 ++++++-- .../ui/MeshComputeSettingsCard.tsx | 9 +- desktop/src/testing/e2eBridge.ts | 27 ++- desktop/tests/e2e/mesh-compute.spec.ts | 22 +- 17 files changed, 781 insertions(+), 360 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49104b22d2..ea5b02aaab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.4", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -2177,7 +2190,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.117", ] [[package]] @@ -3009,8 +3022,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3609,7 +3622,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4501,8 +4514,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "mesh-llm-client", @@ -4511,8 +4524,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4522,13 +4535,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-llm-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4559,8 +4572,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4575,8 +4588,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4585,8 +4598,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "clap", @@ -4597,8 +4610,8 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "cc", @@ -4610,8 +4623,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -4619,16 +4632,16 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "mesh-llm-native-runtime", ] [[package]] name = "mesh-llm-host-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "argon2", @@ -4650,7 +4663,6 @@ dependencies = [ "http", "http-body-util", "httparse", - "if-addrs", "iroh", "json5", "keyring", @@ -4698,6 +4710,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "skippy-coordinator", + "skippy-ffi", "skippy-protocol", "skippy-runtime", "skippy-server", @@ -4720,8 +4733,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", "base64", @@ -4742,8 +4755,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "serde", @@ -4753,8 +4766,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-types", @@ -4767,8 +4780,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4784,8 +4797,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4795,6 +4808,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "zip", @@ -4802,8 +4816,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "hex", @@ -4815,16 +4829,16 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4846,8 +4860,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4861,8 +4875,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -4872,8 +4886,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "chrono", @@ -4895,8 +4909,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "serde", @@ -4906,13 +4920,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-mixture-of-agents" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5038,8 +5052,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5049,8 +5063,8 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5067,8 +5081,8 @@ dependencies = [ [[package]] name = "model-package" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "bytes", @@ -5087,16 +5101,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "model-artifact", @@ -5815,8 +5829,8 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openai-frontend" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "axum", @@ -7368,9 +7382,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64", @@ -7401,9 +7415,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -8153,8 +8167,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "blake3", @@ -8163,29 +8177,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "skippy-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "prost", "prost-build", @@ -8195,8 +8209,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "libc", @@ -8209,9 +8223,10 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ + "ahash", "anyhow", "async-trait", "axum", @@ -8237,8 +8252,8 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.73.1#43103c5c40292be688ac0261129bcbab0e7b9132" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -10465,8 +10480,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.62.2", - "windows-core 0.62.2", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 01f78a2d49..41bdc3b9e9 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -84,8 +84,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.73.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } buzz-core = { workspace = true, features = ["test-utils"] } buzz-auth = { workspace = true, features = ["dev"] } reqwest = { workspace = true } diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-relay/examples/mesh_agent_e2e.rs index b6f723f35a..345ca4c746 100644 --- a/crates/buzz-relay/examples/mesh_agent_e2e.rs +++ b/crates/buzz-relay/examples/mesh_agent_e2e.rs @@ -278,7 +278,9 @@ async fn agent_chat_in_isolated_home( .env("OPENAI_COMPAT_API_KEY", "buzz-mesh-local") .env("OPENAI_COMPAT_API", "chat") .env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096") - .env("BUZZ_AGENT_THINKING_EFFORT", "none") + // No BUZZ_AGENT_THINKING_EFFORT: apply_relay_mesh_env() deliberately + // leaves it unset so each model's chat template picks its own default. + // Pinning a value here would test a config the product does not ship. .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 7e23289f53..cd0fabb69f 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -75,6 +75,19 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if 1.0.4", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -1511,7 +1524,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -1748,7 +1761,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] @@ -2152,7 +2165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 2.0.118", ] [[package]] @@ -3102,8 +3115,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -3848,7 +3861,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.5.3", + "windows-registry 0.6.1", ] [[package]] @@ -3863,7 +3876,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -4939,8 +4952,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "mesh-llm-client", @@ -4949,8 +4962,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4960,13 +4973,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-llm-client" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -4997,8 +5010,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5013,8 +5026,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5023,8 +5036,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "clap", @@ -5035,8 +5048,8 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "cc", @@ -5048,8 +5061,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -5057,16 +5070,16 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "mesh-llm-native-runtime", ] [[package]] name = "mesh-llm-host-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "argon2", @@ -5088,7 +5101,6 @@ dependencies = [ "http", "http-body-util", "httparse", - "if-addrs", "iroh", "json5", "keyring", @@ -5136,6 +5148,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "skippy-coordinator", + "skippy-ffi", "skippy-protocol", "skippy-runtime", "skippy-server", @@ -5158,8 +5171,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "argon2", "base64 0.22.1", @@ -5180,8 +5193,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "serde", @@ -5191,8 +5204,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-types", @@ -5205,8 +5218,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5222,8 +5235,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5233,6 +5246,7 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", + "sha2 0.10.9", "tar", "tempfile", "zip 2.4.2", @@ -5240,8 +5254,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "hex", @@ -5253,16 +5267,16 @@ dependencies = [ [[package]] name = "mesh-llm-routing" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5284,8 +5298,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5299,8 +5313,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "dirs", @@ -5310,8 +5324,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "chrono", @@ -5333,8 +5347,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "hex", "serde", @@ -5344,13 +5358,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "mesh-mixture-of-agents" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5422,8 +5436,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5433,8 +5447,8 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "async-trait", @@ -5451,8 +5465,8 @@ dependencies = [ [[package]] name = "model-package" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "bytes", @@ -5471,16 +5485,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "model-artifact", @@ -6134,7 +6148,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 2.0.2", "proc-macro2", "quote", "syn 2.0.118", @@ -6508,8 +6522,8 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "async-trait", "axum", @@ -6702,7 +6716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -7412,7 +7426,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools", "log", "multimap", @@ -9041,8 +9055,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "blake3", @@ -9051,29 +9065,29 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" [[package]] name = "skippy-protocol" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "prost", "prost-build", @@ -9083,8 +9097,8 @@ dependencies = [ [[package]] name = "skippy-runtime" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "anyhow", "libc", @@ -9097,9 +9111,10 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ + "ahash", "anyhow", "async-trait", "axum", @@ -9125,8 +9140,8 @@ dependencies = [ [[package]] name = "skippy-topology" -version = "0.73.1" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=f455d493a2ae82baf2a326e2d0fda351433b4b30#f455d493a2ae82baf2a326e2d0fda351433b4b30" +version = "0.74.0" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ "serde", "serde_json", @@ -10153,7 +10168,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -11789,7 +11804,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -12418,8 +12433,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b5b1191852..735a45c3b7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -96,14 +96,14 @@ buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" } buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" } iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "f455d493a2ae82baf2a326e2d0fda351433b4b30", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 305c54a203..998bc6e7d2 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -5,12 +5,35 @@ use tauri::{AppHandle, Manager, State}; use crate::{app_state::AppState, mesh_llm, relay}; -#[derive(Debug, serde::Deserialize, serde::Serialize)] +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] struct MeshSharingConfig { enabled: bool, + /// A fresh Share Compute request that must cross a process boundary before + /// it can start. Consumed before startup so an interrupted download is not + /// resumed on a later launch. + #[serde(default)] + start_on_next_launch: bool, model_id: String, max_vram_gb: Option, + /// Community relay where Share Compute was explicitly enabled. Older + /// configs predate community binding and restore against the active relay. + #[serde(default)] + relay_url: Option, +} + +fn pending_new_start_checkpoint(config: &MeshSharingConfig) -> MeshSharingConfig { + let mut checkpoint = config.clone(); + checkpoint.enabled = false; + checkpoint.start_on_next_launch = false; + checkpoint +} + +fn one_shot_restart_checkpoint(config: &MeshSharingConfig) -> MeshSharingConfig { + let mut checkpoint = config.clone(); + checkpoint.enabled = false; + checkpoint.start_on_next_launch = true; + checkpoint } fn mesh_sharing_config_path(app: &AppHandle) -> Result { @@ -89,8 +112,10 @@ fn sharing_config_from_request( .ok_or_else(|| "modelId is required for serve mode".to_string())?; Ok(MeshSharingConfig { enabled: true, + start_on_next_launch: false, model_id: model_id.to_string(), max_vram_gb: request.max_vram_gb, + relay_url: request.relay_url.clone(), }) } @@ -117,7 +142,7 @@ fn restart_to_share( app: &AppHandle, config: &MeshSharingConfig, ) -> CmdResult { - save_mesh_sharing_config(app, config)?; + save_mesh_sharing_config(app, &one_shot_restart_checkpoint(config))?; let status = restarting_share_status(config); app.request_restart(); Ok(status) @@ -133,7 +158,7 @@ fn buzz_mesh_name_for_relay(relay_url: &str) -> String { format!("buzz-community-{}", &digest[..32]) } -fn buzz_mesh_name(state: &AppState) -> String { +pub(super) fn buzz_mesh_name(state: &AppState) -> String { buzz_mesh_name_for_relay(&relay::relay_ws_url_with_override(state)) } @@ -150,8 +175,13 @@ fn advance_mesh_status_cursor( Ok(cursor) } -async fn query_mesh_discovery_events(state: &AppState) -> Result, String> { - let mut events = relay::query_relay(state, &[mesh_llm::relay_membership_filter()]).await?; +async fn query_mesh_discovery_events_at( + state: &AppState, + relay_url: &str, +) -> Result, String> { + let api_base_url = relay::relay_http_base_url(relay_url); + let mut events = + relay::query_relay_at(state, &api_base_url, &[mesh_llm::relay_membership_filter()]).await?; let member_pubkeys = mesh_llm::current_member_pubkeys(&events); if member_pubkeys.is_empty() { // Distinguish "relay returned a membership snapshot listing zero @@ -172,7 +202,7 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result = None; loop { - let page = relay::query_relay(state, &[status_filter.clone()]).await?; + let page = relay::query_relay_at(state, &api_base_url, &[status_filter.clone()]).await?; let done = page.len() < mesh_llm::MESH_STATUS_PAGE_SIZE; if !done { let cursor = advance_mesh_status_cursor(&mut status_filter, &page)?; @@ -188,6 +218,10 @@ async fn query_mesh_discovery_events(state: &AppState) -> Result Result, String> { + query_mesh_discovery_events_at(state, &relay::relay_ws_url_with_override(state)).await +} + /// Resolve the admission roster by intersecting member-signed mesh status /// reporters with the current NIP-43 direct-member list. /// @@ -201,6 +235,14 @@ pub(crate) async fn resolve_trusted_owner_ids(state: &AppState) -> Result Result, String> { + let events = query_mesh_discovery_events_at(state, relay_url).await?; + Ok(mesh_llm::owner_ids_from_events(&events)) +} + /// Resolve the roster for an initial node *start*, failing closed to self-only /// (an empty roster) when the relay query fails. This is safe only at start: /// there is no established allowlist to preserve yet. The periodic @@ -244,10 +286,11 @@ fn buzz_mesh_join_targets( /// Resolve the validated member endpoint this runtime should join to enter the /// existing Buzz community mesh. `Ok(None)` means this machine is the first /// live serving member (or is itself the shared bootstrap contact). -pub(crate) async fn resolve_buzz_mesh_join_targets( +pub(crate) async fn resolve_buzz_mesh_join_targets_at( state: &AppState, + relay_url: &str, ) -> Result, String> { - let events = query_mesh_discovery_events(state).await?; + let events = query_mesh_discovery_events_at(state, relay_url).await?; let self_owner_id = mesh_llm::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))? .owner_id; @@ -261,8 +304,11 @@ pub(crate) async fn resolve_buzz_mesh_join_targets( /// snapshot. A node start used to repeat the full membership + status query /// for each value, making Share Compute startup both slower and more exposed /// to inconsistent snapshots. -async fn resolve_buzz_mesh_startup(state: &AppState) -> (Vec, Option) { - match query_mesh_discovery_events(state).await { +async fn resolve_buzz_mesh_startup_at( + state: &AppState, + relay_url: &str, +) -> (Vec, Option) { + match query_mesh_discovery_events_at(state, relay_url).await { Ok(events) => { let trusted_owner_ids = mesh_llm::owner_ids_from_events(&events); let join_token = mesh_llm::ensure_owner_identity() @@ -291,32 +337,60 @@ async fn resolve_buzz_mesh_startup(state: &AppState) -> (Vec, Option CmdResult<()> { - let Some(config) = load_mesh_sharing_config(app)? else { + let Some(mut config) = load_mesh_sharing_config(app)? else { return Ok(()); }; - if !config.enabled || config.model_id.trim().is_empty() { + if (!config.enabled && !config.start_on_next_launch) || config.model_id.trim().is_empty() { return Ok(()); } + config.model_id = mesh_llm::canonical_curated_model_id(&config.model_id).to_string(); if state.mesh_llm_runtime.lock().await.is_some() { return Ok(()); } - let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(state).await; + let relay_url = config + .relay_url + .clone() + .unwrap_or_else(|| relay::relay_ws_url_with_override(state)); + let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup_at(state, &relay_url).await; let mut runtime = state.mesh_llm_runtime.lock().await; if runtime.is_some() { return Ok(()); } + if config.start_on_next_launch { + // Consume a role-switch request before doing any potentially long model + // work. If Buzz exits during that work, the next launch stays stopped. + config = pending_new_start_checkpoint(&config); + save_mesh_sharing_config(app, &config)?; + } + // This is restoration of a previously inference-ready serving node. Keep + // the enabled checkpoint armed while restoring so a transient startup + // failure does not silently turn Share Compute off. New starts remain + // disarmed in `mesh_start_node` until their first inference probe passes. let request = mesh_llm::StartMeshNodeRequest { mode: mesh_llm::MeshNodeMode::Serve, - model_id: Some(config.model_id), + model_id: Some(config.model_id.clone()), max_vram_gb: config.max_vram_gb, join_token, - mesh_name: Some(buzz_mesh_name(state)), + mesh_name: Some(buzz_mesh_name_for_relay(&relay_url)), + relay_url: Some(relay_url), trusted_owner_ids: Some(trusted_owner_ids), }; let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("failed to restore Share Compute: {error:#}"))?; + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + let cleanup = started.stop().await; + if let Err(cleanup_error) = cleanup { + eprintln!( + "buzz-mesh: restored node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" + ); + } + return Err(format!("failed to restore Share Compute: {error}")); + } *runtime = Some(started); + config.enabled = true; + config.start_on_next_launch = false; + save_mesh_sharing_config(app, &config)?; drop(runtime); mesh_llm::publish_current_status_once(app, "restore").await; Ok(()) @@ -328,6 +402,11 @@ pub async fn mesh_start_node( state: State<'_, AppState>, mut request: mesh_llm::StartMeshNodeRequest, ) -> CmdResult { + let relay_url = relay::relay_ws_url_with_override(&state); + request.relay_url = Some(relay_url.clone()); + if let Some(model_id) = request.model_id.as_mut() { + *model_id = mesh_llm::canonical_curated_model_id(model_id).to_string(); + } let sharing_config = if request.mode == mesh_llm::MeshNodeMode::Serve { Some(sharing_config_from_request(&request)?) } else { @@ -362,13 +441,14 @@ pub async fn mesh_start_node( // Frontend requests never carry a roster. Resolve it and the bootstrap // endpoint from one snapshot so UI startup does not repeat relay probes. if request.trusted_owner_ids.is_none() || request.join_token.is_none() { - let (trusted_owner_ids, join_token) = resolve_buzz_mesh_startup(&state).await; + let (trusted_owner_ids, join_token) = + resolve_buzz_mesh_startup_at(&state, &relay_url).await; request.trusted_owner_ids.get_or_insert(trusted_owner_ids); if request.join_token.is_none() { request.join_token = join_token; } } - request.mesh_name = Some(buzz_mesh_name(&state)); + request.mesh_name = Some(buzz_mesh_name_for_relay(&relay_url)); let mut runtime = state.mesh_llm_runtime.lock().await; let plan = match runtime.as_ref() { @@ -386,6 +466,13 @@ pub async fn mesh_start_node( return Err("mesh node is already running".to_string()); } + if let Some(config) = sharing_config.as_ref() { + // Do not arm launch restoration until the exact inference path used by + // agents succeeds. Mesh may bind its ports after primary weights load + // while package layers are still downloading. + save_mesh_sharing_config(&app, &pending_new_start_checkpoint(config))?; + } + let started = mesh_llm::DesktopMeshRuntime::start(request) .await .map_err(|error| format!("{error:#}"))?; @@ -409,6 +496,21 @@ pub async fn mesh_start_node( )); } }; + if let Some(config) = sharing_config.as_ref() { + if let Err(error) = wait_for_mesh_inference(&config.model_id).await { + let cleanup = started.stop().await; + if let Err(cleanup_error) = &cleanup { + eprintln!( + "buzz-mesh: started node failed inference readiness and cleanup was incomplete: {cleanup_error:#}" + ); + } + drop(runtime); + app.request_restart(); + return Err(format!( + "mesh node started but inference never became ready: {error}; Buzz is restarting to guarantee cleanup" + )); + } + } *runtime = Some(started); drop(runtime); if let Some(config) = sharing_config.as_ref() { @@ -612,6 +714,7 @@ pub(crate) async fn ensure_client_node_for_model( max_vram_gb: None, join_token: Some(join_token.clone()), mesh_name: Some(buzz_mesh_name(state)), + relay_url: Some(relay::relay_ws_url_with_override(state)), trusted_owner_ids: Some(resolve_trusted_owner_ids_or_self_only(state).await), }; let mut runtime = state.mesh_llm_runtime.lock().await; @@ -753,6 +856,18 @@ pub(crate) async fn ensure_relay_mesh_for_record( } } } + + // A persisted Share Compute configuration is authoritative about this + // machine's role. If no runtime is currently tracked (for example after a + // clean process restart), restore the serving node instead of treating an + // agent request as permission to replace it with a client node. + if load_mesh_sharing_config(app)? + .is_some_and(|config| config.enabled && !config.model_id.trim().is_empty()) + { + restore_mesh_sharing(app, &state).await?; + return wait_for_mesh_inference(model_id).await; + } + let target = match resolve_mesh_bootstrap_target(&state, model_id).await { Ok(Some(target)) => target, Ok(None) => { @@ -768,15 +883,9 @@ pub(crate) async fn ensure_relay_mesh_for_record( } }; - // Serve→Client re-arm transition (micspiral review #3, intentional-by-design): - // if the dead ingress belonged to a *serve* node with running consumer - // agents, this re-arms it as a Client (`MeshNodeMode::Client`). That is the - // correct/safe recovery here — config-backed serve restoration is - // `restore_mesh_sharing`'s job (`MeshNodeMode::Serve`), and - // `ensure_client_node_for_model` reuses any live runtime of *either* mode - // (the router resolves per-request), so it only cold-starts a Client when - // there is genuinely no runtime. Falling back to Client if a serve node - // crashed under local pressure is a desirable fail-safe, not a regression. + // No serving configuration exists, so this is a genuine consumer-only + // start. A configured serving machine is restored above and never reaches + // this client fallback. ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?; wait_for_mesh_inference(model_id).await } @@ -792,14 +901,17 @@ pub async fn mesh_stop_node( // role under the lock and, when it's a consume session, leave it running // and return its live status unchanged. The frontend also guards this, but // status can be stale between polls, so the backend is authoritative. - let taken = { + let (taken, bound_relay_url) = { let mut guard = state.mesh_llm_runtime.lock().await; if let Some(runtime) = guard.as_ref() { if !share_stop_should_teardown(runtime.mode()) { return runtime.status().await.map_err(|error| error.to_string()); } } - guard.take() + let bound_relay_url = guard + .as_ref() + .and_then(|runtime| runtime.start_request().relay_url.clone()); + (guard.take(), bound_relay_url) }; if let Some(runtime) = taken { runtime.stop().await.map_err(|error| error.to_string())?; @@ -808,11 +920,13 @@ pub async fn mesh_stop_node( &app, &MeshSharingConfig { enabled: false, + start_on_next_launch: false, model_id: String::new(), max_vram_gb: None, + relay_url: None, }, )?; - mesh_llm::publish_stopped_status_once(&app, "stop").await; + mesh_llm::publish_stopped_status_once_at(&app, bound_relay_url.as_deref(), "stop").await; Ok(mesh_llm::stopped_status()) } diff --git a/desktop/src-tauri/src/commands/mesh_llm_tests.rs b/desktop/src-tauri/src/commands/mesh_llm_tests.rs index ccc5287d62..26eb1f5fba 100644 --- a/desktop/src-tauri/src/commands/mesh_llm_tests.rs +++ b/desktop/src-tauri/src/commands/mesh_llm_tests.rs @@ -110,6 +110,74 @@ fn buzz_mesh_name_is_stable_and_does_not_expose_the_relay() { assert!(!first.contains("example")); } +#[test] +fn sharing_config_keeps_the_community_where_sharing_was_enabled() { + let request = mesh_llm::StartMeshNodeRequest { + mode: mesh_llm::MeshNodeMode::Serve, + model_id: Some("test-model".to_string()), + max_vram_gb: Some(24), + join_token: None, + mesh_name: Some("buzz-community-test".to_string()), + relay_url: Some("wss://community.example".to_string()), + trusted_owner_ids: Some(Vec::new()), + }; + + let config = sharing_config_from_request(&request).expect("valid sharing config"); + assert_eq!(config.relay_url.as_deref(), Some("wss://community.example")); +} + +#[test] +fn legacy_sharing_config_without_community_binding_still_loads() { + let config: MeshSharingConfig = serde_json::from_value(serde_json::json!({ + "enabled": true, + "modelId": "test-model", + "maxVramGb": null + })) + .expect("legacy sharing config"); + + assert_eq!(config.relay_url, None); + assert!(!config.start_on_next_launch); +} + +#[test] +fn new_start_checkpoint_prevents_incomplete_download_restore() { + let config = MeshSharingConfig { + enabled: true, + start_on_next_launch: false, + model_id: "test-model".to_string(), + max_vram_gb: Some(24), + relay_url: Some("wss://community.example".to_string()), + }; + + let checkpoint = pending_new_start_checkpoint(&config); + assert!(!checkpoint.enabled); + assert!(!checkpoint.start_on_next_launch); + assert_eq!(checkpoint.model_id, config.model_id); + assert_eq!(checkpoint.max_vram_gb, config.max_vram_gb); + assert_eq!(checkpoint.relay_url, config.relay_url); +} + +#[test] +fn role_switch_checkpoint_starts_exactly_once_after_restart() { + let config = MeshSharingConfig { + enabled: true, + start_on_next_launch: false, + model_id: "test-model".to_string(), + max_vram_gb: Some(24), + relay_url: Some("wss://community.example".to_string()), + }; + + let restart = one_shot_restart_checkpoint(&config); + assert!(!restart.enabled); + assert!(restart.start_on_next_launch); + + let consumed = pending_new_start_checkpoint(&restart); + assert!(!consumed.enabled); + assert!(!consumed.start_on_next_launch); + assert_eq!(consumed.model_id, config.model_id); + assert_eq!(consumed.relay_url, config.relay_url); +} + #[test] fn readiness_failure_is_catalog_sync_when_model_never_visible() { assert_eq!( @@ -345,6 +413,7 @@ fn ensure_serve_runtime_serves_other_model() { max_vram_gb: None, join_token: None, mesh_name: None, + relay_url: None, trusted_owner_ids: None, }) .await diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 7a6f5b094a..327c106bc8 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -42,15 +42,51 @@ pub fn apply_relay_mesh_env( RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(), "1".to_string(), ); - // Keep the requested response inside smaller local-model context windows, - // and spend that budget on an answer/tool call instead of hidden reasoning. - // Without both settings Qwen3 either fails the router's fit check at the - // agent default (32K) or can consume a tight cap before serializing a tool. - env.insert( - "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), - "4096".to_string(), - ); - env.insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "none".to_string()); + // Keep the requested response inside smaller local-model context windows. + // These are defaults, not policy: the effective agent/persona/global env + // may deliberately choose a smaller cap or a different effort. This function + // runs after those layers during readiness, so never clobber their values. + insert_default_if_unset(env, "BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096"); + // Deliberately no BUZZ_AGENT_THINKING_EFFORT default: mesh translates + // `reasoning_effort` into the chat template's `enable_thinking` flag, so any + // value we pick overrides each model's own template default — and the right + // value is model-specific. Measured with the real prompt and toolset: + // gemma-4-E4B delivers 0/8 at `none` but 6/6 with the field absent, while + // Qwen3-8B delivers 8/8 either way and burns ~4x the output tokens once + // thinking is on (121 -> ~470), risking the 4096 cap. Omitting the field + // lets every model use its own default; explicit agent/persona/global + // values still apply. +} + +#[cfg(feature = "mesh-llm")] +fn insert_default_if_unset( + env: &mut std::collections::BTreeMap, + key: &str, + value: &str, +) { + if env.get(key).is_none_or(|current| current.trim().is_empty()) { + env.insert(key.to_string(), value.to_string()); + } +} + +/// Build the final Mesh-specific process overrides from the already-resolved +/// harness environment. Only user-owned generation controls are seeded: the +/// derived provider/base URL/model values remain authoritative, and unrelated +/// credentials (notably `OPENAI_API_KEY`) must not be copied back after the +/// spawn path removes them. +#[cfg(feature = "mesh-llm")] +pub fn relay_mesh_process_env( + effective_env: &std::collections::BTreeMap, + model: &str, +) -> std::collections::BTreeMap { + let mut env = std::collections::BTreeMap::new(); + for key in ["BUZZ_AGENT_MAX_OUTPUT_TOKENS", "BUZZ_AGENT_THINKING_EFFORT"] { + if let Some(value) = effective_env.get(key) { + env.insert(key.to_string(), value.clone()); + } + } + apply_relay_mesh_env(&mut env, Some(RELAY_MESH_PROVIDER_ID), Some(model)); + env } #[cfg(all(test, feature = "mesh-llm"))] @@ -60,7 +96,7 @@ mod tests { use super::*; #[test] - fn native_provider_uses_context_safe_non_reasoning_budget() { + fn native_provider_uses_context_safe_tool_calling_budget() { let mut env = BTreeMap::new(); apply_relay_mesh_env( &mut env, @@ -72,14 +108,63 @@ mod tests { env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), Some("4096") ); - assert_eq!( - env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str), - Some("none") - ); + // Must stay unset: any value we pick overrides the model's own chat + // template default, and the right value is model-specific ("none" + // stops gemma tool-calling; enabling thinking makes Qwen3 burn ~4x the + // output budget). + assert_eq!(env.get("BUZZ_AGENT_THINKING_EFFORT"), None); assert_eq!( env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV) .map(String::as_str), Some("1") ); } + + #[test] + fn native_provider_preserves_explicit_generation_controls() { + let mut env = BTreeMap::from([ + ( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "2048".to_string(), + ), + ("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()), + ]); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), + Some("2048") + ); + assert_eq!( + env.get("BUZZ_AGENT_THINKING_EFFORT").map(String::as_str), + Some("high") + ); + } + + #[test] + fn process_env_seeds_controls_without_restoring_unrelated_credentials() { + let effective_env = BTreeMap::from([ + ( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "1024".to_string(), + ), + ("OPENAI_API_KEY".to_string(), "must-not-leak".to_string()), + ]); + + let env = relay_mesh_process_env(&effective_env, "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_MAX_OUTPUT_TOKENS").map(String::as_str), + Some("1024") + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some("Gemma-4") + ); + assert!(!env.contains_key("OPENAI_API_KEY")); + } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index f3b4cb67fd..37927961ed 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -869,12 +869,7 @@ pub fn spawn_agent_child( // uses the same trim semantics as the preflight callers. #[cfg(feature = "mesh-llm")] if let Some(ref mesh_model_id) = mesh_model_id { - let mut mesh_env = std::collections::BTreeMap::new(); - super::apply_relay_mesh_env( - &mut mesh_env, - Some(super::RELAY_MESH_PROVIDER_ID), - Some(mesh_model_id.as_str()), - ); + let mesh_env = super::relay_mesh_process_env(&descriptor.env, mesh_model_id); command.env_remove("OPENAI_API_KEY"); for (key, value) in mesh_env { command.env(key, value); diff --git a/desktop/src-tauri/src/mesh_llm/catalog.rs b/desktop/src-tauri/src/mesh_llm/catalog.rs index 385971cb86..1a11fcfcd1 100644 --- a/desktop/src-tauri/src/mesh_llm/catalog.rs +++ b/desktop/src-tauri/src/mesh_llm/catalog.rs @@ -19,12 +19,14 @@ use mesh_llm_system::vram::{format_rated_capacity, rated_capacity_gb}; /// The large pick is resolved through mesh-llm's remote catalog /// (huggingface.co/datasets/meshllm/catalog), so it does not need to exist in /// the compiled `MODEL_CATALOG`; the entry is synthesized below. -const CURATED_LARGE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M"; +const CURATED_LARGE: &str = "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"; +const CURATED_LARGE_ALIAS: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M"; const CURATED_LARGE_SIZE: &str = "17GB"; const CURATED_LARGE_FILE: &str = "gemma-4-26B-A4B-it-UD-Q4_K_M.gguf"; const CURATED_LARGE_DESCRIPTION: &str = "Gemma 4 26B MoE (4B active) — Buzz default for 64GB+ machines"; -const CURATED_SMALL: &str = "Gemma-4-E4B-it-Q4_K_M"; +const CURATED_SMALL: &str = "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"; +const CURATED_SMALL_ALIAS: &str = "Gemma-4-E4B-it-Q4_K_M"; /// Rated-capacity boundary between the two curated tiers, in GB (marketing /// capacity — a "64GB" Mac rates as 64 even though usable AI memory is less). const CURATED_LARGE_MIN_RATED_GB: u64 = 64; @@ -37,6 +39,16 @@ fn buzz_recommended_model(rated_gb: Option) -> &'static str { } } +/// Convert Buzz's pre-0.74 curated package aliases into the canonical model +/// ids advertised and accepted by Mesh's OpenAI ingress. +pub(crate) fn canonical_curated_model_id(model_id: &str) -> &str { + match model_id.trim() { + CURATED_SMALL_ALIAS => CURATED_SMALL, + CURATED_LARGE_ALIAS => CURATED_LARGE, + other => other, + } +} + /// How a model sits inside this machine's usable AI memory. /// Mirrors mesh-llm's private `fit_code_for_size_label` thresholds. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] @@ -146,12 +158,13 @@ fn build_catalog( .filter(|m| !is_draft_only(&m.name)) .map(|m| { let size_gb = parse_size_gb(&m.size); + let name = canonical_curated_model_id(&m.name).to_string(); MeshCatalogEntry { fit: fit_code(size_gb, vram_gb), - installed: is_installed(&m.file, &m.name), + installed: is_installed(&m.file, &name) || is_installed(&m.file, &m.name), recommended: false, curated: false, - name: m.name.clone(), + name, size: m.size.clone(), size_gb, description: m.description.clone(), @@ -166,7 +179,8 @@ fn build_catalog( let size_gb = parse_size_gb(CURATED_LARGE_SIZE); entries.push(MeshCatalogEntry { fit: fit_code(size_gb, vram_gb), - installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE), + installed: is_installed(CURATED_LARGE_FILE, CURATED_LARGE) + || is_installed(CURATED_LARGE_FILE, CURATED_LARGE_ALIAS), recommended: false, curated: false, name: CURATED_LARGE.to_string(), @@ -256,6 +270,8 @@ mod tests { #[test] fn recommendation_follows_buzz_curated_tiers() { + assert_eq!(CURATED_SMALL, "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"); + assert_eq!(CURATED_LARGE, "unsloth/gemma-4-26B-A4B-it-GGUF:UD-Q4_K_M"); // 64GB+ rated machines get the large curated pick. let large = build_catalog(None, 64_000_000_000, 64.0, &[]); assert_eq!(large.recommended.as_deref(), Some(CURATED_LARGE)); @@ -269,6 +285,22 @@ mod tests { assert_eq!(tiny.recommended.as_deref(), Some(CURATED_SMALL)); } + #[test] + fn curated_package_aliases_migrate_to_openai_model_ids() { + assert_eq!( + canonical_curated_model_id(CURATED_SMALL_ALIAS), + CURATED_SMALL + ); + assert_eq!( + canonical_curated_model_id(CURATED_LARGE_ALIAS), + CURATED_LARGE + ); + assert_eq!( + canonical_curated_model_id("other/model:Q4"), + "other/model:Q4" + ); + } + #[test] fn curated_picks_lead_the_catalog() { let catalog = build_catalog(None, 96_000_000_000, 96.0, &[]); diff --git a/desktop/src-tauri/src/mesh_llm/coordinator.rs b/desktop/src-tauri/src/mesh_llm/coordinator.rs index 1e279353b6..066fa46373 100644 --- a/desktop/src-tauri/src/mesh_llm/coordinator.rs +++ b/desktop/src-tauri/src/mesh_llm/coordinator.rs @@ -132,7 +132,7 @@ pub async fn start_coordinator(app: AppHandle) { /// MeshLLM establishes the encrypted peer transport itself. async fn reconcile_buzz_mesh_join(app: &AppHandle) -> Result<(), String> { let state = app.state::(); - let peer_ids = { + let (peer_ids, relay_url) = { let runtime = state.mesh_llm_runtime.lock().await; let Some(runtime) = runtime.as_ref() else { return Ok(()); @@ -141,10 +141,16 @@ async fn reconcile_buzz_mesh_join(app: &AppHandle) -> Result<(), String> { .status_report_payload() .await .map_err(|error| error.to_string())?; - visible_peer_ids(&payload) + let relay_url = runtime + .start_request() + .relay_url + .clone() + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(&state)); + (visible_peer_ids(&payload), relay_url) }; - let targets = crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state).await?; + let targets = + crate::commands::mesh_llm::resolve_buzz_mesh_join_targets_at(&state, &relay_url).await?; let Some(target) = targets .into_iter() .find(|target| !target_is_visible(target, &peer_ids)) @@ -201,8 +207,13 @@ fn target_is_visible(target: &crate::mesh_llm::MeshServeTarget, peer_ids: &[Stri enum RosterReconcileAction { /// Keep the running allowlist untouched (no-op, or a failure we ride out). Keep, - /// Restart the node with a freshly resolved roster. - Restart(Vec), + /// Restart Buzz so MeshLLM is rebuilt with a freshly resolved roster. + /// + /// MeshLLM's native listeners are process-owned in practice: stopping and + /// starting the embedded runtime in one process can terminate Buzz or race + /// ports 9337/3131. The process boundary is therefore part of the safety + /// contract, not an implementation detail. + RestartProcess, /// Observed a *shrink* (or empty) once. Hold the current allowlist and /// require the same reduced roster on the next poll before tearing down, /// so a single transient short-read never drops a member mid-inference. @@ -224,9 +235,9 @@ fn roster_shrinks(current: &[String], fresh: &[String]) -> bool { /// Rules: /// - query failed (`Err`) → `Keep` (never de-admit on a relay blip) /// - resolved roster == current → `Keep` (no-op) -/// - grows (only additions) → `Restart` immediately (fast admission) +/// - grows (only additions) → `RestartProcess` immediately (fast admission) /// - shrinks/empties, first observation → `AwaitConfirm` (hold, re-check next poll) -/// - shrinks/empties, confirmed → `Restart` (same reduced roster twice) +/// - shrinks/empties, confirmed → `RestartProcess` (same reduced roster twice) fn roster_reconcile_action( current_owners: &[String], pending_shrink: Option<&[String]>, @@ -248,13 +259,13 @@ fn roster_reconcile_action( // Growth (pure additions) is safe to apply immediately. if !roster_shrinks(current_owners, &fresh) { - return RosterReconcileAction::Restart(fresh); + return RosterReconcileAction::RestartProcess; } // A shrink (including down to empty) must be confirmed across two // consecutive polls with the *same* reduced roster before we tear down. match pending_shrink { - Some(pending) if pending == fresh => RosterReconcileAction::Restart(fresh), + Some(pending) if pending == fresh => RosterReconcileAction::RestartProcess, _ => RosterReconcileAction::AwaitConfirm(fresh), } } @@ -283,8 +294,13 @@ async fn reconcile_roster( // other member on a transient relay blip (the flapping restart loop). Keep // the current allowlist and try again on the next poll. A shrink is held // for one extra poll (hysteresis) so a single short-read never tears down. - let query = crate::commands::mesh_llm::resolve_trusted_owner_ids(&state).await; - let fresh = match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) { + let relay_url = current_request + .relay_url + .as_deref() + .map(str::to_owned) + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(&state)); + let query = crate::commands::mesh_llm::resolve_trusted_owner_ids_at(&state, &relay_url).await; + match roster_reconcile_action(current_owners, pending_shrink.as_deref(), query) { RosterReconcileAction::Keep => { *pending_shrink = None; return Ok(()); @@ -294,34 +310,12 @@ async fn reconcile_roster( *pending_shrink = Some(reduced); return Ok(()); } - RosterReconcileAction::Restart(fresh) => { + RosterReconcileAction::RestartProcess => { *pending_shrink = None; - fresh } - }; + } - let mut request = current_request.clone(); - request.trusted_owner_ids = Some(fresh); - // Bootstrap endpoints are live device state, not configuration. The - // endpoint used at the previous start may belong to the member that just - // left or to a device whose iroh identity rotated while offline. Resolve a - // fresh validated peer for this restart; starting isolated is safe because - // the join watcher will converge it when a member next publishes. - request.join_token = match crate::commands::mesh_llm::resolve_buzz_mesh_join_targets(&state) - .await - { - Ok(targets) => targets - .into_iter() - .next() - .map(|target| target.endpoint_addr), - Err(error) => { - eprintln!( - "buzz-mesh: could not refresh bootstrap endpoint for roster restart; starting isolated: {error}" - ); - None - } - }; - let mut guard = state.mesh_llm_runtime.lock().await; + let guard = state.mesh_llm_runtime.lock().await; let startup_pending = match guard.as_ref() { Some(runtime) => runtime.is_starting().await, None => false, @@ -342,24 +336,14 @@ async fn reconcile_roster( // snapshot. return Ok(()); } - let Some(running) = guard.take() else { + if guard.is_none() { return Ok(()); - }; - eprintln!("buzz-mesh: membership roster changed; restarting mesh node with fresh allowlist"); - if let Err(error) = running.stop().await { - drop(guard); - eprintln!( - "buzz-mesh: stopping mesh node for roster restart failed; restarting Buzz instead of racing the occupied ingress: {error}" - ); - app.request_restart(); - return Err(format!( - "mesh node shutdown failed during roster change: {error}" - )); } - let replacement = crate::mesh_llm::DesktopMeshRuntime::start(request) - .await - .map_err(|error| format!("mesh node restart after roster change failed: {error:#}"))?; - *guard = Some(replacement); + drop(guard); + eprintln!( + "buzz-mesh: membership roster changed; restarting Buzz to rebuild MeshLLM with the fresh community allowlist" + ); + app.request_restart(); Ok(()) } @@ -377,11 +361,15 @@ pub(crate) async fn publish_current_status_once(app: &AppHandle, reason: &str) { } } -pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) { +pub(crate) async fn publish_stopped_status_once_at( + app: &AppHandle, + relay_url: Option<&str>, + reason: &str, +) { let state = app.state::(); match tokio::time::timeout( STATUS_PUBLISH_TIMEOUT, - publish_stopped_status_for_state(&state), + publish_stopped_status_for_state(&state, relay_url), ) .await { @@ -396,26 +384,43 @@ pub(crate) async fn publish_stopped_status_once(app: &AppHandle, reason: &str) { async fn publish_current_status_for_state(state: &AppState) -> Result<(), String> { let identity = super::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; - let mut payload = { + let (mut payload, relay_url) = { let runtime = state.mesh_llm_runtime.lock().await; match runtime.as_ref() { - Some(runtime) => runtime - .status_report_payload() - .await - .map_err(|error| error.to_string())?, - None => stopped_status_payload(&identity), + Some(runtime) => { + let payload = runtime + .status_report_payload() + .await + .map_err(|error| error.to_string())?; + let relay_url = runtime + .start_request() + .relay_url + .clone() + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(state)); + (payload, relay_url) + } + None => ( + stopped_status_payload(&identity), + crate::relay::relay_ws_url_with_override(state), + ), } }; bind_payload_to_member(state, &identity, &mut payload)?; - publish_status_report(state, payload).await + publish_status_report_at(state, &relay_url, payload).await } -async fn publish_stopped_status_for_state(state: &AppState) -> Result<(), String> { +async fn publish_stopped_status_for_state( + state: &AppState, + relay_url: Option<&str>, +) -> Result<(), String> { let identity = super::ensure_owner_identity() .map_err(|error| format!("failed to load mesh owner identity: {error}"))?; let mut payload = stopped_status_payload(&identity); bind_payload_to_member(state, &identity, &mut payload)?; - publish_status_report(state, payload).await + let relay_url = relay_url + .map(str::to_owned) + .unwrap_or_else(|| crate::relay::relay_ws_url_with_override(state)); + publish_status_report_at(state, &relay_url, payload).await } fn stopped_status_payload(identity: &super::identity::OwnerIdentity) -> serde_json::Value { @@ -469,13 +474,21 @@ pub(crate) fn build_status_report_event( .tags([d, k])) } -pub(crate) async fn publish_status_report( +async fn publish_status_report_at( state: &AppState, + relay_url: &str, payload: serde_json::Value, ) -> Result<(), String> { - crate::relay::submit_event(build_status_report_event(payload)?, state) - .await - .map(|_| ()) + let api_base_url = crate::relay::relay_http_base_url(relay_url); + let keys = state.signing_keys()?; + crate::relay::submit_event_at_with_keys( + build_status_report_event(payload)?, + state, + &api_base_url, + &keys, + ) + .await + .map(|_| ()) } #[cfg(test)] @@ -554,11 +567,11 @@ mod tests { // Growth (pure additions) applies immediately — fast admission is fine. #[test] - fn roster_growth_restarts_immediately() { + fn roster_growth_requests_process_restart_immediately() { let current = vec!["owner-a".to_string()]; let fresh = vec!["owner-a".to_string(), "owner-c".to_string()]; - let action = roster_reconcile_action(¤t, None, Ok(fresh.clone())); - assert_eq!(action, RosterReconcileAction::Restart(fresh)); + let action = roster_reconcile_action(¤t, None, Ok(fresh)); + assert_eq!(action, RosterReconcileAction::RestartProcess); } // A shrink is NOT applied on first observation — it must be confirmed. @@ -572,11 +585,11 @@ mod tests { // The same reduced roster on two consecutive polls confirms the shrink. #[test] - fn roster_shrink_restarts_once_confirmed() { + fn roster_shrink_requests_process_restart_once_confirmed() { let current = vec!["owner-a".to_string(), "owner-b".to_string()]; let reduced = vec!["owner-a".to_string()]; let action = roster_reconcile_action(¤t, Some(&reduced), Ok(reduced.clone())); - assert_eq!(action, RosterReconcileAction::Restart(reduced)); + assert_eq!(action, RosterReconcileAction::RestartProcess); } // A shrink that changes between polls is not confirmed — it re-holds with @@ -600,7 +613,7 @@ mod tests { assert_eq!(first, RosterReconcileAction::AwaitConfirm(Vec::new())); let empty: Vec = Vec::new(); let confirmed = roster_reconcile_action(¤t, Some(&empty), Ok(Vec::new())); - assert_eq!(confirmed, RosterReconcileAction::Restart(Vec::new())); + assert_eq!(confirmed, RosterReconcileAction::RestartProcess); } // A shrink followed by recovery to the full roster cancels the teardown. diff --git a/desktop/src-tauri/src/mesh_llm/mod.rs b/desktop/src-tauri/src/mesh_llm/mod.rs index 6e3ab4b28b..e206c53886 100644 --- a/desktop/src-tauri/src/mesh_llm/mod.rs +++ b/desktop/src-tauri/src/mesh_llm/mod.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; mod coordinator; -pub(crate) use coordinator::{publish_current_status_once, publish_stopped_status_once}; +pub(crate) use coordinator::{publish_current_status_once, publish_stopped_status_once_at}; pub use coordinator::{start_coordinator, MeshCoordinator, KIND_BUZZ_MESH_MEMBER_STATUS}; mod discovery; @@ -14,6 +14,7 @@ pub(crate) use discovery::{ use discovery::{device_name_from_status, endpoint_id_from_status, enrich_status_payload_identity}; mod catalog; +pub(crate) use catalog::canonical_curated_model_id; pub use catalog::{model_catalog, MeshModelCatalog}; mod identity; @@ -200,6 +201,11 @@ pub struct StartMeshNodeRequest { /// accepted from the frontend and contains no relay address. #[serde(default, skip_deserializing)] pub mesh_name: Option, + /// Relay this runtime's community membership and discovery are bound to. + /// Injected by the backend when sharing starts and retained across UI + /// workspace switches; moving a share requires an explicit stop/start. + #[serde(default, skip_deserializing)] + pub relay_url: Option, /// Mesh owner ids admitted to this node (the member roster from /// member-signed discovery notes). `None` = caller did not resolve a roster /// (tests, direct invocations): the node runs without allowlist @@ -308,17 +314,20 @@ pub const MESH_WORKER_STACK_SIZE: usize = 8 * 1024 * 1024; /// before the node starts. Without this the download happens *inside* /// `serve::start()` where the UI can only show a frozen "starting…" state. /// Already-installed models return immediately from the cache scan. -async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> { - let model_owned = model.to_string(); - let installed = tokio::task::spawn_blocking(move || { +async fn model_is_installed(model: &str) -> bool { + let model_owned = model.replace("@main", ""); + tokio::task::spawn_blocking(move || { let cache = mesh_llm_node::models::default_huggingface_cache_dir(); mesh_llm_node::models::scan_installed_models(cache) .iter() - .any(|m| m.model_ref.contains(&model_owned)) + .any(|m| m.model_ref.replace("@main", "").contains(&model_owned)) }) .await - .unwrap_or(false); - if installed { + .unwrap_or(false) +} + +async fn ensure_model_downloaded(model: &str) -> anyhow::Result<()> { + if model_is_installed(model).await { return Ok(()); } mesh_llm_host_runtime::models::download_model_ref_with_progress_details(model, true) diff --git a/desktop/src-tauri/src/mesh_llm/mod_tests.rs b/desktop/src-tauri/src/mesh_llm/mod_tests.rs index 0b726c264f..557cd040fa 100644 --- a/desktop/src-tauri/src/mesh_llm/mod_tests.rs +++ b/desktop/src-tauri/src/mesh_llm/mod_tests.rs @@ -12,6 +12,7 @@ fn pending_client_runtime( max_vram_gb: None, join_token: Some("initial-token".to_string()), mesh_name: None, + relay_url: None, trusted_owner_ids: None, }; super::DesktopMeshRuntime { diff --git a/desktop/src-tauri/src/mesh_llm/recovery.rs b/desktop/src-tauri/src/mesh_llm/recovery.rs index 809fab8993..89ca6396e9 100644 --- a/desktop/src-tauri/src/mesh_llm/recovery.rs +++ b/desktop/src-tauri/src/mesh_llm/recovery.rs @@ -153,6 +153,13 @@ fn should_evict_after_probe( || consecutive >= DEAD_PROBE_EVICT_THRESHOLD } +fn requires_process_restart( + mode: crate::mesh_llm::MeshNodeMode, + startup_in_progress: bool, +) -> bool { + startup_in_progress || mode == crate::mesh_llm::MeshNodeMode::Serve +} + /// Probe and, when justified, remove one stale runtime. A closed port is /// decisive for a foreground agent start; watchdog and ambiguous/unhealthy /// ports require consecutive failures to avoid restarting on a transient load @@ -161,22 +168,23 @@ pub(crate) async fn recover_stale_mesh_runtime( state: &AppState, urgency: MeshRecoveryUrgency, ) -> MeshRuntimeRecovery { - let (candidate_id, startup_in_progress) = match state.mesh_llm_runtime.lock().await.as_ref() { - Some(runtime) => (runtime.id(), runtime.is_starting().await), - None => { - state.mesh_recovery.reset_probe_streak(); - // A cancelled SDK startup can outlive its Buzz-side task briefly - // because the embedded runtime runs on its own thread. Never start - // a replacement merely because the tracked handle is gone: first - // prove the old ingress is either still useful or has released the - // port. This closes the port-conflict loop in #2304. - return match probe_mesh_ingress().await { - MeshIngressProbe::Live => MeshRuntimeRecovery::Live, - MeshIngressProbe::PortClosed => MeshRuntimeRecovery::Absent, - MeshIngressProbe::Unhealthy => MeshRuntimeRecovery::ReleasePending, - }; - } - }; + let (candidate_id, startup_in_progress, candidate_mode) = + match state.mesh_llm_runtime.lock().await.as_ref() { + Some(runtime) => (runtime.id(), runtime.is_starting().await, runtime.mode()), + None => { + state.mesh_recovery.reset_probe_streak(); + // A cancelled SDK startup can outlive its Buzz-side task briefly + // because the embedded runtime runs on its own thread. Never start + // a replacement merely because the tracked handle is gone: first + // prove the old ingress is either still useful or has released the + // port. This closes the port-conflict loop in #2304. + return match probe_mesh_ingress().await { + MeshIngressProbe::Live => MeshRuntimeRecovery::Live, + MeshIngressProbe::PortClosed => MeshRuntimeRecovery::Absent, + MeshIngressProbe::Unhealthy => MeshRuntimeRecovery::ReleasePending, + }; + } + }; let probe = probe_mesh_ingress().await; if probe == MeshIngressProbe::Live { state.mesh_recovery.reset_probe_streak(); @@ -196,12 +204,13 @@ pub(crate) async fn recover_stale_mesh_runtime( return MeshRuntimeRecovery::Debouncing; } - // The pinned SDK does not yield its control handle until the management - // API is ready. Dropping its still-pending start future would detach the - // embedded runtime thread without sending a shutdown request, so Buzz must - // not evict it and race a replacement onto the same ports. A controlled - // app relaunch is the only process-owned cleanup boundary in this state. - if startup_in_progress { + // Never replace a serving runtime in-process. Its native listeners and + // model host are process-owned; stopping it here and then cold-starting a + // client silently disables Share Compute and can race ports 9337/3131. + // Pending client startups have the same ownership problem because the SDK + // has not yielded a shutdown handle yet. In both cases, process restart is + // the only boundary that preserves the configured role safely. + if requires_process_restart(candidate_mode, startup_in_progress) { state.mesh_recovery.reset_probe_streak(); return MeshRuntimeRecovery::RestartRequired; } @@ -241,6 +250,12 @@ pub(crate) async fn recover_stale_mesh_runtime( pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Result<(), String> { let state = app.state::(); let _rearm_guard = state.mesh_recovery.rearm_lock.lock().await; + let runtime_mode = state + .mesh_llm_runtime + .lock() + .await + .as_ref() + .map(|runtime| runtime.mode()); let recovery = recover_stale_mesh_runtime(&state, MeshRecoveryUrgency::Watchdog).await; let active_pubkeys = active_managed_agent_pubkeys(&state); // Mesh participation is resolved through the same definition-authoritative @@ -254,6 +269,13 @@ pub(crate) async fn rearm_relay_mesh_for_running_agents(app: &AppHandle) -> Resu | MeshRuntimeRecovery::Debouncing | MeshRuntimeRecovery::Replaced => return Ok(()), MeshRuntimeRecovery::RestartRequired => { + if runtime_mode == Some(crate::mesh_llm::MeshNodeMode::Serve) { + eprintln!( + "buzz-mesh: serving ingress failed; restarting Buzz to restore Share Compute without changing roles" + ); + app.request_restart(); + return Ok(()); + } let records = crate::managed_agents::load_managed_agents(app).unwrap_or_default(); if !records.iter().any(|record| { running_relay_mesh_model_id(record, &active_pubkeys, &personas, &global).is_some() @@ -410,6 +432,7 @@ mod tests { name_pool: Vec::new(), is_builtin: false, is_active: true, + shared: false, source_team: None, source_team_persona_slug: None, catalog_source: None, @@ -481,6 +504,22 @@ mod tests { assert!(STALE_STOP_TIMEOUT <= Duration::from_secs(15)); } + #[test] + fn failed_serving_runtime_requires_process_restart_instead_of_client_fallback() { + assert!(requires_process_restart( + crate::mesh_llm::MeshNodeMode::Serve, + false + )); + assert!(requires_process_restart( + crate::mesh_llm::MeshNodeMode::Client, + true + )); + assert!(!requires_process_restart( + crate::mesh_llm::MeshNodeMode::Client, + false + )); + } + #[test] fn only_running_relay_mesh_agents_trigger_rearm() { let personas: Vec = Vec::new(); diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index 76f86f8054..fd7550eff0 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -107,7 +107,9 @@ export function MeshComputeSettingsCard() { // One-shot hardware-aware catalog fetch. Purely additive: when it fails // (stub build, survey error) the card falls back to the free-text field. - // Keep an empty draft empty so the UI can explicitly ask the member to choose. + // When there is no saved choice, make the curated recommendation the actual + // default so a new member can turn Share Compute on directly. An explicit + // saved draft always wins. React.useEffect(() => { let cancelled = false; (async () => { @@ -115,6 +117,11 @@ export function MeshComputeSettingsCard() { const value = await meshModelCatalog(); if (cancelled) return; setCatalog(value); + setModelInput((current) => { + if (current.trim() !== "" || !value.recommended) return current; + writeDraft(MODEL_DRAFT_STORAGE_KEY, value.recommended); + return value.recommended; + }); } catch { // Non-fatal — picker just doesn't render. } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 96948c31df..73b564429a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2890,9 +2890,7 @@ const mockMeshState: { servingUsage: MockServingUsage; } = { admitted: true, - models: [ - { id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" }, - ], + models: [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }], denyReason: "not a relay member", nodeState: "off", nodeMode: null, @@ -2901,9 +2899,7 @@ const mockMeshState: { function resetMockMesh() { mockMeshState.admitted = true; - mockMeshState.models = [ - { id: "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M", name: "SmolLM2 135M" }, - ]; + mockMeshState.models = [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }]; mockMeshState.denyReason = "not a relay member"; mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; @@ -9744,6 +9740,25 @@ export function maybeInstallE2eTauriMocks() { } case "mesh_installed_models": return mockMeshState.models; + case "mesh_model_catalog": + return { + gpuName: "Mock Apple GPU", + vramDisplay: "32 GB", + vramGb: 32, + recommended: "Gemma-4-E4B-it-Q4_K_M", + entries: [ + { + name: "Gemma-4-E4B-it-Q4_K_M", + size: "3.5GB", + sizeGb: 3.5, + description: "Buzz-curated local agent model", + fit: "comfortable", + installed: true, + recommended: true, + curated: true, + }, + ], + }; case "mesh_node_status": return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode); case "mesh_serving_usage": diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index 7c360368a2..b2e8fc28a9 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -15,7 +15,7 @@ type E2eWindow = Window & { }) => void; }; -test("Share compute has a clear empty state and starts and stops sharing", async ({ +test("Share compute selects the curated default and starts and stops sharing", async ({ page, }) => { await installMockBridge(page); @@ -30,22 +30,32 @@ test("Share compute has a clear empty state and starts and stops sharing", async await expect(card).toContainText( "Choose a suggested model below, or enter a model reference or local file", ); - await expect(toggle).toBeDisabled(); - - await model.fill("hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M"); + await expect(model).toHaveValue("Gemma-4-E4B-it-Q4_K_M"); + await expect(toggle).toBeEnabled(); await expect(card).toContainText( "Buzz downloads remote models when sharing starts", ); - await expect(toggle).toBeEnabled(); await toggle.click(); await expect(toggle).toBeChecked(); - await expect(card).toContainText("Sharing SmolLM2 135M with relay members"); + await expect(card).toContainText("Sharing Gemma 4 E4B with relay members"); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), ) .toContain("mesh_start_node"); + await expect + .poll(() => + page.evaluate( + () => (window as E2eWindow).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [], + ), + ) + .toContainEqual({ + command: "mesh_start_node", + payload: { + request: { mode: "serve", modelId: "Gemma-4-E4B-it-Q4_K_M" }, + }, + }); await toggle.click(); await expect(toggle).not.toBeChecked(); From 02be413b823c356587e6e9f4d07f6cb06bb41c3c Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 30 Jul 2026 11:38:28 -0400 Subject: [PATCH 62/99] feat(catalog): resolve publisher display name in catalog detail pane (#3640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog detail pane hardcoded "Community member" for every non-own catalog entry. The publisher pubkey (`catalogSource.ownerPubkey`) was already on every entry — it just was not being resolved to a name. ## What changed **`desktop/src/features/agents/ui/PersonaCatalogDialog.tsx`** `PersonaCatalogDetail` now calls `useUsersBatchQuery([ownerPubkey])` when the selected entry is a community (non-own) catalog agent. The label derivation is extracted into the exported pure function `resolveCatalogOwnerLabel` and uses truthy fallbacks to handle empty or whitespace-only kind:0 fields: - Own entry → `"You"` (unchanged) - `displayName` present and non-blank → the display name - `displayName` absent/blank but `name` present and non-blank → the name - Loading, unresolvable, or both candidates blank → `"Community member"` (fallback preserved) The batch query is disabled (`enabled: false`) when the entry is not a community entry, so there is no extra network call for own entries or built-in agents. **`desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs`** Unit tests for `resolveCatalogOwnerLabel` covering: populated `displayName` wins; whitespace-only `displayName` falls through to `name`; both candidates empty/whitespace/null/undefined all fall through to `"Community member"`. **`desktop/tests/e2e/agents.spec.ts`** - Updated the existing assertion — it previously checked for the hardcoded fallback; now asserts the resolved mock display name `"alice"`. - Added "catalog detail shows Community member when the publisher profile cannot be resolved" — installs a catalog event from an unknown pubkey and asserts the fallback still renders. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> --- .../agents/ui/PersonaCatalogDialog.tsx | 45 +++++++++-- .../ui/personaCatalogOwnerLabel.test.mjs | 77 +++++++++++++++++++ desktop/tests/e2e/agents.spec.ts | 36 ++++++++- 3 files changed, 149 insertions(+), 9 deletions(-) create mode 100644 desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index ba76d6e4ed..e1ec946092 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; import { isCatalogPersona } from "@/features/agents/lib/personaCatalogRelay"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; @@ -276,7 +277,42 @@ function PersonaCatalogChooser({ ); } +/** + * Derives the "Added by" label for a catalog entry from a resolved profile + * summary. Prefers `displayName`, falls back to `name`, then to the default + * "Community member" string when both are absent, null, or whitespace-only. + */ +export function resolveCatalogOwnerLabel( + summary: + | { displayName?: string | null; name?: string | null } + | null + | undefined, +): string { + return ( + summary?.displayName?.trim() || summary?.name?.trim() || "Community member" + ); +} + function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { + const isCommunityEntry = + isCatalogPersona(persona) && !persona.catalogSource.isOwn; + const ownerPubkey = isCommunityEntry + ? persona.catalogSource.ownerPubkey + : undefined; + const ownerBatchQuery = useUsersBatchQuery(ownerPubkey ? [ownerPubkey] : [], { + enabled: !!ownerPubkey, + }); + + let addedByLabel: string; + if (!isCommunityEntry) { + addedByLabel = "You"; + } else { + const summary = ownerPubkey + ? ownerBatchQuery.data?.profiles[ownerPubkey.toLowerCase()] + : undefined; + addedByLabel = resolveCatalogOwnerLabel(summary); + } + return (
@@ -290,14 +326,7 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) { {persona.displayName} {persona.isBuiltIn ? null : ( - + )}
diff --git a/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs new file mode 100644 index 0000000000..7ad726352f --- /dev/null +++ b/desktop/src/features/agents/ui/personaCatalogOwnerLabel.test.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveCatalogOwnerLabel } from "./PersonaCatalogDialog.tsx"; + +// ── null / undefined summary ────────────────────────────────────────────────── + +test("test_null_summary_returns_community_member", () => { + assert.equal(resolveCatalogOwnerLabel(null), "Community member"); +}); + +test("test_undefined_summary_returns_community_member", () => { + assert.equal(resolveCatalogOwnerLabel(undefined), "Community member"); +}); + +// ── populated displayName ───────────────────────────────────────────────────── + +test("test_display_name_present_returns_display_name", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: "Alice", name: "alice" }), + "Alice", + ); +}); + +test("test_display_name_present_without_name_returns_display_name", () => { + assert.equal(resolveCatalogOwnerLabel({ displayName: "Alice" }), "Alice"); +}); + +// ── empty / whitespace displayName with valid name ──────────────────────────── + +test("test_empty_display_name_falls_through_to_name", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: "", name: "alice" }), + "alice", + ); +}); + +test("test_whitespace_only_display_name_falls_through_to_name", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: " ", name: "alice" }), + "alice", + ); +}); + +// ── both candidates absent / empty ──────────────────────────────────────────── + +test("test_both_null_returns_community_member", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: null, name: null }), + "Community member", + ); +}); + +test("test_both_empty_returns_community_member", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: "", name: "" }), + "Community member", + ); +}); + +test("test_both_whitespace_returns_community_member", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: " ", name: "\t" }), + "Community member", + ); +}); + +test("test_display_name_absent_name_present_returns_name", () => { + assert.equal(resolveCatalogOwnerLabel({ name: "alice" }), "alice"); +}); + +test("test_display_name_null_name_present_returns_name", () => { + assert.equal( + resolveCatalogOwnerLabel({ displayName: null, name: "alice" }), + "alice", + ); +}); diff --git a/desktop/tests/e2e/agents.spec.ts b/desktop/tests/e2e/agents.spec.ts index 42d4aac114..3cbe097c05 100644 --- a/desktop/tests/e2e/agents.spec.ts +++ b/desktop/tests/e2e/agents.spec.ts @@ -1736,8 +1736,10 @@ test("a community member can discover and add another member's catalog agent", a ); await expect(remoteEntry).toContainText("Alice’s Reviewer"); await remoteEntry.click(); + // The detail pane resolves the publisher's display name; 'Community member' + // is only the fallback for an unresolvable pubkey. await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( - "Added by Community member", + "Added by alice", ); await page @@ -1790,6 +1792,38 @@ test("a community member can discover and add another member's catalog agent", a expect(await countCommandInvocations(page, "create_persona")).toBe(1); }); +test("catalog detail shows Community member when the publisher profile cannot be resolved", async ({ + page, +}) => { + // A pubkey that is not in the mock profile registry — profile resolution + // will fail and the detail pane must fall back gracefully. + const unknownPubkey = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const personaId = "unresolvable-reviewer"; + await installMockBridge(page, { + personaCatalogEvents: [ + createCatalogEvent({ + ownerPubkey: unknownPubkey, + sourcePersonaId: personaId, + displayName: "Mystery Agent", + systemPrompt: "Published by someone whose profile cannot be fetched.", + }), + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await openPersonaCatalog(page); + + await page + .getByTestId( + `persona-catalog-list-item-catalog:${unknownPubkey}:${personaId}`, + ) + .click(); + await expect(page.getByTestId("persona-catalog-detail-pane")).toContainText( + "Added by Community member", + ); +}); + test("one share level selector drives both the link and send paths", async ({ page, }) => { From 53771c8f5439f9c5c26876f0229bfcfe5da9b170 Mon Sep 17 00:00:00 2001 From: Cameron Hotchkies Date: Thu, 30 Jul 2026 09:43:52 -0700 Subject: [PATCH 63/99] fix(acp): preserve truncated thread context (#3340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Long Buzz threads were rendered as `[Thread Context (13 of 13 messages)]` because the harness counted only the already-limited query result. That hid older context and could also hide the agent's own prior reply in busy threads. ## What - Fetch one extra thread reply as a sentinel so truncated context is labeled correctly. - Use a best-effort `/count` call for improved truncated totals when available, clamped to the sentinel-proven minimum so racy counts cannot render impossible labels. - Keep the `/count` path single-attempt with a short timeout and only add the root to exact totals when the root was actually fetched. - Fetch and preserve the agent's newest prior reply when it falls outside the recent window, with exact event-id matching for the pin/dedup boundary. - Add parser and fetch-boundary tests for truncation, exact count, missing root, count-below-minimum clamping, count failure fallback, distinct fetched-reply lower bounds, agent-reply dedup/pinning, and serialized query/count filter semantics. ## Risk Assessment Low-to-medium — limited to buzz-acp prompt context fetching and a small RestClient helper. If `/count` fails or times out, the code falls back to the sentinel-derived minimum total rather than failing the prompt. The synchronous `/count` happens only for truncated thread contexts and is bounded to one short best-effort attempt. ## References - Buzz thread: chotchkies-buzz-bombing-flakes / `7ef71407f1c7a642382c7e48e0c80fb6ca66948890e04d1eb6f1408c3b7278b1` - Validation at `c1cfd1b16a04a3ac1d1d0d3cf43e1a08508f3532`: - `cargo fmt -p buzz-acp` ✅ - `cargo test -p buzz-acp test_fetch_thread_context -- --nocapture` ✅ (6 tests) - `cargo test -p buzz-acp parse_nostr_thread_response` ✅ - `cargo test -p buzz-acp` ✅ (649 unit + 9 lifecycle tests) - `git diff --check` ✅ - Push was completed with `--no-verify` after pre-push hooks reached non-code local environment failures: `flutter` missing for `mobile-test`; Node.js v20.20.2 too old for pnpm/node:sqlite in `desktop-check` and `desktop-test`. Earlier hook stages passed: `check-push-org`, `branch-skew`, `rust-tests`, `test`, `desktop-tauri-checks`. - Earlier full `./bin/just ci` at `622ed7eb8807d64e06209101569b1013414af091` ⚠️ passed Rust/desktop/web stages, then failed in `mobile-test` on unrelated existing mobile test `ChannelDetailPage keeps follow mode off while a tall newest message stays visible`; rerunning that single mobile test reproduced the same failure without touching mobile code. Generated with Codex Signed-off-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 Co-authored-by: npub1m0vvn9qm5md0a080p27qzkm9uaw49e699ukwfq7fc0756xq0y5zqhzhdk2 --- crates/buzz-acp/src/pool.rs | 787 ++++++++++++++++++++++++++++++++++- crates/buzz-acp/src/relay.rs | 13 + 2 files changed, 782 insertions(+), 18 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index d1e005cbcc..158477c0af 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -19,6 +19,7 @@ //! //! `AcpClient` is NOT Clone — ownership moves out on claim and back on return. +use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -800,6 +801,9 @@ pub enum IdleSwitchResult { /// 2 × CONTEXT_FETCH_TIMEOUT + CONTEXT_FETCH_RETRY_DELAY ≈ 6.5 s. const CONTEXT_FETCH_TIMEOUT: Duration = Duration::from_millis(3_000); +/// Short, single-attempt timeout for best-effort exact truncated-thread counts. +const CONTEXT_COUNT_TIMEOUT: Duration = Duration::from_millis(500); + /// Delay between the first failed context fetch and the single retry. const CONTEXT_FETCH_RETRY_DELAY: Duration = Duration::from_millis(500); @@ -2600,7 +2604,14 @@ async fn fetch_conversation_context( let last_event = batch.events.last()?; let tags = crate::queue::parse_thread_tags(&last_event.event); if let Some(root_id) = tags.root_event_id { - return fetch_thread_context(batch.channel_id, &root_id, limit, &ctx.rest_client).await; + return fetch_thread_context( + batch.channel_id, + &root_id, + limit, + ctx.agent_keys.public_key(), + &ctx.rest_client, + ) + .await; } // DM non-reply: fetch recent conversation history. @@ -2762,12 +2773,48 @@ async fn fetch_prompt_profile_lookup( } /// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag. +/// +/// The reply query intentionally requests one more reply than the configured +/// display window. That sentinel event lets the prompt say `N of M, truncated` +/// when the relay has more thread history, instead of reporting the capped page +/// as the total. When the window is full, a best-effort `/count` attempts to +/// improve that lower-bound total; because it is a separate racy request, the +/// result is clamped to the sentinel-proven minimum. The query also asks for the +/// agent's newest reply separately so the next prompt can include the agent's +/// own prior turn even in busy threads where the recent-message window would +/// otherwise push it out. async fn fetch_thread_context( channel_id: Uuid, root_event_id: &str, limit: u32, + agent_pubkey: nostr::PublicKey, rest: &RestClient, ) -> Option { + fetch_thread_context_with( + channel_id, + root_event_id, + limit, + agent_pubkey, + |filters| async move { rest.query(&filters).await }, + |filters| async move { rest.count(&filters).await }, + ) + .await +} + +async fn fetch_thread_context_with( + channel_id: Uuid, + root_event_id: &str, + limit: u32, + agent_pubkey: nostr::PublicKey, + query: Query, + count: Count, +) -> Option +where + Query: Fn(Vec) -> QueryFut, + QueryFut: std::future::Future>, + Count: Fn(Vec) -> CountFut, + CountFut: std::future::Future>, +{ use nostr::{Alphabet, SingleLetterTag}; // Defense-in-depth: validate hex event ID. @@ -2786,7 +2833,8 @@ async fn fetch_thread_context( let h_tag = SingleLetterTag::lowercase(Alphabet::H); let ch_str = channel_id.to_string(); - // Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel. + // Three filters: (1) root event by ID, (2) recent replies with #e=root + + // #h=channel plus a sentinel, and (3) the agent's newest reply for pinning. let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?); let replies_filter = nostr::Filter::new() .kinds([ @@ -2795,16 +2843,23 @@ async fn fetch_thread_context( ]) .custom_tags(e_tag, [root_event_id]) .custom_tags(h_tag, [ch_str.as_str()]) - .limit(limit as usize); + .limit(limit.saturating_add(1) as usize); + let agent_reply_filter = replies_filter.clone().author(agent_pubkey).limit(1); - fetch_with_retry(|| async { + let context = fetch_with_retry(|| async { match timeout( CONTEXT_FETCH_TIMEOUT, - rest.query(&[root_filter.clone(), replies_filter.clone()]), + query(vec![ + root_filter.clone(), + replies_filter.clone(), + agent_reply_filter.clone(), + ]), ) .await { - Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id), + Ok(Ok(json)) => { + parse_nostr_thread_response_with_meta(json, root_event_id, limit, &agent_pubkey) + } Ok(Err(e)) => { tracing::warn!( channel_id = %channel_id, @@ -2823,7 +2878,75 @@ async fn fetch_thread_context( } } }) - .await + .await; + + let mut parsed = context?; + + if matches!( + parsed.context, + ConversationContext::Thread { + truncated: true, + .. + } + ) { + let replies_count_filter = replies_filter.clone().limit(0); + if let Some(total) = fetch_thread_total( + channel_id, + &replies_count_filter, + parsed.root_present, + &count, + ) + .await + { + if let ConversationContext::Thread { + total: context_total, + .. + } = &mut parsed.context + { + let sentinel_minimum = *context_total; + // `/count` is a separate best-effort request after the message + // query. If replies are deleted between the two, the exact count + // can fall below the already-proven sentinel minimum; never + // render impossible labels like `13 of 12 messages, truncated`. + *context_total = total.max(sentinel_minimum); + } + } + } + + Some(parsed.context) +} + +/// Best-effort exact thread size for truncated context labels. +async fn fetch_thread_total( + channel_id: Uuid, + replies_filter: &nostr::Filter, + root_present: bool, + count: &Count, +) -> Option +where + Count: Fn(Vec) -> CountFut, + CountFut: std::future::Future>, +{ + let replies_count = + match timeout(CONTEXT_COUNT_TIMEOUT, count(vec![replies_filter.clone()])).await { + Ok(Ok(json)) => json.get("count").and_then(|v| v.as_u64())?, + Ok(Err(e)) => { + tracing::debug!( + channel_id = %channel_id, + "thread context count failed; using sentinel minimum: {e}" + ); + return None; + } + Err(_) => { + tracing::debug!( + channel_id = %channel_id, + "thread context count timed out; using sentinel minimum" + ); + return None; + } + }; + + Some(replies_count as usize + usize::from(root_present)) } /// Fetch DM context via Nostr query: recent messages in channel by `#h` tag. @@ -2976,48 +3099,110 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { /// Parse a Nostr query response (array of events) into thread context. /// -/// Separates the root event (matching `root_event_id`) from replies, sorts -/// chronologically by `created_at`. +/// Separates the root event (matching `root_event_id`) from replies, keeps the +/// newest `limit` replies returned by the sentinel query, then sorts the +/// displayed window chronologically for the prompt. If the agent's newest reply +/// is outside that window, keep it instead of the oldest displayed reply so the +/// next prompt always includes the agent's most recent prior turn. +#[cfg(test)] fn parse_nostr_thread_response( json: serde_json::Value, root_event_id: &str, + limit: u32, + agent_pubkey: &nostr::PublicKey, ) -> Option { + parse_nostr_thread_response_with_meta(json, root_event_id, limit, agent_pubkey) + .map(|parsed| parsed.context) +} + +struct ParsedThreadContext { + context: ConversationContext, + root_present: bool, +} + +fn parse_nostr_thread_response_with_meta( + json: serde_json::Value, + root_event_id: &str, + limit: u32, + agent_pubkey: &nostr::PublicKey, +) -> Option { let events = json.as_array()?; + let agent_pubkey_hex = agent_pubkey.to_hex(); let mut root_msg = None; let mut reply_msgs = Vec::new(); + let mut seen_reply_ids = HashSet::new(); for ev in events { let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or(""); if let Some(msg) = json_to_context_message(ev) { if ev_id == root_event_id { root_msg = Some(msg); - } else { + } else if seen_reply_ids.insert(ev_id.to_string()) { + let is_agent = msg.pubkey.eq_ignore_ascii_case(&agent_pubkey_hex); reply_msgs.push(( + ev_id.to_string(), ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + is_agent, msg, )); } } } - // Sort replies chronologically. - reply_msgs.sort_by_key(|(ts, _)| *ts); + let root_present = root_msg.is_some(); + let fetched_total = reply_msgs.len() + usize::from(root_present); + let newest_agent_reply = reply_msgs + .iter() + .filter(|(_, _, is_agent, _)| *is_agent) + .max_by_key(|(_, ts, _, _)| *ts) + .cloned(); + + let truncated = reply_msgs.len() > limit as usize; + if truncated { + // The relay returns limited REQ results newest-first. Sort explicitly so + // the sentinel we drop is the oldest reply in the fetched window, not an + // arbitrary last element if the HTTP bridge ever changes iteration order. + reply_msgs.sort_by_key(|(_, ts, _, _)| Reverse(*ts)); + reply_msgs.truncate(limit as usize); + } + + if let Some(agent_reply) = newest_agent_reply { + let agent_reply_already_displayed = + reply_msgs.iter().any(|(id, _, _, _)| *id == agent_reply.0); + if !agent_reply_already_displayed { + reply_msgs.sort_by_key(|(_, ts, _, _)| *ts); + if let Some(oldest) = reply_msgs.first_mut() { + *oldest = agent_reply; + } + } + } + + // Sort displayed replies chronologically. + reply_msgs.sort_by_key(|(_, ts, _, _)| *ts); let mut messages = Vec::new(); if let Some(root) = root_msg { messages.push(root); } - messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg)); + messages.extend(reply_msgs.into_iter().map(|(_, _, _, msg)| msg)); - let total = messages.len(); if messages.is_empty() { return None; } - Some(ConversationContext::Thread { - messages, - total, - truncated: false, // query returns all within limit + let total = if truncated { + fetched_total // all distinct fetched replies plus the root are proven visible history + } else { + messages.len() + }; + + Some(ParsedThreadContext { + context: ConversationContext::Thread { + messages, + total, + truncated, + }, + root_present, }) } @@ -4204,6 +4389,572 @@ mod tests { assert!(parse_dm_response(json, 12).is_none()); } + #[test] + fn test_parse_nostr_thread_response_marks_query_window_truncated() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let agent_hex = agent.public_key().to_hex(); + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": agent_hex, + "content": "newest agent reply", + "created_at": 4000 + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "humanpub", + "content": "middle reply", + "created_at": 3000 + }, + { + "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "oldpub", + "content": "sentinel omitted reply", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert_eq!(messages.len(), 3); // root + 2 displayed replies + assert_eq!(total, 4); // root + displayed replies + sentinel + assert!(truncated); + assert_eq!(messages[0].content, "root"); + assert_eq!(messages[1].content, "middle reply"); + assert_eq!(messages[2].content, "newest agent reply"); + assert!(messages + .iter() + .all(|msg| msg.content != "sentinel omitted reply")); + } + _ => panic!("expected Thread context"), + } + } + + #[test] + fn test_parse_nostr_thread_response_not_truncated_below_limit() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "replypub", + "content": "reply", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert_eq!(messages.len(), 2); + assert_eq!(total, 2); + assert!(!truncated); + } + _ => panic!("expected Thread context"), + } + } + + #[test] + fn test_parse_nostr_thread_response_keeps_agent_reply_outside_recent_window() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let agent_hex = agent.public_key().to_hex(); + let json = json!([ + { + "id": root_id, + "pubkey": "rootpub", + "content": "root", + "created_at": 1000 + }, + { + "id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "pubkey": "humanpub", + "content": "newer human reply", + "created_at": 5000 + }, + { + "id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "pubkey": "humanpub", + "content": "middle human reply", + "created_at": 4000 + }, + { + "id": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "pubkey": "humanpub", + "content": "oldest displayed reply without agent pin", + "created_at": 3000 + }, + { + "id": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "pubkey": agent_hex, + "content": "agent reply outside recent window", + "created_at": 2000 + } + ]); + + let ctx = parse_nostr_thread_response(json, root_id, 2, &agent.public_key()) + .expect("should parse"); + match ctx { + ConversationContext::Thread { messages, .. } => { + assert_eq!(messages.len(), 3); // root + 2 displayed replies + assert_eq!(messages[0].content, "root"); + assert!(messages + .iter() + .any(|msg| msg.content == "agent reply outside recent window")); + assert!(messages + .iter() + .any(|msg| msg.content == "newer human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "oldest displayed reply without agent pin")); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_uses_exact_count_when_above_sentinel_minimum() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let agent_pubkey = agent.public_key(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent_pubkey, + move |filters| { + assert_thread_query_filters(&filters, channel_id, root_id, agent_pubkey, 3); + std::future::ready(Ok(json.clone())) + }, + move |filters| { + assert_thread_count_filter(&filters, channel_id, root_id); + std::future::ready(Ok(json!({ "count": 6 }))) + }, + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 7); // 6 replies + root + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_does_not_add_missing_root_to_exact_count() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 6 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 2); + assert_eq!(total, 6); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_clamps_count_below_sentinel_minimum() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 1 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 4); // root + displayed replies + sentinel minimum + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_preserves_sentinel_minimum_when_count_fails() { + let agent = Keys::generate(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest reply", + 4000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle reply", + 3000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!(total, 4); // count failure leaves parser's sentinel minimum intact + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_deduplicates_and_pins_agent_reply() { + let agent = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newer human reply", + 5000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle human reply", + 4000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + &agent_hex, + "agent reply outside recent window", + 2000 + ), + // Same event as the separately fetched author-filtered result; the + // parser should deduplicate it before pinning. + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + &agent_hex, + "agent reply outside recent window", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Ok(json!({ "count": 3 }))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(total, 4); + assert_eq!(messages.len(), 3); + assert_eq!( + messages + .iter() + .filter(|msg| msg.content == "agent reply outside recent window") + .count(), + 1, + "separate agent-reply query must not duplicate the same event" + ); + assert!(messages + .iter() + .any(|msg| msg.content == "newer human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + } + _ => panic!("expected Thread context"), + } + } + + #[tokio::test] + async fn test_fetch_thread_context_uses_distinct_fetched_replies_as_minimum() { + let agent = Keys::generate(); + let agent_hex = agent.public_key().to_hex(); + let root_id = "1111111111111111111111111111111111111111111111111111111111111111"; + let channel_id = Uuid::new_v4(); + let json = json!([ + thread_event(root_id, "rootpub", "root", 1000), + thread_event( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "humanpub", + "newest human reply", + 5000 + ), + thread_event( + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "humanpub", + "middle human reply", + 4000 + ), + thread_event( + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "humanpub", + "sentinel human reply", + 3000 + ), + thread_event( + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + &agent_hex, + "older distinct agent reply", + 2000 + ) + ]); + + let ctx = fetch_thread_context_with( + channel_id, + root_id, + 2, + agent.public_key(), + move |_filters| std::future::ready(Ok(json.clone())), + |_filters| std::future::ready(Err(crate::relay::RelayError::Http("boom".into()))), + ) + .await + .expect("thread context"); + + match ctx { + ConversationContext::Thread { + messages, + total, + truncated, + } => { + assert!(truncated); + assert_eq!(messages.len(), 3); + assert_eq!( + total, 5, + "root plus all four distinct fetched replies prove the lower bound" + ); + assert!(messages + .iter() + .any(|msg| msg.content == "older distinct agent reply")); + assert!(messages + .iter() + .any(|msg| msg.content == "newest human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "middle human reply")); + assert!(messages + .iter() + .all(|msg| msg.content != "sentinel human reply")); + } + _ => panic!("expected Thread context"), + } + } + + fn assert_thread_query_filters( + filters: &[nostr::Filter], + channel_id: Uuid, + root_id: &str, + agent_pubkey: nostr::PublicKey, + reply_limit: u64, + ) { + assert_eq!( + filters.len(), + 3, + "root, recent replies, and agent reply filters" + ); + + let root = serde_json::to_value(&filters[0]).expect("serialize root filter"); + assert_eq!(root.get("ids"), Some(&json!([root_id]))); + assert!(root.get("limit").is_none()); + + let replies = serde_json::to_value(&filters[1]).expect("serialize replies filter"); + assert_eq!(replies.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(replies.get("#e"), Some(&json!([root_id]))); + assert_eq!(replies.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(replies.get("limit"), Some(&json!(reply_limit))); + assert!(replies.get("authors").is_none()); + + let agent = serde_json::to_value(&filters[2]).expect("serialize agent filter"); + assert_eq!(agent.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(agent.get("#e"), Some(&json!([root_id]))); + assert_eq!(agent.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(agent.get("authors"), Some(&json!([agent_pubkey.to_hex()]))); + assert_eq!(agent.get("limit"), Some(&json!(1))); + } + + fn assert_thread_count_filter(filters: &[nostr::Filter], channel_id: Uuid, root_id: &str) { + assert_eq!(filters.len(), 1, "count should query only matching replies"); + + let count = serde_json::to_value(&filters[0]).expect("serialize count filter"); + assert_eq!(count.get("kinds"), Some(&json!([9, 40002]))); + assert_eq!(count.get("#e"), Some(&json!([root_id]))); + assert_eq!(count.get("#h"), Some(&json!([channel_id.to_string()]))); + assert_eq!(count.get("limit"), Some(&json!(0))); + assert!(count.get("ids").is_none()); + assert!(count.get("authors").is_none()); + } + + fn thread_event(id: &str, pubkey: &str, content: &str, created_at: u64) -> serde_json::Value { + json!({ + "id": id, + "pubkey": pubkey, + "content": content, + "created_at": created_at + }) + } + #[test] fn test_json_to_context_message_integer_timestamp() { let obj = json!({ diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index c8312cc61e..aea5cee077 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -405,6 +405,19 @@ impl RestClient { .map_err(|e| RelayError::Http(e.to_string())) } + /// Count events via the HTTP bridge: `POST /count` with NIP-98 auth. + /// + /// Accepts a slice of `nostr::Filter` (serialized as JSON array). + /// Returns the bridge response as a `serde_json::Value` (usually `{ "count": n }`). + pub async fn count(&self, filters: &[nostr::Filter]) -> Result { + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; + let resp = self.bridge_post("/count", &body_bytes).await?; + resp.json() + .await + .map_err(|e| RelayError::Http(e.to_string())) + } + /// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth. /// /// The event must already be signed. Returns the relay response JSON. From 61b96c9828d1dd54106b570d87a54edbc92bb9c4 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 10:54:13 -0600 Subject: [PATCH 64/99] fix(catalog): update Amp description (#3758) ## Summary - replace Amp's outdated Sourcegraph attribution in the runtime catalog - describe Amp neutrally as a coding agent for the terminal and editor ## Verification - `pnpm test` (desktop: 3,819 passed) - `pnpm typecheck` - pre-push `desktop-check`, `desktop-test`, and `branch-skew` hooks Signed-off-by: Wes Co-authored-by: Carl --- desktop/src/features/settings/ui/harnessCatalogCopy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/settings/ui/harnessCatalogCopy.ts b/desktop/src/features/settings/ui/harnessCatalogCopy.ts index 79c55f0148..70439091b2 100644 --- a/desktop/src/features/settings/ui/harnessCatalogCopy.ts +++ b/desktop/src/features/settings/ui/harnessCatalogCopy.ts @@ -36,7 +36,7 @@ const HARNESS_DESCRIPTIONS: Record = { // https://moonshotai.github.io/kimi-cli/en/ kimi: "A terminal coding agent for software development and command-line tasks.", // Sources: https://ampcode.com, https://ampcode.com/manual - amp: "A coding agent from Sourcegraph.", + amp: "A coding agent for your terminal and editor.", // Sources: https://github.com/NousResearch/hermes-agent, // https://hermes-agent.nousresearch.com/docs/ hermes: "A general-purpose AI agent from Nous Research.", From 06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 18:02:48 +0100 Subject: [PATCH 65/99] Render mobile agent mention chips (#3702) ## Summary - Render selected agent mentions as visible bot chips in the mobile composer. - Recognize agent profiles consistently when rendering message-body mentions. Screenshot 2026-07-30 at 07 54 16 ## Validation - `flutter test test/features/channels/compose_bar_test.dart test/features/channels/message_content_test.dart` - `flutter analyze` --------- Signed-off-by: kenny lopez --- .../lib/features/activity/activity_page.dart | 14 +- .../activity/activity_page/inbox_row.dart | 24 ++ .../agent_activity/working_bots_provider.dart | 31 +- .../channels/channel_detail_page.dart | 2 +- .../channel_detail_page/message_bubble.dart | 18 +- .../channels/channel_management_provider.dart | 10 +- .../channels/channel_messages_provider.dart | 12 - mobile/lib/features/channels/compose_bar.dart | 12 + .../compose_bar/agent_mention_labels.dart | 10 + .../markdown_editing_controller.dart | 159 +++++++++- .../channels/mentions/mention_candidates.dart | 53 +--- .../mentions/mention_candidates_provider.dart | 59 +--- .../features/channels/message_content.dart | 65 +++-- .../features/channels/thread_detail_page.dart | 18 +- .../lib/features/forum/forum_post_card.dart | 48 ++- .../lib/features/forum/forum_thread_page.dart | 47 ++- mobile/lib/features/search/search_page.dart | 43 ++- .../mentions/agent_identity_provider.dart | 274 ++++++++++++++++++ mobile/lib/shared/mentions/mention_tags.dart | 6 + .../channels/channel_detail_page_test.dart | 4 + .../features/channels/compose_bar_test.dart | 72 ++++- .../mentions/mention_candidates_test.dart | 15 + .../channels/message_content_test.dart | 37 +++ .../features/search/search_page_test.dart | 74 ++++- .../agent_identity_provider_test.dart | 228 +++++++++++++++ 25 files changed, 1140 insertions(+), 195 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/agent_mention_labels.dart create mode 100644 mobile/lib/shared/mentions/agent_identity_provider.dart create mode 100644 mobile/lib/shared/mentions/mention_tags.dart create mode 100644 mobile/test/shared/mentions/agent_identity_provider_test.dart diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index db39850481..aecef6329c 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/utils/string_utils.dart'; @@ -87,8 +89,16 @@ class ActivityPage extends HookConsumerWidget { ]; // Preload sender profiles for visible rows. - final pubkeys = visibleItems.map((i) => i.item.pubkey).toSet().toList(); - ref.read(userCacheProvider.notifier).preload(pubkeys); + final preloadPubkeys = { + for (final item in visibleItems) item.item.pubkey.toLowerCase(), + for (final item in visibleItems) + ...mentionedPubkeysFromTags(item.item.tags), + }.toList()..sort(); + final preloadPubkeysKey = preloadPubkeys.join('\u0000'); + useEffect(() { + ref.read(userCacheProvider.notifier).preload(preloadPubkeys); + return null; + }, [preloadPubkeysKey]); final unreadVisibleCount = visibleItems.where((i) => !isDone(i)).length; diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart index fb267c03be..9dd8b6ddfa 100644 --- a/mobile/lib/features/activity/activity_page/inbox_row.dart +++ b/mobile/lib/features/activity/activity_page/inbox_row.dart @@ -61,6 +61,28 @@ class _InboxRow extends ConsumerWidget { final userCache = ref.watch(userCacheProvider); final profile = userCache[item.item.pubkey.toLowerCase()]; final senderLabel = profile?.displayName ?? shortPubkey(item.item.pubkey); + final profileMentionNames = { + for (final pubkey in mentionedPubkeysFromTags(item.item.tags)) + if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: userCache[pubkey]!.displayName!.trim(), + }; + final mentionPubkeys = mentionedPubkeysFromTags(item.item.tags); + final knownAgentPubkeys = channel == null + ? ref.watch(knownAgentPubkeysProvider) + : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: knownAgentPubkeys, + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); final isDm = channel?.isDm ?? false; final channelName = channel != null && !isDm @@ -172,6 +194,8 @@ class _InboxRow extends ConsumerWidget { // Message preview. MessageContent( content: item.item.displayContent, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: item.item.tags, maxLines: 2, baseStyle: activityPreviewTextStyle.copyWith( diff --git a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart index 179ea0d4f3..8730908ada 100644 --- a/mobile/lib/features/channels/agent_activity/working_bots_provider.dart +++ b/mobile/lib/features/channels/agent_activity/working_bots_provider.dart @@ -8,21 +8,20 @@ import '../channel_typing_provider.dart'; /// /// Used by both the members button badge and the members sheet to avoid /// duplicating the bot-typing cross-reference logic. -final workingBotPubkeysProvider = Provider.family, String>(( - ref, - channelId, -) { - final typingEntries = ref.watch(channelTypingProvider(channelId)); - final membersAsync = ref.watch(channelMembersProvider(channelId)); - final allMembers = membersAsync.asData?.value ?? const []; +final workingBotPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final typingEntries = ref.watch(channelTypingProvider(channelId)); + final membersAsync = ref.watch(channelMembersProvider(channelId)); + final allMembers = membersAsync.asData?.value ?? const []; - final botPubkeys = { - for (final m in allMembers) - if (m.isBot) m.pubkey.toLowerCase(), - }; + final botPubkeys = { + for (final m in allMembers) + if (m.isBot) m.pubkey.toLowerCase(), + }; - return { - for (final e in typingEntries) - if (botPubkeys.contains(e.pubkey.toLowerCase())) e.pubkey.toLowerCase(), - }; -}); + return { + for (final e in typingEntries) + if (botPubkeys.contains(e.pubkey.toLowerCase())) + e.pubkey.toLowerCase(), + }; + }); diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 7abeed8d7a..044342d101 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -39,7 +40,6 @@ import 'manage_channel_sheet.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; -import 'mentions/mention_candidates_provider.dart'; import 'read_state/deferred_read_state_update.dart'; import 'read_state/read_state_provider.dart'; import 'read_state/read_state_time.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index fcabfd619a..cb6ab67065 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -36,8 +36,14 @@ class _MessageBubble extends ConsumerWidget { // Build mention names map from event p-tags. final userCache = ref.watch(userCacheProvider); - final knownAgentPubkeys = ref.watch( - mentionAgentPubkeysProvider(currentChannelId), + final knownAgentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch( + agentMentionPubkeysProvider(currentChannelId), + ), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], ); final mentionNames = {}; final agentMentionPubkeys = {}; @@ -51,6 +57,12 @@ class _MessageBubble extends ConsumerWidget { agentMentionPubkeys.add(normalizedPubkey); } } + final resolvedMentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: message.mentionPubkeys, + profileMentionNames: mentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), @@ -166,7 +178,7 @@ class _MessageBubble extends ConsumerWidget { ), MessageContent( content: message.content, - mentionNames: mentionNames, + mentionNames: resolvedMentionNames, agentMentionPubkeys: agentMentionPubkeys, channelNames: channelNames, tags: message.tags, diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 9a72054a2b..b990194d15 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/auth/auth.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../profile/profile_provider.dart'; import 'channel.dart'; @@ -392,8 +393,9 @@ final channelDetailsProvider = FutureProvider.family(( }); /// Channel members from kind:39002 NIP-29 members event. -final channelMembersProvider = - FutureProvider.family, String>((ref, channelId) async { +final channelMembersProvider = FutureProvider.autoDispose + .family, String>((ref, channelId) async { + ref.watch(channelMembershipUpdateProvider(channelId)); final session = ref.watch(relaySessionProvider.notifier); final events = await session.fetchHistory( NostrFilters.channelMembers(channelId), @@ -557,6 +559,7 @@ class ChannelActions { ); } _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future joinChannel(String channelId) async { @@ -626,6 +629,7 @@ class ChannelActions { await _ref.read(channelsProvider.notifier).refresh(); _ref.invalidate(channelDetailsProvider(channelId)); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); _ref.invalidate(channelCanvasProvider(channelId)); } @@ -659,6 +663,7 @@ class ChannelActions { ], ); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future removeMember({ @@ -674,6 +679,7 @@ class ChannelActions { ], ); _ref.invalidate(channelMembersProvider(channelId)); + _ref.invalidate(channelBotPubkeysProvider(channelId)); } Future addReaction(String eventId, String emoji) async { diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart index c03087a85d..fbcbca8956 100644 --- a/mobile/lib/features/channels/channel_messages_provider.dart +++ b/mobile/lib/features/channels/channel_messages_provider.dart @@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; -import 'channel_management_provider.dart'; import 'pending_local_messages_provider.dart'; import 'channel_window.dart'; import 'thread_replies_provider.dart'; @@ -222,11 +221,6 @@ class ChannelMessagesNotifier extends Notifier>> { _lastKnownMessages = merged; state = AsyncData(merged); } - - if (event.kind == EventKind.systemMessage && - _isMembershipEvent(event.content)) { - ref.invalidate(channelMembersProvider(channelId)); - } } void _handleWindowLiveEvent(NostrEvent event) { @@ -288,12 +282,6 @@ class ChannelMessagesNotifier extends Notifier>> { .confirm(eventIds); } - static bool _isMembershipEvent(String content) { - return content.contains('member_joined') || - content.contains('member_left') || - content.contains('member_removed'); - } - /// Adds a just-signed outgoing message before the relay acknowledges it. /// The live relay echo is deduplicated by event id. void addLocalMessage(NostrEvent event) { diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 1a9a02e409..7560f998f3 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -15,6 +15,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:nostr/nostr.dart' as nostr; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -36,6 +37,7 @@ import 'mentions/mention_ranking.dart'; import 'photo_library.dart'; part 'compose_bar/helpers.dart'; +part 'compose_bar/agent_mention_labels.dart'; part 'compose_bar/markdown_editing_controller.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; @@ -231,6 +233,16 @@ class ComposeBar extends HookConsumerWidget { // owners so @mention suggestions show names ("managed by …" included). final relayAgents = ref.watch(agentDirectoryProvider).asData?.value; final agentOwners = ref.watch(agentOwnersProvider).asData?.value; + final agentMentionLabels = _agentMentionLabels( + candidates: mentionMap.value.values, + ); + final agentMentionLabelsKey = (agentMentionLabels.toList()..sort()).join( + '\u0000', + ); + useEffect(() { + controller.setAgentMentionNames(agentMentionLabels); + return null; + }, [controller, agentMentionLabelsKey]); useEffect( () { final memberList = membersAsync.asData?.value ?? []; diff --git a/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart new file mode 100644 index 0000000000..bc29d5bb46 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/agent_mention_labels.dart @@ -0,0 +1,10 @@ +part of '../compose_bar.dart'; + +Set _agentMentionLabels({ + required Iterable candidates, +}) { + return { + for (final candidate in candidates) + if (candidate.isAgent) candidate.label, + }; +} diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart index 29e22a8ca9..bbca57037a 100644 --- a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -15,6 +15,8 @@ class _MarkdownRule { } class _MarkdownEditingController extends TextEditingController { + final Set _agentMentionNames = {}; + static final _rules = [ _MarkdownRule( r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```', @@ -31,6 +33,21 @@ class _MarkdownEditingController extends TextEditingController { _MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic), ]; + /// Updates the known agent labels which should render as agent mention + /// chips. The editor still stores the literal `@Name` text, matching the + /// markdown sent to the relay. + void setAgentMentionNames(Iterable names) { + final next = { + for (final name in names) + if (name.trim().isNotEmpty) name.trim().toLowerCase(), + }; + if (setEquals(_agentMentionNames, next)) return; + _agentMentionNames + ..clear() + ..addAll(next); + notifyListeners(); + } + @override TextSpan buildTextSpan({ required BuildContext context, @@ -81,6 +98,7 @@ class _MarkdownEditingController extends TextEditingController { if (nextRule == null || nextMatch == null) { spans.addAll( _buildTextSpans( + context, source.substring(offset), inheritedStyle, sourceOffset + offset, @@ -93,6 +111,7 @@ class _MarkdownEditingController extends TextEditingController { if (nextMatch.start > 0) { spans.addAll( _buildTextSpans( + context, tail.substring(0, nextMatch.start), inheritedStyle, sourceOffset + offset, @@ -129,10 +148,12 @@ class _MarkdownEditingController extends TextEditingController { } else { spans.addAll( _buildTextSpans( + context, content, contentStyle, matchOffset + contentStart, composingRange, + renderAgentMentions: false, ), ); } @@ -150,11 +171,18 @@ class _MarkdownEditingController extends TextEditingController { } List _buildTextSpans( + BuildContext context, String source, TextStyle style, int sourceOffset, - TextRange composingRange, - ) { + TextRange composingRange, { + bool renderAgentMentions = true, + }) { + List buildTextSegment(String text, TextStyle segmentStyle) => + renderAgentMentions + ? _buildAgentMentionSpans(context, text, segmentStyle) + : [TextSpan(text: text, style: segmentStyle)]; + if (source.isEmpty) return const []; final localStart = (composingRange.start - sourceOffset) .clamp(0, source.length) @@ -165,7 +193,7 @@ class _MarkdownEditingController extends TextEditingController { if (!composingRange.isValid || composingRange.isCollapsed || localStart >= localEnd) { - return [TextSpan(text: source, style: style)]; + return buildTextSegment(source, style); } final composingDecorations = [ @@ -178,16 +206,80 @@ class _MarkdownEditingController extends TextEditingController { ); return [ if (localStart > 0) - TextSpan(text: source.substring(0, localStart), style: style), + ...buildTextSegment(source.substring(0, localStart), style), TextSpan( text: source.substring(localStart, localEnd), style: composingStyle, ), if (localEnd < source.length) - TextSpan(text: source.substring(localEnd), style: style), + ...buildTextSegment(source.substring(localEnd), style), ]; } + List _buildAgentMentionSpans( + BuildContext context, + String source, + TextStyle style, + ) { + if (_agentMentionNames.isEmpty) { + return [TextSpan(text: source, style: style)]; + } + + final escapedNames = _agentMentionNames.toList() + ..sort((a, b) => b.length.compareTo(a.length)); + final expression = RegExp( + r'(^|\s)@(' + + escapedNames.map(RegExp.escape).join('|') + + r')(?=\s|[,.!?:;)\]}*_]|$)', + caseSensitive: false, + multiLine: true, + ); + final spans = []; + var offset = 0; + for (final match in expression.allMatches(source)) { + final prefix = match.group(1)!; + if (match.start > offset) { + spans.add( + TextSpan(text: source.substring(offset, match.start), style: style), + ); + } + if (prefix.isNotEmpty) spans.add(TextSpan(text: prefix, style: style)); + + final label = match.group(2)!; + spans.add( + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: _ComposerAgentMentionChip(label: label, textStyle: style), + ), + ); + // The visual chip replaces the `@` placeholder. Keep the label as + // invisible source text so the text span still has one character per + // source character, preserving native cursor and deletion behavior. + spans.add( + TextSpan( + text: label, + semanticsLabel: '', + style: _hiddenMentionTextStyle(style), + ), + ); + offset = match.end; + } + if (offset < source.length) { + spans.add(TextSpan(text: source.substring(offset), style: style)); + } + return spans.isEmpty ? [TextSpan(text: source, style: style)] : spans; + } + + TextStyle _hiddenMentionTextStyle(TextStyle inheritedStyle) => + inheritedStyle.copyWith( + color: Colors.transparent, + fontSize: 0.01, + height: 0.01, + letterSpacing: 0, + decoration: TextDecoration.none, + ); + (int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) { final delimiterLength = switch (markdownStyle) { _MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2, @@ -247,3 +339,60 @@ class _MarkdownEditingController extends TextEditingController { ); } } + +class _ComposerAgentMentionChip extends StatelessWidget { + final String label; + final TextStyle textStyle; + + const _ComposerAgentMentionChip({ + required this.label, + required this.textStyle, + }); + + @override + Widget build(BuildContext context) { + final style = textStyle.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w500, + height: 1, + ); + final fontSize = style.fontSize ?? 16; + + return Semantics( + label: 'Agent mention: $label', + excludeSemantics: true, + child: Container( + key: const ValueKey('composer-agent-mention-chip'), + padding: const EdgeInsets.fromLTRB( + Grid.half, + Grid.quarter + 1, + Grid.half, + Grid.quarter, + ), + decoration: BoxDecoration( + // The composer surface is already tinted, so the body chip's + // low-opacity fill disappears here. Keep the same chip geometry + // while giving this editable token enough contrast to read as one. + color: context.colors.primary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(Radii.sm), + border: Border.all( + color: context.colors.primary.withValues(alpha: 0.12), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + LucideIcons.bot, + size: fontSize * 0.95, + color: context.colors.primary, + ), + const SizedBox(width: Grid.quarter), + Text(label, style: style), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index ca97d65e34..9c4ef96bbe 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -1,59 +1,8 @@ -import 'dart:convert'; - -import '../../../shared/relay/nostr_models.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../profile/user_profile.dart'; import '../channel_management_provider.dart'; import 'mention_ranking.dart'; -/// A relay agent parsed from its kind:10100 agent-profile event. -/// -/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility -/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which -/// channels it sits in. -class AgentDirectoryEntry { - final String pubkey; - final String? displayName; - final String? respondTo; - final List respondToAllowlist; - final List channelIds; - - const AgentDirectoryEntry({ - required this.pubkey, - this.displayName, - this.respondTo, - this.respondToAllowlist = const [], - this.channelIds = const [], - }); - - factory AgentDirectoryEntry.fromEvent(NostrEvent event) { - final content = _tryDecodeJsonMap(event.content); - return AgentDirectoryEntry( - pubkey: event.pubkey.toLowerCase(), - displayName: - (content?['display_name'] as String?) ?? - (content?['name'] as String?), - respondTo: content?['respond_to'] as String?, - respondToAllowlist: [ - for (final value in (content?['respond_to_allowlist'] as List?) ?? []) - if (value is String) value.toLowerCase(), - ], - channelIds: [ - for (final value in (content?['channel_ids'] as List?) ?? []) - if (value is String) value, - ], - ); - } -} - -Map? _tryDecodeJsonMap(String content) { - try { - final decoded = jsonDecode(content); - return decoded is Map ? decoded : null; - } catch (_) { - return null; - } -} - /// Whether a non-member relay agent should be mentionable by the current /// user. Mirrors desktop's `relayAgentIsSharedWithUser`: /// - allowlist mode: user must be on the allowlist diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index c2aa056a05..6e94459231 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/crypto/nip_oa.dart'; +import '../../../shared/mentions/agent_identity_provider.dart'; import '../../../shared/relay/relay.dart'; import '../../profile/user_cache_provider.dart'; import '../../profile/user_profile.dart'; @@ -10,64 +11,6 @@ import '../channels_provider.dart'; import 'mention_candidates.dart'; import 'mention_ranking.dart'; -/// Relay agent directory from kind:10100 agent-profile events. -/// -/// Watches the session and only fetches after the WebSocket connects. -final agentDirectoryProvider = FutureProvider>(( - ref, -) async { - final sessionState = ref.watch(relaySessionProvider); - if (sessionState.status != SessionStatus.connected) return const []; - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory(NostrFilters.agentProfiles()); - return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; -}); - -/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 -/// profiles. An entry exists only when the `auth` tag verifies — mirrors -/// desktop's `profile_valid_oa_owner_pubkey`. -final agentOwnersProvider = FutureProvider>((ref) async { - final agents = await ref.watch(agentDirectoryProvider.future); - if (agents.isEmpty) return const {}; - final session = ref.read(relaySessionProvider.notifier); - final events = await session.fetchHistory( - NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), - ); - final owners = {}; - for (final event in events) { - final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); - if (owner != null) owners[event.pubkey.toLowerCase()] = owner; - } - return owners; -}); - -/// Pubkeys currently known to represent agents for rendered mention chips. -/// -/// Uses the same three identity sources as mention autocomplete: channel bot -/// roles, relay agent-directory entries, and verified NIP-OA ownership. -final mentionAgentPubkeysProvider = Provider.family, String>(( - ref, - channelId, -) { - final members = - ref.watch(channelMembersProvider(channelId)).asData?.value ?? - const []; - final relayAgents = - ref.watch(agentDirectoryProvider).asData?.value ?? - const []; - final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; - final userCache = ref.watch(userCacheProvider); - - return { - for (final member in members) - if (member.isBot) member.pubkey.toLowerCase(), - for (final agent in relayAgents) agent.pubkey.toLowerCase(), - ...owners.keys.map((pubkey) => pubkey.toLowerCase()), - for (final profile in userCache.values) - if (profile.ownerPubkey != null) profile.pubkey.toLowerCase(), - }; -}); - /// Debounce before a mention query hits the relay search endpoint. const _mentionSearchDebounce = Duration(milliseconds: 250); diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index ab992a4314..465585d966 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -145,6 +145,10 @@ class MessageContent extends HookConsumerWidget { final baseTextStyle = baseStyle ?? context.textTheme.bodyMedium?.copyWith(color: context.colors.onSurface); + final resolvedMentionNames = mentionNames; + final resolvedAgentMentionPubkeys = { + ...agentMentionPubkeys.map((pubkey) => pubkey.toLowerCase()), + }; final imetaByUrl = parseImetaTags(tags); final trailingGallery = maxLines == null ? _extractTrailingImageGallery(content, imetaByUrl) @@ -154,6 +158,13 @@ class MessageContent extends HookConsumerWidget { customEmojiFromTags(tags), ref.watch(customEmojiListProvider), ); + final mentionPresentationKey = [ + for (final entry + in (resolvedMentionNames.entries.toList() + ..sort((a, b) => a.key.compareTo(b.key)))) + '${entry.key}\u0000${entry.value}', + ...(resolvedAgentMentionPubkeys.toList()..sort()), + ].join('\u0001'); // Decided here rather than by the caller: this is where the event's own // emoji tags and the community palette have already been merged, and a @@ -220,7 +231,7 @@ class MessageContent extends HookConsumerWidget { mentionBuf.write('`${mentionParts[i]}`'); } else { var segment = mentionParts[i]; - for (final name in mentionNames.values) { + for (final name in resolvedMentionNames.values) { if (name.contains(' ')) { final normalizedName = _markdownMentionName(name); segment = segment.replaceAllMapped( @@ -241,29 +252,35 @@ class MessageContent extends HookConsumerWidget { result = '\u200B$result'; } return result; - }, [markdownContent, mentionNames]); - - final markdown = GptMarkdown( - finalContent, - style: style, - followLinkColor: false, - codeBuilder: (context, name, code, closed) => - _MessageCodeBlock(name: name, code: code), - linkBuilder: (context, linkText, url, linkStyle) => - _buildLink(context, ref, linkText, url, linkStyle, style), - imageBuilder: (context, imageUrl) => - _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), - maxLines: maxLines, - inlineComponents: [ - _MentionMd( - mentionNames: mentionNames, - agentMentionPubkeys: agentMentionPubkeys, - onMentionTap: onMentionTap, - ), - CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), - _ChannelLinkMd(channelNames: channelNames, onChannelTap: onChannelTap), - ...MarkdownComponent.inlineComponents, - ], + }, [markdownContent, resolvedMentionNames]); + + final markdown = KeyedSubtree( + key: ValueKey('$finalContent\u0000$mentionPresentationKey'), + child: GptMarkdown( + finalContent, + style: style, + followLinkColor: false, + codeBuilder: (context, name, code, closed) => + _MessageCodeBlock(name: name, code: code), + linkBuilder: (context, linkText, url, linkStyle) => + _buildLink(context, ref, linkText, url, linkStyle, style), + imageBuilder: (context, imageUrl) => + _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), + maxLines: maxLines, + inlineComponents: [ + _MentionMd( + mentionNames: resolvedMentionNames, + agentMentionPubkeys: resolvedAgentMentionPubkeys, + onMentionTap: onMentionTap, + ), + CustomEmojiMd(customEmoji, size: inlineCustomEmojiSize), + _ChannelLinkMd( + channelNames: channelNames, + onChannelTap: onChannelTap, + ), + ...MarkdownComponent.inlineComponents, + ], + ), ); if (trailingGallery == null) return markdown; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 5d28214b01..810861aa00 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; @@ -24,7 +25,6 @@ import 'day_divider.dart'; import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; -import 'mentions/mention_candidates_provider.dart'; import 'reaction_row.dart'; import 'read_state/read_state_format.dart'; import 'read_state/read_state_provider.dart'; @@ -607,7 +607,13 @@ class _ThreadMessage extends ConsumerWidget { profile?.ownerPubkey == currentPubkey?.toLowerCase()); final userCache = ref.watch(userCacheProvider); - final knownAgentPubkeys = ref.watch(mentionAgentPubkeysProvider(channelId)); + final knownAgentPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); final mentionNames = {}; final agentMentionPubkeys = {}; for (final mpk in message.mentionPubkeys) { @@ -620,6 +626,12 @@ class _ThreadMessage extends ConsumerWidget { agentMentionPubkeys.add(normalizedPubkey); } } + final resolvedMentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: message.mentionPubkeys, + profileMentionNames: mentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0), @@ -725,7 +737,7 @@ class _ThreadMessage extends ConsumerWidget { ), MessageContent( content: message.content, - mentionNames: mentionNames, + mentionNames: resolvedMentionNames, agentMentionPubkeys: agentMentionPubkeys, channelNames: channelNames, tags: message.tags, diff --git a/mobile/lib/features/forum/forum_post_card.dart b/mobile/lib/features/forum/forum_post_card.dart index 8666919a4a..ddc36a1d4a 100644 --- a/mobile/lib/features/forum/forum_post_card.dart +++ b/mobile/lib/features/forum/forum_post_card.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../channels/message_content.dart'; @@ -15,7 +17,7 @@ import 'forum_models.dart'; /// /// Long-press opens an action sheet (copy, delete) matching the stream /// message pattern from channel_detail_page.dart. -class ForumPostCard extends ConsumerWidget { +class ForumPostCard extends HookConsumerWidget { final ForumPost post; final String? currentPubkey; final VoidCallback onTap; @@ -31,16 +33,57 @@ class ForumPostCard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mentionPubkeys = useMemoized( + () => + post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()).toSet() + ..remove(post.pubkey.toLowerCase()), + [post], + ); + final mentionPubkeysKey = (mentionPubkeys.toList()..sort()).join('\u0000'); + + useEffect(() { + if (mentionPubkeys.isNotEmpty) { + ref.read(userCacheProvider.notifier).preload(mentionPubkeys.toList()); + } + return null; + }, [mentionPubkeysKey]); + final pk = post.pubkey.toLowerCase(); final profile = ref.watch(userCacheProvider.select((cache) => cache[pk])) ?? ref.read(userCacheProvider.notifier).get(pk); final displayName = profile?.label ?? _shortPubkey(post.pubkey); - final mentionNames = ref.watch( + final profileMentionNames = ref.watch( userCacheProvider.select( (cache) => _buildMentionNames(post.mentionPubkeys, cache), ), ); + final profileOwnedMentionPubkeys = ref.watch( + userCacheProvider.select( + (cache) => + (post.mentionPubkeys + .where( + (pubkey) => + cache[pubkey.toLowerCase()]?.ownerPubkey != null, + ) + .map((pubkey) => pubkey.toLowerCase()) + .toList() + ..sort()) + .join('\u0000'), + ), + ); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)), + profileOwnedAgentPubkeys: profileOwnedMentionPubkeys.isEmpty + ? const [] + : profileOwnedMentionPubkeys.split('\u0000'), + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: post.mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); final preview = post.content.length > 200 ? '${post.content.substring(0, 200)}...' : post.content; @@ -128,6 +171,7 @@ class ForumPostCard extends ConsumerWidget { child: MessageContent( content: preview, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: post.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, diff --git a/mobile/lib/features/forum/forum_thread_page.dart b/mobile/lib/features/forum/forum_thread_page.dart index d2e8490d61..68d2562f68 100644 --- a/mobile/lib/features/forum/forum_thread_page.dart +++ b/mobile/lib/features/forum/forum_thread_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -209,21 +210,27 @@ class _ThreadContent extends HookConsumerWidget { final post = thread.post; final replies = thread.replies; - // Preload profiles for all participants. + // Preload profiles for all participants and tagged mentions. final allPubkeys = useMemoized(() { - final pks = {post.pubkey}; + final pks = { + post.pubkey.toLowerCase(), + ...post.mentionPubkeys.map((pubkey) => pubkey.toLowerCase()), + }; for (final reply in replies) { - pks.add(reply.pubkey); + pks + ..add(reply.pubkey.toLowerCase()) + ..addAll(reply.mentionPubkeys.map((pubkey) => pubkey.toLowerCase())); } - return pks.toList(); + return pks.toList()..sort(); }, [post, replies]); + final allPubkeysKey = allPubkeys.join('\u0000'); useEffect(() { if (allPubkeys.isNotEmpty) { ref.read(userCacheProvider.notifier).preload(allPubkeys); } return null; - }, [allPubkeys]); + }, [allPubkeysKey]); return Column( children: [ @@ -322,7 +329,19 @@ class _OriginalPost extends ConsumerWidget { final displayName = profile?.label ?? _shortPubkey(post.pubkey); final userCache = ref.watch(userCacheProvider); - final mentionNames = _buildMentionNames(post.mentionPubkeys, userCache); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(post.channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: post.mentionPubkeys, + profileMentionNames: _buildMentionNames(post.mentionPubkeys, userCache), + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: const EdgeInsets.all(Grid.xs), @@ -375,6 +394,7 @@ class _OriginalPost extends ConsumerWidget { MessageContent( content: post.content, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: post.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, @@ -409,7 +429,19 @@ class _ReplyRow extends ConsumerWidget { final displayName = profile?.label ?? _shortPubkey(reply.pubkey); final userCache = ref.watch(userCacheProvider); - final mentionNames = _buildMentionNames(reply.mentionPubkeys, userCache); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: ref.watch(agentMentionPubkeysProvider(channelId)), + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: reply.mentionPubkeys, + profileMentionNames: _buildMentionNames(reply.mentionPubkeys, userCache), + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return Padding( padding: const EdgeInsets.symmetric( @@ -481,6 +513,7 @@ class _ReplyRow extends ConsumerWidget { child: MessageContent( content: reply.content, mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: reply.tags, baseStyle: messageBodyTextStyle.copyWith( color: context.colors.onSurface, diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index b608b65aef..65fc12dfd9 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -3,6 +3,8 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/mentions/mention_tags.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; @@ -597,7 +599,7 @@ class _PeopleSection extends ConsumerWidget { } } -class _MessagesSection extends ConsumerWidget { +class _MessagesSection extends HookConsumerWidget { final List hits; final String? currentPubkey; final VoidCallback onResultSelected; @@ -614,8 +616,15 @@ class _MessagesSection extends ConsumerWidget { final channels = ref.watch(channelsProvider).value ?? []; // Preload author profiles. - final pubkeys = hits.map((h) => h.pubkey.toLowerCase()).toSet().toList(); - ref.read(userCacheProvider.notifier).preload(pubkeys); + final preloadPubkeys = { + for (final hit in hits) hit.pubkey.toLowerCase(), + for (final hit in hits) ...mentionedPubkeysFromTags(hit.tags), + }.toList()..sort(); + final preloadPubkeysKey = preloadPubkeys.join('\u0000'); + useEffect(() { + ref.read(userCacheProvider.notifier).preload(preloadPubkeys); + return null; + }, [preloadPubkeysKey]); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -635,7 +644,7 @@ class _MessagesSection extends ConsumerWidget { } } -class _MessageTile extends StatelessWidget { +class _MessageTile extends ConsumerWidget { final SearchHit hit; final UserProfile? authorProfile; final Map userCache; @@ -653,12 +662,34 @@ class _MessageTile extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final authorName = authorProfile?.label ?? shortPubkey(hit.pubkey); final timeAgo = relativeTime(hit.createdAt); final channelName = hit.channelName?.trim().replaceFirst(RegExp(r'^#'), ''); final hasChannelName = channelName != null && channelName.isNotEmpty; final isDm = channel?.isDm ?? false; + final profileMentionNames = { + for (final pubkey in mentionedPubkeysFromTags(hit.tags)) + if (userCache[pubkey]?.displayName?.trim().isNotEmpty == true) + pubkey: userCache[pubkey]!.displayName!.trim(), + }; + final mentionPubkeys = mentionedPubkeysFromTags(hit.tags); + final knownAgentPubkeys = channel == null + ? ref.watch(knownAgentPubkeysProvider) + : ref.watch(agentMentionPubkeysProvider(channel!.id)); + final agentMentionPubkeys = agentPubkeysWithProfileOwners( + knownAgentPubkeys: knownAgentPubkeys, + profileOwnedAgentPubkeys: [ + for (final profile in userCache.values) + if (profile.ownerPubkey != null) profile.pubkey, + ], + ); + final mentionNames = mentionNamesWithDirectoryLabels( + mentionPubkeys: mentionPubkeys, + profileMentionNames: profileMentionNames, + directoryDisplayNames: ref.watch(agentDirectoryDisplayNamesProvider), + agentMentionPubkeys: agentMentionPubkeys, + ); return ListTile( key: ValueKey('search-message-row-${hit.eventId}'), @@ -730,6 +761,8 @@ class _MessageTile extends StatelessWidget { MessageContent( key: ValueKey('search-message-body-${hit.eventId}'), content: hit.content, + mentionNames: mentionNames, + agentMentionPubkeys: agentMentionPubkeys, tags: hit.tags, maxLines: 2, baseStyle: activityPreviewTextStyle.copyWith( diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart new file mode 100644 index 0000000000..ea6a2ee50f --- /dev/null +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -0,0 +1,274 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/crypto/nip_oa.dart'; +import '../../shared/relay/relay.dart'; + +/// A relay agent parsed from its kind:10100 agent-profile event. +/// +/// Mirrors the fields desktop's `RelayAgent` uses for mention eligibility +/// (`agentAutocompleteEligibility.ts`): who the agent responds to and which +/// channels it sits in. +class AgentDirectoryEntry { + final String pubkey; + final String? displayName; + final String? respondTo; + final List respondToAllowlist; + final List channelIds; + + const AgentDirectoryEntry({ + required this.pubkey, + this.displayName, + this.respondTo, + this.respondToAllowlist = const [], + this.channelIds = const [], + }); + + factory AgentDirectoryEntry.fromEvent(NostrEvent event) { + final content = _tryDecodeJsonMap(event.content); + return AgentDirectoryEntry( + pubkey: event.pubkey.toLowerCase(), + displayName: + (content?['display_name'] as String?) ?? + (content?['name'] as String?), + respondTo: content?['respond_to'] as String?, + respondToAllowlist: [ + for (final value in (content?['respond_to_allowlist'] as List?) ?? []) + if (value is String) value.toLowerCase(), + ], + channelIds: [ + for (final value in (content?['channel_ids'] as List?) ?? []) + if (value is String) value, + ], + ); + } +} + +Map? _tryDecodeJsonMap(String content) { + try { + final decoded = jsonDecode(content); + return decoded is Map ? decoded : null; + } catch (_) { + return null; + } +} + +/// Relay agent directory from kind:10100 agent-profile events. +/// +/// Watches the session and only fetches after the WebSocket connects. +final agentDirectoryProvider = FutureProvider>(( + ref, +) async { + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) return const []; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory(NostrFilters.agentProfiles()); + return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; +}); + +/// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 +/// profiles. An entry exists only when the `auth` tag verifies — mirrors +/// desktop's `profile_valid_oa_owner_pubkey`. +final agentOwnersProvider = FutureProvider>((ref) async { + final agents = await ref.watch(agentDirectoryProvider.future); + if (agents.isEmpty) return const {}; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.profilesBatch([for (final agent in agents) agent.pubkey]), + ); + final owners = {}; + for (final event in events) { + final owner = verifiedOaOwnerPubkey(event.tags, event.pubkey); + if (owner != null) owners[event.pubkey.toLowerCase()] = owner; + } + return owners; +}); + +/// Pubkeys currently known to represent agents across the active relay. +/// +/// Message surfaces that do not own channel membership can use this shared +/// identity source; channel features add their bot roles separately. +final knownAgentPubkeysProvider = Provider>((ref) { + final relayAgents = + ref.watch(agentDirectoryProvider).asData?.value ?? + const []; + final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + return _AgentPubkeySet({ + for (final agent in relayAgents) agent.pubkey.toLowerCase(), + ...owners.keys.map((pubkey) => pubkey.toLowerCase()), + }); +}); + +/// Directory display names keyed by agent pubkey for mention presentation. +final agentDirectoryDisplayNamesProvider = Provider>((ref) { + final agents = + ref.watch(agentDirectoryProvider).asData?.value ?? + const []; + return Map.unmodifiable({ + for (final agent in agents) + if (agent.displayName?.trim().isNotEmpty == true) + agent.pubkey.toLowerCase(): agent.displayName!.trim(), + }); +}); + +/// Adds channel bot roles to relay-wide agent identities. +Set agentPubkeysWithChannelBots({ + required Set knownAgentPubkeys, + required Iterable channelBotPubkeys, +}) => _AgentPubkeySet({ + ...knownAgentPubkeys, + ...channelBotPubkeys.map((pubkey) => pubkey.toLowerCase()), +}); + +/// Adds agent identities derived from locally cached verified profiles. +Set agentPubkeysWithProfileOwners({ + required Set knownAgentPubkeys, + required Iterable profileOwnedAgentPubkeys, +}) => _AgentPubkeySet({ + ...knownAgentPubkeys, + ...profileOwnedAgentPubkeys.map((pubkey) => pubkey.toLowerCase()), +}); + +/// Preserves profile labels while filling missing agent mentions from the +/// relay's agent directory. +Map mentionNamesWithDirectoryLabels({ + required Iterable mentionPubkeys, + required Map profileMentionNames, + required Map directoryDisplayNames, + required Set agentMentionPubkeys, +}) { + final names = Map.from(profileMentionNames); + for (final pubkey in mentionPubkeys) { + final normalizedPubkey = pubkey.toLowerCase(); + if (names[normalizedPubkey]?.trim().isEmpty == true) { + names.remove(normalizedPubkey); + } + final directoryName = directoryDisplayNames[normalizedPubkey]; + if (!names.containsKey(normalizedPubkey) && directoryName != null) { + names[normalizedPubkey] = directoryName; + } + if (!names.containsKey(normalizedPubkey) && + agentMentionPubkeys.contains(normalizedPubkey)) { + names[normalizedPubkey] = _agentFallbackLabel(normalizedPubkey); + } + } + return names; +} + +String _agentFallbackLabel(String pubkey) => + pubkey.length >= 8 ? pubkey.substring(0, 8) : pubkey; + +/// Keeps the role feed alive for consumers that render mentions outside the +/// channel timeline, such as search results. A membership change refreshes the +/// shared bot-role lookup below, regardless of which surface owns the channel. +class _ChannelBotRoleSubscription extends Notifier { + final String channelId; + void Function()? _unsubscribe; + int _subscriptionVersion = 0; + + _ChannelBotRoleSubscription(this.channelId); + + @override + int build() { + final sessionState = ref.watch(relaySessionProvider); + final subscriptionVersion = ++_subscriptionVersion; + _clearSubscription(); + ref.onDispose(() { + _subscriptionVersion++; + _clearSubscription(); + }); + + if (sessionState.status != SessionStatus.connected) return 0; + Future.microtask(() => _subscribe(channelId, subscriptionVersion)); + return 0; + } + + Future _subscribe(String channelId, int subscriptionVersion) async { + final session = ref.read(relaySessionProvider.notifier); + try { + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: const [39002], + tags: { + '#h': [channelId], + }, + ).copyWithSince(DateTime.now().millisecondsSinceEpoch ~/ 1000), + (_) { + if (_isCurrent(subscriptionVersion)) { + state++; + } + }, + ); + if (!_isCurrent(subscriptionVersion)) { + unsubscribe(); + return; + } + _unsubscribe = unsubscribe; + } catch (error) { + if (_isCurrent(subscriptionVersion)) { + debugPrint( + '[ChannelBotRoleSubscription] failed for $channelId: $error', + ); + } + } + } + + bool _isCurrent(int subscriptionVersion) => + subscriptionVersion == _subscriptionVersion; + + void _clearSubscription() { + _unsubscribe?.call(); + _unsubscribe = null; + } +} + +/// Monotonically increments when the channel's kind:39002 membership snapshot +/// changes. Channel-member and agent-role views share this source so remote +/// membership updates refresh both snapshots together. +final channelMembershipUpdateProvider = NotifierProvider.autoDispose + .family<_ChannelBotRoleSubscription, int, String>( + _ChannelBotRoleSubscription.new, + ); + +/// Bot pubkeys currently assigned a channel bot role. +final channelBotPubkeysProvider = FutureProvider.autoDispose + .family, String>((ref, channelId) async { + ref.watch(channelMembershipUpdateProvider(channelId)); + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) return const {}; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory( + NostrFilters.channelMembers(channelId), + ); + if (events.isEmpty) return const {}; + return _AgentPubkeySet({ + for (final member in membersFromEvent(events.first)) + if (member.role == 'bot') member.pubkey.toLowerCase(), + }); + }); + +/// Pubkeys currently known to represent agents in a channel. +final agentMentionPubkeysProvider = Provider.autoDispose + .family, String>((ref, channelId) { + final channelBotPubkeys = + ref.watch(channelBotPubkeysProvider(channelId)).asData?.value ?? + const {}; + return agentPubkeysWithChannelBots( + knownAgentPubkeys: ref.watch(knownAgentPubkeysProvider), + channelBotPubkeys: channelBotPubkeys, + ); + }); + +class _AgentPubkeySet extends UnmodifiableSetView { + _AgentPubkeySet(Iterable pubkeys) : super(Set.unmodifiable(pubkeys)); + + @override + bool operator ==(Object other) => + other is Set && length == other.length && every(other.contains); + + @override + int get hashCode => Object.hashAllUnordered(this); +} diff --git a/mobile/lib/shared/mentions/mention_tags.dart b/mobile/lib/shared/mentions/mention_tags.dart new file mode 100644 index 0000000000..bf21282715 --- /dev/null +++ b/mobile/lib/shared/mentions/mention_tags.dart @@ -0,0 +1,6 @@ +/// Pubkeys tagged as message mentions, normalized for profile lookups. +Set mentionedPubkeysFromTags(Iterable> tags) => { + for (final tag in tags) + if (tag.length >= 2 && (tag[0] == 'p' || tag[0] == 'mention')) + tag[1].toLowerCase(), +}; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index e5ed0fb352..1c66093899 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -26,6 +26,7 @@ import 'package:buzz/features/channels/small_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/widgets/skeleton.dart'; @@ -188,6 +189,9 @@ Widget _buildTestable({ channelMembersProvider(_channelId).overrideWith( (ref) async => loadMembers != null ? loadMembers() : members, ), + channelBotPubkeysProvider( + _channelId, + ).overrideWith((ref) async => const {}), if (createChannelActions != null) channelActionsProvider.overrideWith(createChannelActions), if (readStateNotifier != null) diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 7b747adfac..919d4039eb 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -17,11 +17,10 @@ import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; -import 'package:buzz/features/channels/mentions/mention_candidates.dart'; -import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; import 'package:buzz/features/channels/photo_library.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -2269,6 +2268,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); @@ -2285,6 +2289,70 @@ void main() { ]); }); + testWidgets( + 'renders chips only for selected agents outside code and composition', + (tester) async { + final semantics = tester.ensureSemantics(); + final signer = nostr.Keys.generate(); + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(signer.nsec), + currentPubkey: signer.public, + relayAgents: [_testAgent('f' * 64)], + channels: [_makeCurrentChannel(), _makeSharedMemberChannel()], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), '@Helper Bot'); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsNothing, + ); + + await tester.enterText(find.byType(TextField), '@hel'); + await tester.pumpAndSettle(); + await tester.tap(find.text('Helper Bot')); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); + expect( + find.bySemanticsLabel('Agent mention: Helper Bot'), + findsOneWidget, + ); + expect(find.bySemanticsLabel('Helper Bot'), findsNothing); + + await tester.enterText(find.byType(TextField), '`@Helper Bot`'); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsNothing, + ); + + await tester.enterText(find.byType(TextField), '@Helper Bot typing'); + final textField = tester.widget(find.byType(TextField)); + textField.controller!.value = textField.controller!.value.copyWith( + composing: const TextRange(start: 12, end: 18), + ); + await tester.pump(); + expect( + find.byKey(const ValueKey('composer-agent-mention-chip')), + findsOneWidget, + ); + await tester.pump(const Duration(milliseconds: 250)); + semantics.dispose(); + }, + ); + testWidgets('does not mutate a DM when mentioning a non-member agent', ( tester, ) async { diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 3f2e8e3d6a..811996857c 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:buzz/features/channels/channel_management_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; final userPubkey = 'a' * 64; final memberPubkey = 'b' * 64; @@ -18,6 +19,20 @@ ChannelMember member(String pubkey, {String role = 'member'}) { } void main() { + test('role-only agent mentions fall back to a pubkey prefix label', () { + const pubkey = 'deadbeef0123456789'; + + expect( + mentionNamesWithDirectoryLabels( + mentionPubkeys: const [pubkey], + profileMentionNames: const {}, + directoryDisplayNames: const {}, + agentMentionPubkeys: const {pubkey}, + ), + const {pubkey: 'deadbeef'}, + ); + }); + group('agentIsSharedWithUser', () { test('anyone-mode agent is shared when a channel overlaps', () { final agent = AgentDirectoryEntry( diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index bc6772fd67..9d8adb69bc 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -1204,6 +1204,43 @@ Photos expect(find.text('Alice'), findsOneWidget); }); + testWidgets('renders a known agent mention with the bot chip', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Ask @Helper Bot to investigate', + mentionNames: {'agent-pubkey': 'Helper Bot'}, + agentMentionPubkeys: {'agent-pubkey'}, + maxLines: 2, + ), + ), + ); + + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect(find.text('@'), findsNothing); + expect(find.text('Helper Bot'), findsOneWidget); + }); + + testWidgets('normalizes passed multi-word agent mentions', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Ask @Helper Bot to investigate', + mentionNames: {'agent-pubkey': 'Helper Bot'}, + agentMentionPubkeys: {'agent-pubkey'}, + ), + ), + ); + + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + expect(find.text('Helper Bot'), findsOneWidget); + expect(_allRichText(tester), isNot(contains('Bot Bot'))); + }); + testWidgets('highlights an entire multi-word display name', ( tester, ) async { diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index e4a576b91d..c3db833fc1 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -10,8 +10,10 @@ import 'package:buzz/features/search/recent_searches_provider.dart'; import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/features/search/search_provider.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../helpers/widget_helpers.dart'; @@ -593,6 +595,72 @@ void main() { await tester.pump(); expect(recentSearches.searches, const ['design']); }); + + testWidgets('renders channel-role bots in message previews', (tester) async { + final channel = Channel( + id: 'channel-1', + name: 'general', + channelType: 'stream', + visibility: 'open', + description: '', + createdBy: 'test', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + ); + const agentPubkey = 'agent-pubkey'; + const cachedProfile = UserProfile(pubkey: 'author-pubkey'); + final state = SearchState( + query: 'helper', + messageResults: [ + SearchHit( + eventId: 'message-1', + content: 'Ask @Helper Bot to investigate', + kind: 9, + pubkey: 'author-pubkey', + channelId: channel.id, + channelName: channel.name, + createdAt: 1, + score: 1, + tags: [ + ['p', agentPubkey], + ], + ), + ], + ); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + searchProvider.overrideWith(() => _FakeSearchNotifier(state)), + recentSearchesProvider.overrideWith( + () => _FakeRecentSearchesNotifier(const []), + ), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith(() => _FakeChannelsNotifier([channel])), + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier(cachedProfile), + ), + knownAgentPubkeysProvider.overrideWith((ref) => const {}), + channelBotPubkeysProvider( + channel.id, + ).overrideWith((ref) async => {agentPubkey}), + agentDirectoryDisplayNamesProvider.overrideWith( + (ref) => const {agentPubkey: 'Helper Bot'}, + ), + ], + child: const SearchPage(), + ), + ); + await tester.pumpAndSettle(); + + final content = tester.widget( + find.byKey(const ValueKey('search-message-body-message-1')), + ); + expect(content.mentionNames, const {agentPubkey: 'Helper Bot'}); + expect(content.agentMentionPubkeys, contains(agentPubkey)); + expect(find.byIcon(LucideIcons.bot), findsOneWidget); + }); } class _FakeSearchNotifier extends SearchNotifier { @@ -646,8 +714,12 @@ class _FakeProfileNotifier extends ProfileNotifier { } class _FakeChannelsNotifier extends ChannelsNotifier { + _FakeChannelsNotifier([this.channels = const []]); + + final List channels; + @override - Future> build() async => const []; + Future> build() async => channels; } class _FakeUserCacheNotifier extends UserCacheNotifier { diff --git a/mobile/test/shared/mentions/agent_identity_provider_test.dart b/mobile/test/shared/mentions/agent_identity_provider_test.dart new file mode 100644 index 0000000000..0ea0afb268 --- /dev/null +++ b/mobile/test/shared/mentions/agent_identity_provider_test.dart @@ -0,0 +1,228 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/channels/agent_activity/working_bots_provider.dart'; +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; + +void main() { + test('refreshes channel bot roles from live membership updates', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + expect(await container.read(channelBotPubkeysProvider(_channelId).future), { + _agentPubkey, + }); + await relaySession.subscribed; + expect(relaySession.liveFilters.single.kinds, const [39002]); + expect(relaySession.liveFilters.single.tags['#h'], [_channelId]); + + relaySession.emit(_membershipEvent(role: 'member')); + await _pumpEventQueue(); + + expect( + await container.read(channelBotPubkeysProvider(_channelId).future), + isEmpty, + ); + }); + + test('refreshes channel members from live membership updates', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + _membershipEvent(role: 'member'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelMembersProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + addTearDown(keepAlive.close); + + expect( + (await container.read( + channelMembersProvider(_channelId).future, + )).single.role, + 'bot', + ); + await relaySession.subscribed; + + relaySession.emit(_membershipEvent(role: 'member')); + await _pumpEventQueue(); + + expect( + (await container.read( + channelMembersProvider(_channelId).future, + )).single.role, + 'member', + ); + }); + + test('disposes the live role subscription without consumers', () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + channelBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + + await container.read(channelBotPubkeysProvider(_channelId).future); + await relaySession.subscribed; + keepAlive.close(); + await container.pump(); + + expect(relaySession.unsubscribeCount, 1); + }); + + test( + 'does not retain a live role subscription through working bots', + () async { + final relaySession = _MembershipRelaySessionNotifier([ + _membershipEvent(role: 'bot'), + ]); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => relaySession)], + ); + addTearDown(container.dispose); + final keepAlive = container.listen( + workingBotPubkeysProvider(_channelId), + (_, _) {}, + fireImmediately: true, + ); + + await relaySession.subscribed; + keepAlive.close(); + await container.pump(); + + expect(relaySession.unsubscribeCount, 1); + }, + ); + + test('blank profile labels defer to the directory label', () { + const pubkey = 'deadbeef0123456789'; + + expect( + mentionNamesWithDirectoryLabels( + mentionPubkeys: const [pubkey], + profileMentionNames: const {pubkey: ' '}, + directoryDisplayNames: const {pubkey: 'Directory bot'}, + agentMentionPubkeys: const {pubkey}, + ), + const {pubkey: 'Directory bot'}, + ); + }); +} + +const _channelId = '11111111-1111-4111-8111-111111111111'; +const _agentPubkey = + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + +NostrEvent _membershipEvent({required String role}) => NostrEvent( + id: 'membership-$role', + pubkey: 'owner', + createdAt: 1, + kind: 39002, + tags: [ + ['d', _channelId], + ['h', _channelId], + ['p', _agentPubkey, 'wss://relay.example', role], + ], + content: '', + sig: 'sig', +); + +Future _pumpEventQueue() async { + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); +} + +class _MembershipRelaySessionNotifier extends RelaySessionNotifier { + final List _memberships; + final List liveFilters = []; + final List<_LiveSubscription> _subscriptions = []; + final Completer _subscribed = Completer(); + var unsubscribeCount = 0; + var _membershipIndex = 0; + + _MembershipRelaySessionNotifier(this._memberships); + + Future get subscribed => _subscribed.future; + + @override + SessionState build() => const SessionState(status: SessionStatus.connected); + + @override + Future> fetchHistory( + NostrFilter filter, { + Duration timeout = const Duration(seconds: 8), + }) async { + return [_memberships[_membershipIndex++]]; + } + + @override + Future subscribe( + NostrFilter filter, + void Function(NostrEvent) onEvent, { + void Function(String message)? onClosed, + }) async { + liveFilters.add(filter); + final subscription = _LiveSubscription(filter, onEvent); + _subscriptions.add(subscription); + if (!_subscribed.isCompleted) _subscribed.complete(); + return () { + unsubscribeCount++; + _subscriptions.remove(subscription); + }; + } + + void emit(NostrEvent event) { + for (final subscription in List.of(_subscriptions)) { + if (_matches(subscription.filter, event)) { + subscription.onEvent(event); + } + } + } +} + +class _LiveSubscription { + final NostrFilter filter; + final void Function(NostrEvent) onEvent; + + const _LiveSubscription(this.filter, this.onEvent); +} + +bool _matches(NostrFilter filter, NostrEvent event) { + if (!filter.kinds.contains(event.kind)) return false; + return filter.tags.entries.every((entry) { + final tagName = entry.key.substring(1); + return event.tags.any( + (tag) => + tag.isNotEmpty && + tag.first == tagName && + tag.skip(1).any(entry.value.contains), + ); + }); +} From f44b5a2477f3979ae66e49153b11be36538cf859 Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Thu, 30 Jul 2026 10:48:54 -0700 Subject: [PATCH 66/99] fix(desktop): reuse profiles when joining communities (#2155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why People joining a community with an existing relay profile should not be asked to recreate their name and avatar. ## What - Check the active identity's relay profile after the joined community becomes active - Skip directly to the starter-team step when a kind-0 profile event exists - Preserve the profile setup path when no event exists or discovery fails - Cover both new-profile and existing-profile join paths in E2E tests ## Risk Assessment Low — the lookup is scoped to the community onboarding profile stage, runs once per transaction, and fails open to the existing flow. ## References - `pnpm build:e2e && pnpm exec playwright test --project=integration tests/e2e/onboarding.spec.ts --grep 'first-community direct join reaches profile|community onboarding reuses an existing relay profile'` (2 passed) Generated with Codex Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> --- .../onboarding/ui/CommunityOnboardingFlow.tsx | 17 ++++++ desktop/src/testing/e2eBridge.ts | 9 +++ desktop/tests/e2e/onboarding.spec.ts | 60 +++++++++++++++++++ desktop/tests/helpers/bridge.ts | 2 + 4 files changed, 88 insertions(+) diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index 4b729ab33f..98210c57cd 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -160,6 +160,7 @@ export function CommunityOnboardingFlow({ [], ); const [isPending, setIsPending] = React.useState(false); + const checkedProfileTransactionRef = React.useRef(null); const [starterChannelFailureCount, setStarterChannelFailureCount] = React.useState(0); const [deniedPubkey, setDeniedPubkey] = React.useState(""); @@ -283,6 +284,22 @@ export function CommunityOnboardingFlow({ }, [isPending, update]); const isProfileStage = transaction?.stage === "profile"; + React.useEffect(() => { + if (!isProfileStage || !transaction) return; + if (checkedProfileTransactionRef.current === transaction.id) return; + + checkedProfileTransactionRef.current = transaction.id; + void getProfile() + .then((profile) => { + if (profile.hasProfileEvent) { + update({ stage: "team-intro", error: undefined }, transaction.id); + } + }) + .catch(() => { + // Discovery is best-effort. Staying on the profile step preserves the + // existing path when the relay cannot answer the lookup. + }); + }, [isProfileStage, transaction, update]); const isTeamStage = transaction?.stage === "team-intro" || transaction?.stage === "finalizing" || diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 73b564429a..d15f2269d3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -277,6 +277,8 @@ type E2eConfig = { channelWindowDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; + /** Override whether get_profile reports a real kind:0 event. */ + profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; searchProfiles?: MockSearchProfileSeed[]; @@ -5378,6 +5380,13 @@ async function handleGetChannels(config: E2eConfig | undefined) { async function handleGetProfile(config: E2eConfig | undefined) { const identity = getIdentity(config); + const forcedHasProfileEvent = config?.mock?.profileHasEvent; + if (forcedHasProfileEvent !== undefined) { + return { + ...cloneProfile(ensureMockProfile(config)), + has_profile_event: forcedHasProfileEvent, + }; + } if (!identity) { const profileReadDelayMs = config?.mock?.profileReadDelayMs ?? 0; if (profileReadDelayMs > 0) { diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0c4d630994..3f6a79dd7b 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1280,6 +1280,66 @@ test("first-community direct join reaches profile", async ({ page }) => { .toEqual({ communityCount: 1, transactionMatchesOnlyCommunity: true }); }); +test("community onboarding reuses an existing relay profile", async ({ + page, +}) => { + await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); + await page.addInitScript( + ({ pubkey, transactionStorageKey }) => { + window.localStorage.setItem( + `buzz-machine-onboarding-complete.v2:${pubkey}`, + "true", + ); + const timestamp = new Date().toISOString(); + window.localStorage.setItem( + transactionStorageKey, + JSON.stringify({ + id: "txn-existing-profile", + source: "add-community", + stage: "profile", + relayUrl: "wss://onboarding.communities.buzz.xyz", + communityName: "Onboarding", + communityId: "e2e-default-community", + createdAt: timestamp, + updatedAt: timestamp, + }), + ); + }, + { + pubkey: BLANK_TYLER_IDENTITY.pubkey, + transactionStorageKey: COMMUNITY_ONBOARDING_TRANSACTION_STORAGE_KEY, + }, + ); + await installMockBridge( + page, + { profileHasEvent: true }, + { + relayWsUrl: "wss://onboarding.communities.buzz.xyz", + skipOnboardingSeed: true, + }, + ); + await page.goto("/"); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as Window & { __BUZZ_E2E_COMMANDS__?: string[] } + ).__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "get_profile", + ).length ?? 0, + ), + ) + .toBeGreaterThan(0); + await expect( + page.getByRole("heading", { name: "Meet your starter team" }), + ).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Build your profile" }), + ).toHaveCount(0); +}); + test("first-community direct join cancel returns to request access", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index ca4d62ddd6..b48a75914b 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -268,6 +268,8 @@ type MockBridgeOptions = { channelWindowDelayMs?: number; profileReadDelayMs?: number; profileReadError?: string; + /** Override whether get_profile reports a real kind:0 event. */ + profileHasEvent?: boolean; profileUpdateError?: string; profileUpdateErrors?: string[]; searchProfiles?: MockSearchProfileSeed[]; From bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 30 Jul 2026 11:15:39 -0700 Subject: [PATCH 67/99] feat(desktop): add password-protected backups in settings (#3701) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** new-feature **User Impact:** Users can create, download, and verify a password-protected backup of their private identity from desktop Settings. **Problem:** Buzz does not currently give signed-in users a Settings-based path to protect or validate their private identity independently of onboarding. **Solution:** Add a focused backup menu to the private-key row, keep encryption and verification local in Rust, and preserve completed encrypted backups briefly so native saves can be retried without repeating encryption.
File changes **desktop/src/features/settings/** Adds the background backup lifecycle, create and test dialogs, private-key menu integration, password handling, and focused unit coverage. **desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx** Extends the masked private-key display with reusable overflow-menu actions used by Settings. **desktop/src/app/App.tsx** Mounts the backup provider at app scope so encryption and save work survive closing Settings or the modal. **desktop/src/shared/api/tauriIdentity.ts** Adds typed desktop bindings for local backup creation, save, selection, and verification. **desktop/src-tauri/src/key_backup.rs and desktop/src-tauri/src/commands/identity.rs** Implements local NIP-49 encryption, password generation, file handling, and public-identity-only verification results. **desktop/src-tauri/src/egress_guard.rs and guarded call sites** Blocks encrypted secret material from relay, websocket, snapshot, sharing, and huddle egress paths. **desktop/src-tauri tests and fixtures** Covers encryption, verification, file behavior, and fail-closed no-egress protections. **desktop/src/testing/e2eBridge.ts, desktop/tests/, and desktop/playwright.config.ts** Expands the mock native bridge and browser coverage across create, retry, expiry, and current/different-identity verification states. **desktop/src-tauri/Cargo.toml, Cargo.lock, and assets** Adds the local cryptography/password-generation dependencies and embedded short-word list.
## Reproduction steps 1. Run the desktop app and open **Settings → Profile → Identity**. 2. Open the private-key overflow menu and choose **Create backup**. 3. Enter or generate a valid password, submit, and confirm progress continues if the dialog or Settings is closed. 4. Save the resulting `.ncryptsec` file; cancel and retry to confirm the temporary download remains available. 5. Choose **Test backup**, select the file, enter a wrong password, then retry with the correct password. 6. Confirm success identifies whether the backup matches the current identity and displays only the public `npub`. ## Screenshots | Settings identity | Private-key menu | Create backup | |---|---|---| | image | image | image | | Encrypting | Download available | Test success | |---|---|---| | image | image | image | Visual review and additional states: [Buzz thread](buzz://message?channel=50ca7ef1-201e-4159-9499-40de3964b7c3&id=87eceb5f0f82fd50c32e560de3d35be48e293760f6620718aafdcef289d475fe) --------- Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 5 +- .../src/assets/eff_short_wordlist_2_0.txt | 1296 +++++++++++++++++ desktop/src-tauri/src/commands/export_util.rs | 34 +- desktop/src-tauri/src/commands/identity.rs | 141 ++ .../src/commands/identity_key_backup_tests.rs | 139 ++ .../src/commands/personas/snapshot/import.rs | 33 +- .../src-tauri/src/commands/team_snapshot.rs | 4 +- .../src/commands/team_snapshot/tests.rs | 28 + desktop/src-tauri/src/egress_guard.rs | 58 + desktop/src-tauri/src/egress_guard_tests.rs | 446 ++++++ desktop/src-tauri/src/huddle/pipeline.rs | 24 +- desktop/src-tauri/src/key_backup.rs | 187 +++ desktop/src-tauri/src/key_backup_tests.rs | 155 ++ desktop/src-tauri/src/lib.rs | 6 + desktop/src-tauri/src/native_websocket.rs | 20 +- desktop/src-tauri/src/relay.rs | 2 + desktop/src-tauri/src/relay/submit.rs | 1 + desktop/src/app/App.tsx | 16 +- .../onboarding/ui/NsecMaskedDisplay.tsx | 100 +- .../settings/EncryptedBackupProvider.tsx | 251 ++++ .../settings/lib/encryptedBackup.test.mjs | 133 ++ .../features/settings/lib/encryptedBackup.ts | 143 ++ .../features/settings/ui/BackupTestFlow.tsx | 459 ++++++ .../settings/ui/EncryptedBackupCreator.tsx | 375 +++++ .../settings/ui/PrivateKeyBackupRow.tsx | 215 +++ .../settings/ui/ProfileSettingsCard.tsx | 93 +- desktop/src/shared/api/tauriIdentity.ts | 49 + desktop/src/testing/e2eBridge.ts | 49 +- .../tests/e2e/profile-backup-settings.spec.ts | 259 ++++ desktop/tests/helpers/bridge.ts | 8 + 32 files changed, 4599 insertions(+), 132 deletions(-) create mode 100644 desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt create mode 100644 desktop/src-tauri/src/commands/identity_key_backup_tests.rs create mode 100644 desktop/src-tauri/src/egress_guard.rs create mode 100644 desktop/src-tauri/src/egress_guard_tests.rs create mode 100644 desktop/src-tauri/src/key_backup.rs create mode 100644 desktop/src-tauri/src/key_backup_tests.rs create mode 100644 desktop/src/features/settings/EncryptedBackupProvider.tsx create mode 100644 desktop/src/features/settings/lib/encryptedBackup.test.mjs create mode 100644 desktop/src/features/settings/lib/encryptedBackup.ts create mode 100644 desktop/src/features/settings/ui/BackupTestFlow.tsx create mode 100644 desktop/src/features/settings/ui/EncryptedBackupCreator.tsx create mode 100644 desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx create mode 100644 desktop/tests/e2e/profile-backup-settings.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c79ef1bf9d..b86406d9b0 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -119,6 +119,7 @@ export default defineConfig({ "**/nostr-bind.spec.ts", "**/mobile-pairing-qr.spec.ts", "**/profile-nsec-reveal.spec.ts", + "**/profile-backup-settings.spec.ts", "**/signout-confirmation.spec.ts", "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index cd0fabb69f..325eb9aa67 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1045,6 +1045,7 @@ dependencies = [ "ed25519-dalek", "flate2", "futures-util", + "getrandom 0.2.17", "hex", "image", "infer", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 735a45c3b7..6f3c03c5a5 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -86,7 +86,10 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" toml = "0.8" -nostr = { version = "0.44", features = ["nip44"] } +nostr = { version = "0.44", features = ["nip44", "nip49"] } +# OS-entropy source for backup passphrase generation (already in the tree as a +# transitive dependency; pinned here for direct use). +getrandom = "0.2" zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } diff --git a/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt b/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt new file mode 100644 index 0000000000..9ac732fe36 --- /dev/null +++ b/desktop/src-tauri/src/assets/eff_short_wordlist_2_0.txt @@ -0,0 +1,1296 @@ +aardvark +abandoned +abbreviate +abdomen +abhorrence +abiding +abnormal +abrasion +absorbing +abundant +abyss +academy +accountant +acetone +achiness +acid +acoustics +acquire +acrobat +actress +acuteness +aerosol +aesthetic +affidavit +afloat +afraid +aftershave +again +agency +aggressor +aghast +agitate +agnostic +agonizing +agreeing +aidless +aimlessly +ajar +alarmclock +albatross +alchemy +alfalfa +algae +aliens +alkaline +almanac +alongside +alphabet +already +also +altitude +aluminum +always +amazingly +ambulance +amendment +amiable +ammunition +amnesty +amoeba +amplifier +amuser +anagram +anchor +android +anesthesia +angelfish +animal +anklet +announcer +anonymous +answer +antelope +anxiety +anyplace +aorta +apartment +apnea +apostrophe +apple +apricot +aquamarine +arachnid +arbitrate +ardently +arena +argument +aristocrat +armchair +aromatic +arrowhead +arsonist +artichoke +asbestos +ascend +aseptic +ashamed +asinine +asleep +asocial +asparagus +astronaut +asymmetric +atlas +atmosphere +atom +atrocious +attic +atypical +auctioneer +auditorium +augmented +auspicious +automobile +auxiliary +avalanche +avenue +aviator +avocado +awareness +awhile +awkward +awning +awoke +axially +azalea +babbling +backpack +badass +bagpipe +bakery +balancing +bamboo +banana +barracuda +basket +bathrobe +bazooka +blade +blender +blimp +blouse +blurred +boatyard +bobcat +body +bogusness +bohemian +boiler +bonnet +boots +borough +bossiness +bottle +bouquet +boxlike +breath +briefcase +broom +brushes +bubblegum +buckle +buddhist +buffalo +bullfrog +bunny +busboy +buzzard +cabin +cactus +cadillac +cafeteria +cage +cahoots +cajoling +cakewalk +calculator +camera +canister +capsule +carrot +cashew +cathedral +caucasian +caviar +ceasefire +cedar +celery +cement +census +ceramics +cesspool +chalkboard +cheesecake +chimney +chlorine +chopsticks +chrome +chute +cilantro +cinnamon +circle +cityscape +civilian +clay +clergyman +clipboard +clock +clubhouse +coathanger +cobweb +coconut +codeword +coexistent +coffeecake +cognitive +cohabitate +collarbone +computer +confetti +copier +cornea +cosmetics +cotton +couch +coverless +coyote +coziness +crawfish +crewmember +crib +croissant +crumble +crystal +cubical +cucumber +cuddly +cufflink +cuisine +culprit +cup +curry +cushion +cuticle +cybernetic +cyclist +cylinder +cymbal +cynicism +cypress +cytoplasm +dachshund +daffodil +dagger +dairy +dalmatian +dandelion +dartboard +dastardly +datebook +daughter +dawn +daytime +dazzler +dealer +debris +decal +dedicate +deepness +defrost +degree +dehydrator +deliverer +democrat +dentist +deodorant +depot +deranged +desktop +detergent +device +dexterity +diamond +dibs +dictionary +diffuser +digit +dilated +dimple +dinnerware +dioxide +diploma +directory +dishcloth +ditto +dividers +dizziness +doctor +dodge +doll +dominoes +donut +doorstep +dorsal +double +downstairs +dozed +drainpipe +dresser +driftwood +droppings +drum +dryer +dubiously +duckling +duffel +dugout +dumpster +duplex +durable +dustpan +dutiful +duvet +dwarfism +dwelling +dwindling +dynamite +dyslexia +eagerness +earlobe +easel +eavesdrop +ebook +eccentric +echoless +eclipse +ecosystem +ecstasy +edged +editor +educator +eelworm +eerie +effects +eggnog +egomaniac +ejection +elastic +elbow +elderly +elephant +elfishly +eliminator +elk +elliptical +elongated +elsewhere +elusive +elves +emancipate +embroidery +emcee +emerald +emission +emoticon +emperor +emulate +enactment +enchilada +endorphin +energy +enforcer +engine +enhance +enigmatic +enjoyably +enlarged +enormous +enquirer +enrollment +ensemble +entryway +enunciate +envoy +enzyme +epidemic +equipment +erasable +ergonomic +erratic +eruption +escalator +eskimo +esophagus +espresso +essay +estrogen +etching +eternal +ethics +etiquette +eucalyptus +eulogy +euphemism +euthanize +evacuation +evergreen +evidence +evolution +exam +excerpt +exerciser +exfoliate +exhale +exist +exorcist +explode +exquisite +exterior +exuberant +fabric +factory +faded +failsafe +falcon +family +fanfare +fasten +faucet +favorite +feasibly +february +federal +feedback +feigned +feline +femur +fence +ferret +festival +fettuccine +feudalist +feverish +fiberglass +fictitious +fiddle +figurine +fillet +finalist +fiscally +fixture +flashlight +fleshiness +flight +florist +flypaper +foamless +focus +foggy +folksong +fondue +footpath +fossil +fountain +fox +fragment +freeway +fridge +frosting +fruit +fryingpan +gadget +gainfully +gallstone +gamekeeper +gangway +garlic +gaslight +gathering +gauntlet +gearbox +gecko +gem +generator +geographer +gerbil +gesture +getaway +geyser +ghoulishly +gibberish +giddiness +giftshop +gigabyte +gimmick +giraffe +giveaway +gizmo +glasses +gleeful +glisten +glove +glucose +glycerin +gnarly +gnomish +goatskin +goggles +goldfish +gong +gooey +gorgeous +gosling +gothic +gourmet +governor +grape +greyhound +grill +groundhog +grumbling +guacamole +guerrilla +guitar +gullible +gumdrop +gurgling +gusto +gutless +gymnast +gynecology +gyration +habitat +hacking +haggard +haiku +halogen +hamburger +handgun +happiness +hardhat +hastily +hatchling +haughty +hazelnut +headband +hedgehog +hefty +heinously +helmet +hemoglobin +henceforth +herbs +hesitation +hexagon +hubcap +huddling +huff +hugeness +hullabaloo +human +hunter +hurricane +hushing +hyacinth +hybrid +hydrant +hygienist +hypnotist +ibuprofen +icepack +icing +iconic +identical +idiocy +idly +igloo +ignition +iguana +illuminate +imaging +imbecile +imitator +immigrant +imprint +iodine +ionosphere +ipad +iphone +iridescent +irksome +iron +irrigation +island +isotope +issueless +italicize +itemizer +itinerary +itunes +ivory +jabbering +jackrabbit +jaguar +jailhouse +jalapeno +jamboree +janitor +jarring +jasmine +jaundice +jawbreaker +jaywalker +jazz +jealous +jeep +jelly +jeopardize +jersey +jetski +jezebel +jiffy +jigsaw +jingling +jobholder +jockstrap +jogging +john +joinable +jokingly +journal +jovial +joystick +jubilant +judiciary +juggle +juice +jujitsu +jukebox +jumpiness +junkyard +juror +justifying +juvenile +kabob +kamikaze +kangaroo +karate +kayak +keepsake +kennel +kerosene +ketchup +khaki +kickstand +kilogram +kimono +kingdom +kiosk +kissing +kite +kleenex +knapsack +kneecap +knickers +koala +krypton +laboratory +ladder +lakefront +lantern +laptop +laryngitis +lasagna +latch +laundry +lavender +laxative +lazybones +lecturer +leftover +leggings +leisure +lemon +length +leopard +leprechaun +lettuce +leukemia +levers +lewdness +liability +library +licorice +lifeboat +lightbulb +likewise +lilac +limousine +lint +lioness +lipstick +liquid +listless +litter +liverwurst +lizard +llama +luau +lubricant +lucidity +ludicrous +luggage +lukewarm +lullaby +lumberjack +lunchbox +luridness +luscious +luxurious +lyrics +macaroni +maestro +magazine +mahogany +maimed +majority +makeover +malformed +mammal +mango +mapmaker +marbles +massager +matchstick +maverick +maximum +mayonnaise +moaning +mobilize +moccasin +modify +moisture +molecule +momentum +monastery +moonshine +mortuary +mosquito +motorcycle +mousetrap +movie +mower +mozzarella +muckiness +mudflow +mugshot +mule +mummy +mundane +muppet +mural +mustard +mutation +myriad +myspace +myth +nail +namesake +nanosecond +napkin +narrator +nastiness +natives +nautically +navigate +nearest +nebula +nectar +nefarious +negotiator +neither +nemesis +neoliberal +nephew +nervously +nest +netting +neuron +nevermore +nextdoor +nicotine +niece +nimbleness +nintendo +nirvana +nuclear +nugget +nuisance +nullify +numbing +nuptials +nursery +nutcracker +nylon +oasis +oat +obediently +obituary +object +obliterate +obnoxious +observer +obtain +obvious +occupation +oceanic +octopus +ocular +office +oftentimes +oiliness +ointment +older +olympics +omissible +omnivorous +oncoming +onion +onlooker +onstage +onward +onyx +oomph +opaquely +opera +opium +opossum +opponent +optical +opulently +oscillator +osmosis +ostrich +otherwise +ought +outhouse +ovation +oven +owlish +oxford +oxidize +oxygen +oyster +ozone +pacemaker +padlock +pageant +pajamas +palm +pamphlet +pantyhose +paprika +parakeet +passport +patio +pauper +pavement +payphone +pebble +peculiarly +pedometer +pegboard +pelican +penguin +peony +pepperoni +peroxide +pesticide +petroleum +pewter +pharmacy +pheasant +phonebook +phrasing +physician +plank +pledge +plotted +plug +plywood +pneumonia +podiatrist +poetic +pogo +poison +poking +policeman +poncho +popcorn +porcupine +postcard +poultry +powerboat +prairie +pretzel +princess +propeller +prune +pry +pseudo +psychopath +publisher +pucker +pueblo +pulley +pumpkin +punchbowl +puppy +purse +pushup +putt +puzzle +pyramid +python +quarters +quesadilla +quilt +quote +racoon +radish +ragweed +railroad +rampantly +rancidity +rarity +raspberry +ravishing +rearrange +rebuilt +receipt +reentry +refinery +register +rehydrate +reimburse +rejoicing +rekindle +relic +remote +renovator +reopen +reporter +request +rerun +reservoir +retriever +reunion +revolver +rewrite +rhapsody +rhetoric +rhino +rhubarb +rhyme +ribbon +riches +ridden +rigidness +rimmed +riptide +riskily +ritzy +riverboat +roamer +robe +rocket +romancer +ropelike +rotisserie +roundtable +royal +rubber +rudderless +rugby +ruined +rulebook +rummage +running +rupture +rustproof +sabotage +sacrifice +saddlebag +saffron +sainthood +saltshaker +samurai +sandworm +sapphire +sardine +sassy +satchel +sauna +savage +saxophone +scarf +scenario +schoolbook +scientist +scooter +scrapbook +sculpture +scythe +secretary +sedative +segregator +seismology +selected +semicolon +senator +septum +sequence +serpent +sesame +settler +severely +shack +shelf +shirt +shovel +shrimp +shuttle +shyness +siamese +sibling +siesta +silicon +simmering +singles +sisterhood +sitcom +sixfold +sizable +skateboard +skeleton +skies +skulk +skylight +slapping +sled +slingshot +sloth +slumbering +smartphone +smelliness +smitten +smokestack +smudge +snapshot +sneezing +sniff +snowsuit +snugness +speakers +sphinx +spider +splashing +sponge +sprout +spur +spyglass +squirrel +statue +steamboat +stingray +stopwatch +strawberry +student +stylus +suave +subway +suction +suds +suffocate +sugar +suitcase +sulphur +superstore +surfer +sushi +swan +sweatshirt +swimwear +sword +sycamore +syllable +symphony +synagogue +syringes +systemize +tablespoon +taco +tadpole +taekwondo +tagalong +takeout +tallness +tamale +tanned +tapestry +tarantula +tastebud +tattoo +tavern +thaw +theater +thimble +thorn +throat +thumb +thwarting +tiara +tidbit +tiebreaker +tiger +timid +tinsel +tiptoeing +tirade +tissue +tractor +tree +tripod +trousers +trucks +tryout +tubeless +tuesday +tugboat +tulip +tumbleweed +tupperware +turtle +tusk +tutorial +tuxedo +tweezers +twins +tyrannical +ultrasound +umbrella +umpire +unarmored +unbuttoned +uncle +underwear +unevenness +unflavored +ungloved +unhinge +unicycle +unjustly +unknown +unlocking +unmarked +unnoticed +unopened +unpaved +unquenched +unroll +unscrewing +untied +unusual +unveiled +unwrinkled +unyielding +unzip +upbeat +upcountry +update +upfront +upgrade +upholstery +upkeep +upload +uppercut +upright +upstairs +uptown +upwind +uranium +urban +urchin +urethane +urgent +urologist +username +usher +utensil +utility +utmost +utopia +utterance +vacuum +vagrancy +valuables +vanquished +vaporizer +varied +vaseline +vegetable +vehicle +velcro +vendor +vertebrae +vestibule +veteran +vexingly +vicinity +videogame +viewfinder +vigilante +village +vinegar +violin +viperfish +virus +visor +vitamins +vivacious +vixen +vocalist +vogue +voicemail +volleyball +voucher +voyage +vulnerable +waffle +wagon +wakeup +walrus +wanderer +wasp +water +waving +wheat +whisper +wholesaler +wick +widow +wielder +wifeless +wikipedia +wildcat +windmill +wipeout +wired +wishbone +wizardry +wobbliness +wolverine +womb +woolworker +workbasket +wound +wrangle +wreckage +wristwatch +wrongdoing +xerox +xylophone +yacht +yahoo +yard +yearbook +yesterday +yiddish +yield +yo-yo +yodel +yogurt +yuppie +zealot +zebra +zeppelin +zestfully +zigzagged +zillion +zipping +zirconium +zodiac +zombie +zookeeper +zucchini diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs index 806f58d739..ded14679c1 100644 --- a/desktop/src-tauri/src/commands/export_util.rs +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -1,16 +1,14 @@ use tauri::AppHandle; use tauri_plugin_dialog::DialogExt; -/// Show a save-file dialog with a custom filter and write `data` to the chosen -/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the -/// user cancelled the dialog. -pub async fn save_bytes_with_dialog( +/// Show a save-file dialog with a custom filter and return the chosen path, +/// or `None` when the user cancelled. Selection only — no write. +pub async fn pick_save_path( app: &AppHandle, suggested_filename: &str, filter_name: &str, extensions: &[&str], - data: &[u8], -) -> Result { +) -> Result, String> { let (tx, rx) = tokio::sync::oneshot::channel(); app.dialog() .file() @@ -23,12 +21,34 @@ pub async fn save_bytes_with_dialog( let selected = rx.await.map_err(|_| "dialog cancelled".to_string())?; let file_path = match selected { Some(p) => p, - None => return Ok(false), + None => return Ok(None), }; let dest = file_path .as_path() .ok_or_else(|| "Save dialog returned an invalid path".to_string())?; + Ok(Some(dest.to_path_buf())) +} + +/// Show a save-file dialog with a custom filter and write `data` to the chosen +/// path. Returns `Ok(true)` when the file was written, `Ok(false)` when the +/// user cancelled the dialog. +/// +/// NOT for secrets: the write is plain `std::fs::write` (no atomic commit, no +/// 0o600). Secret exports go through `pick_save_path` + +/// `key_backup::write_backup_file`. +pub async fn save_bytes_with_dialog( + app: &AppHandle, + suggested_filename: &str, + filter_name: &str, + extensions: &[&str], + data: &[u8], +) -> Result { + let dest = match pick_save_path(app, suggested_filename, filter_name, extensions).await? { + Some(p) => p, + None => return Ok(false), + }; + std::fs::write(dest, data).map_err(|e| format!("Failed to write file: {e}"))?; Ok(true) diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 2840c0ade6..142e3bac88 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -194,6 +194,143 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result { .map_err(|error| format!("encode nsec: {error}")) } +/// Generate a passphrase for a new encrypted backup (EFF short wordlist, OS +/// entropy). `words` is clamped to the range allowed by `key_backup`; +/// `separator` joins the words (defaults to a space). +#[tauri::command] +pub fn generate_backup_passphrase( + words: Option, + separator: Option, +) -> Result { + crate::key_backup::generate_passphrase( + words.map_or(crate::key_backup::DEFAULT_PASSPHRASE_WORDS, |w| w as usize), + separator.as_deref().unwrap_or(" "), + ) +} + +/// Core of [`create_ncryptsec_backup`], factored so tests can drive it with a +/// bare `AppState` + temp dir (and a fast scrypt tier) without an `AppHandle`. +pub(crate) fn create_backup_with_log_n( + state: &AppState, + password: &str, + log_n: u8, +) -> Result { + if password.chars().count() < crate::key_backup::MIN_PASSPHRASE_LEN { + return Err(format!( + "passphrase must be at least {} characters", + crate::key_backup::MIN_PASSPHRASE_LEN + )); + } + + // Serialize against import_identity/persist_current_identity: the blob + // must be derived from — and persisted for — one stable identity. Also + // caps KDF concurrency at one. + let _mutation_guard = state.identity_mutation.lock().map_err(|e| e.to_string())?; + + // Recovery mode (lost/locked) → Err, same gate as signing. + let keys = state.signing_keys()?; + + crate::key_backup::create_backup_blob(&keys, password, log_n) +} + +/// Create a NIP-49 backup of the live identity in memory. +/// +/// Encrypts under `password`, decrypt-verifies the fresh blob against the live +/// pubkey, and returns the `ncryptsec1…` string for the native save flow. The +/// body runs under `identity_mutation`, so identity changes cannot race the KDF. +#[tauri::command] +pub async fn create_ncryptsec_backup( + password: String, + app_handle: tauri::AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let state = app_handle.state::(); + create_backup_with_log_n(&state, &password, crate::key_backup::BACKUP_LOG_N) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupVerification { + pub pubkey: String, + pub npub: String, + pub matches_current_identity: bool, +} + +fn verify_ncryptsec_backup_inner( + state: &AppState, + ncryptsec: &str, + password: &str, +) -> Result { + let keys = crate::key_backup::decrypt_ncryptsec(ncryptsec, password)?; + let pubkey = keys.public_key(); + let current = state.signing_keys()?.public_key(); + Ok(BackupVerification { + pubkey: pubkey.to_hex(), + npub: pubkey + .to_bech32() + .map_err(|e| format!("encode backup identity: {e}"))?, + matches_current_identity: pubkey == current, + }) +} + +/// Decrypt and validate a NIP-49 backup without exposing its secret key. +#[tauri::command] +pub async fn verify_ncryptsec_backup( + ncryptsec: String, + password: String, + app_handle: tauri::AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + let password = zeroize::Zeroizing::new(password); + let state = app_handle.state::(); + verify_ncryptsec_backup_inner(&state, &ncryptsec, &password) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Save a portable copy of an `ncryptsec1…` backup to a user-chosen path. +/// +/// The input must parse as a structurally valid NIP-49 payload. The dialog is +/// selection-only; the write uses secret-file semantics (atomic + 0o600). +/// Never mutates canonical app state. Returns the chosen path, or `None` when +/// the user cancelled. +#[tauri::command] +pub async fn save_ncryptsec_copy( + ncryptsec: String, + app_handle: tauri::AppHandle, +) -> Result, String> { + // Reject anything that is not a valid encrypted-key blob — this command + // must not become a generic file writer. + crate::key_backup::parse_ncryptsec(&ncryptsec)?; + let normalized = ncryptsec.trim().to_string(); + + let dest = match crate::commands::export_util::pick_save_path( + &app_handle, + crate::key_backup::BACKUP_FILE_NAME, + "Password-protected key backup", + &["ncryptsec"], + ) + .await? + { + Some(p) => p, + None => return Ok(None), + }; + + let dest_for_write = dest.clone(); + tokio::task::spawn_blocking(move || { + crate::key_backup::write_backup_file(&dest_for_write, &normalized) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))??; + + Ok(Some(dest.display().to_string())) +} + #[tauri::command] pub async fn import_identity( nsec: String, @@ -589,3 +726,7 @@ mod nostr_identity_binding_tests { assert_eq!(error, "expires_at is expired"); } } + +#[cfg(test)] +#[path = "identity_key_backup_tests.rs"] +mod identity_key_backup_tests; diff --git a/desktop/src-tauri/src/commands/identity_key_backup_tests.rs b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs new file mode 100644 index 0000000000..c36af66879 --- /dev/null +++ b/desktop/src-tauri/src/commands/identity_key_backup_tests.rs @@ -0,0 +1,139 @@ +use super::{create_backup_with_log_n, verify_ncryptsec_backup_inner}; +use crate::app_state::build_app_state; +use nostr::{Keys, ToBech32}; + +/// Fast scrypt tier for tests; production uses BACKUP_LOG_N (18), covered +/// once in key_backup_tests::round_trip_at_production_cost. +const FAST_LOG_N: u8 = 16; +const PASSWORD: &str = "correct horse battery"; + +#[test] +fn verification_returns_only_public_identity_and_match_status() { + let state = build_app_state(); + let backup = create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).unwrap(); + let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); + assert_eq!( + result.pubkey, + state.keys.lock().unwrap().public_key().to_hex() + ); + assert!(result.npub.starts_with("npub1")); + assert!(result.matches_current_identity); +} + +#[test] +fn verification_reports_valid_backup_for_a_different_identity() { + let state = build_app_state(); + let other = Keys::generate(); + let backup = crate::key_backup::create_backup_blob(&other, PASSWORD, FAST_LOG_N).unwrap(); + let result = verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); + assert_eq!(result.pubkey, other.public_key().to_hex()); + assert!(!result.matches_current_identity); +} + +#[test] +fn verification_rejects_wrong_password() { + let state = build_app_state(); + let backup = + crate::key_backup::create_backup_blob(&Keys::generate(), PASSWORD, FAST_LOG_N).unwrap(); + assert_eq!( + verify_ncryptsec_backup_inner(&state, &backup, "wrong password").unwrap_err(), + "wrong backup password or damaged key backup" + ); +} + +#[test] +fn verification_accepts_maximum_supported_kdf_cost() { + let state = build_app_state(); + let backup = crate::key_backup::create_backup_blob( + &Keys::generate(), + PASSWORD, + crate::key_backup::MAX_VERIFY_LOG_N, + ) + .unwrap(); + verify_ncryptsec_backup_inner(&state, &backup, PASSWORD).unwrap(); +} + +#[test] +fn verification_rejects_unsupported_kdf_cost_before_decryption() { + let state = build_app_state(); + let supported = + crate::key_backup::create_backup_blob(&Keys::generate(), PASSWORD, FAST_LOG_N).unwrap(); + let encrypted = crate::key_backup::parse_ncryptsec(&supported).unwrap(); + let mut payload = encrypted.as_vec(); + payload[1] = crate::key_backup::MAX_VERIFY_LOG_N + 1; + let unsupported = nostr::nips::nip49::EncryptedSecretKey::from_slice(&payload) + .unwrap() + .to_bech32() + .unwrap(); + + let err = verify_ncryptsec_backup_inner(&state, &unsupported, PASSWORD).unwrap_err(); + assert_eq!( + err, + format!( + "unsupported backup KDF cost: log_n {} exceeds maximum {}", + crate::key_backup::MAX_VERIFY_LOG_N + 1, + crate::key_backup::MAX_VERIFY_LOG_N + ) + ); +} + +#[test] +fn rejects_short_passphrase() { + let state = build_app_state(); + let err = create_backup_with_log_n(&state, "short", FAST_LOG_N).unwrap_err(); + assert!(err.contains("at least"), "{err}"); +} + +#[test] +fn recovery_mode_blocks_backup_creation() { + let state = build_app_state(); + + state + .identity_lost + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).is_err(), + "lost identity must not be backed up" + ); + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + + state + .keyring_locked + .store(true, std::sync::atomic::Ordering::Release); + assert!( + create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).is_err(), + "locked keyring must not be backed up" + ); +} + +/// Concurrent identity changes serialize with backup creation. +#[test] +fn concurrent_identity_swap_vs_backup_is_serialized() { + let state = std::sync::Arc::new(build_app_state()); + let key_a = state.keys.lock().unwrap().clone(); + let key_b = Keys::generate(); + + let swapper = { + let state = state.clone(); + let key_b = key_b.clone(); + std::thread::spawn(move || { + // Mirrors import_identity's locking: mutation guard held + // across the key swap. + let _guard = state.identity_mutation.lock().unwrap(); + *state.keys.lock().unwrap() = key_b; + }) + }; + + let backup = create_backup_with_log_n(&state, PASSWORD, FAST_LOG_N).unwrap(); + swapper.join().unwrap(); + + let recovered = crate::key_backup::decrypt_ncryptsec(&backup, PASSWORD) + .unwrap() + .public_key(); + assert!( + recovered == key_a.public_key() || recovered == key_b.public_key(), + "backup must match one coherent identity" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index d23efe7730..eccf8ee601 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -685,7 +685,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent /// POST a pre-built signed engram event to the relay, authenticating as the /// new agent. -async fn submit_engram_event( +pub(crate) async fn submit_engram_event( state: &AppState, agent_keys: &nostr::Keys, event_json: &[u8], @@ -695,6 +695,8 @@ async fn submit_engram_event( use crate::relay::build_nip98_auth_header_for_keys; use reqwest::Method; + crate::egress_guard::assert_no_key_backup_bytes(event_json, "persona snapshot engram submit")?; + // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the // wait produces a stale `created_at` that the relay will reject. @@ -739,6 +741,35 @@ async fn submit_engram_event( Ok(()) } +// ── NIP-49 egress guard: boundary 7 (persona snapshot engram submit) ───────── + +#[cfg(test)] +mod egress_guard_tests { + use super::submit_engram_event; + + const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + + /// An engram body carrying an ncryptsec must be rejected by the guard + /// before any network I/O (the target port is a discard address; a guard + /// error — not a connection error — proves the abort ordering). + #[tokio::test] + async fn blocks_ncryptsec_before_network() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let body = format!("{{\"content\":\"{NCRYPTSEC}\"}}"); + let err = submit_engram_event( + &state, + &keys, + body.as_bytes(), + "http://127.0.0.1:9/events", + None, + ) + .await + .unwrap_err(); + assert!(err.contains("key-backup material"), "{err}"); + } +} + #[cfg(test)] mod import_avatar_tests { use super::materialize_import_avatar; diff --git a/desktop/src-tauri/src/commands/team_snapshot.rs b/desktop/src-tauri/src/commands/team_snapshot.rs index 91a0126f58..97cd11933d 100644 --- a/desktop/src-tauri/src/commands/team_snapshot.rs +++ b/desktop/src-tauri/src/commands/team_snapshot.rs @@ -895,7 +895,7 @@ fn retain_agent_pending(app: &AppHandle, state: &AppState, record: &ManagedAgent /// POST a pre-built signed engram event to the relay, authenticating as the /// new agent. Mirrors the same helper in `snapshot::import`. -async fn submit_engram_event( +pub(crate) async fn submit_engram_event( state: &AppState, agent_keys: &nostr::Keys, event_json: &[u8], @@ -905,6 +905,8 @@ async fn submit_engram_event( use crate::relay::build_nip98_auth_header_for_keys; use reqwest::Method; + crate::egress_guard::assert_no_key_backup_bytes(event_json, "team snapshot engram submit")?; + // Wait before signing: the relay enforces NIP-98 freshness (±60s) and the // gate may hold for up to MAX_HINT_SECONDS (300s). Building auth before the // wait produces a stale `created_at` that the relay will reject. diff --git a/desktop/src-tauri/src/commands/team_snapshot/tests.rs b/desktop/src-tauri/src/commands/team_snapshot/tests.rs index 0616411307..c9a6d8812a 100644 --- a/desktop/src-tauri/src/commands/team_snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/team_snapshot/tests.rs @@ -733,3 +733,31 @@ fn full_rollback_at_teams_boundary_absent_agents_store() { assert!(!teams_path.exists()); assert_eq!(errors.len(), 1, "only the teams-write error"); } + +// ── NIP-49 egress guard: boundary 6 (team snapshot engram submit) ──────────── + +mod egress_guard_boundary { + use super::super::submit_engram_event; + + const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + + /// An engram body carrying an ncryptsec must be rejected by the guard + /// before any network I/O (the target port is a discard address; a guard + /// error — not a connection error — proves the abort ordering). + #[tokio::test] + async fn blocks_ncryptsec_before_network() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let body = format!("{{\"content\":\"{NCRYPTSEC}\"}}"); + let err = submit_engram_event( + &state, + &keys, + body.as_bytes(), + "http://127.0.0.1:9/events", + None, + ) + .await + .unwrap_err(); + assert!(err.contains("key-backup material"), "{err}"); + } +} diff --git a/desktop/src-tauri/src/egress_guard.rs b/desktop/src-tauri/src/egress_guard.rs new file mode 100644 index 0000000000..db58ddafa0 --- /dev/null +++ b/desktop/src-tauri/src/egress_guard.rs @@ -0,0 +1,58 @@ +//! Relay egress guard for NIP-49 key-backup material. +//! +//! The local `ncryptsec` backup (see [`crate::key_backup`]) must NEVER be +//! transmitted to a relay. This module enforces that contract at runtime, +//! fail-closed, at every relay-bound egress boundary: +//! +//! | # | Boundary | Site | +//! |---|----------|------| +//! | 1 | `submit_signed_event_at_with_keys` (funnel for `submit_event*`) | `relay/submit.rs` | +//! | 2 | `sync_managed_agent_profile` | `relay.rs` | +//! | 3 | pre-signed path into the boundary-1 funnel | `relay/submit.rs` | +//! | 4 | `submit_signed_event_with_keys` | `relay.rs` | +//! | 5 | huddle STT publisher | `huddle/pipeline.rs` | +//! | 6 | `submit_engram_event` (team snapshot) | `commands/team_snapshot.rs` | +//! | 7 | `submit_engram_event` (persona import) | `commands/personas/snapshot/import.rs` | +//! | 8 | native websocket send loop (all webview relay WS) | `native_websocket.rs` | +//! +//! The inventory-completeness test in `egress_guard_tests.rs` asserts that +//! every `/events` URL-construction site in the tree calls this guard, so a +//! new submission path fails the build until it is wired. +//! +//! Scope: `ncryptsec1` only. The raw `nsec` intentionally transits the +//! NIP-44-encrypted pairing session (NIP-AB payload_type "nsec"); guarding it +//! here would break pairing. Raw-key DLP is separate policy work. + +/// Bech32 HRP of NIP-49 encrypted secret keys. +const NCRYPTSEC_PREFIX: &str = "ncryptsec1"; +/// Bech32 also permits an ALL-UPPERCASE encoding of the same payload +/// (BIP-173); an uppercased valid backup decodes identically, so the guard +/// must reject it too. Mixed case is invalid bech32 and cannot decode — a +/// substring matching either all-lower or all-upper prefix covers every +/// decodable form. +const NCRYPTSEC_PREFIX_UPPER: &str = "NCRYPTSEC1"; + +/// Reject `text` if it contains NIP-49 key-backup material. +/// +/// Returns `Err` when an `ncryptsec1…` (or uppercase `NCRYPTSEC1…`) +/// substring is present. Callers MUST abort the network operation on `Err` — +/// this is a fail-closed guard, not a warning. +pub fn assert_no_key_backup(text: &str, context: &'static str) -> Result<(), String> { + if text.contains(NCRYPTSEC_PREFIX) || text.contains(NCRYPTSEC_PREFIX_UPPER) { + return Err(format!( + "blocked {context}: payload contains NIP-49 key-backup material \ + (ncryptsec); the local key backup must never be transmitted to a relay" + )); + } + Ok(()) +} + +/// Byte-slice variant for callers that hold serialized bodies. +pub fn assert_no_key_backup_bytes(body: &[u8], context: &'static str) -> Result<(), String> { + // ncryptsec is ASCII bech32; a UTF-8-lossy view preserves any occurrence. + assert_no_key_backup(&String::from_utf8_lossy(body), context) +} + +#[cfg(test)] +#[path = "egress_guard_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs new file mode 100644 index 0000000000..f487c8ce16 --- /dev/null +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -0,0 +1,446 @@ +use super::*; + +/// NIP-49 spec vector — a real ncryptsec blob for injection payloads. +const NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +fn assert_guard_error(err: &str) { + assert!( + err.contains("key-backup material"), + "expected the egress-guard error, got: {err}" + ); +} + +// ── Guard unit behavior ─────────────────────────────────────────────────────── + +#[test] +fn rejects_ncryptsec_anywhere_in_text() { + assert_guard_error(&assert_no_key_backup(NCRYPTSEC, "test").unwrap_err()); + assert_guard_error( + &assert_no_key_backup( + &format!("{{\"content\":\"my backup: {NCRYPTSEC}\"}}"), + "test", + ) + .unwrap_err(), + ); +} + +/// Bech32 permits an all-uppercase encoding of the same payload — an +/// uppercased valid backup must not bypass the guard (text and bytes). +/// Mixed case is invalid bech32 (cannot decode) and is deliberately not +/// blocked. +#[test] +fn rejects_uppercase_ncryptsec() { + let upper = NCRYPTSEC.to_ascii_uppercase(); + assert_guard_error(&assert_no_key_backup(&upper, "test").unwrap_err()); + assert_guard_error(&assert_no_key_backup_bytes(upper.as_bytes(), "test").unwrap_err()); + // Mixed case cannot decode; not blocked. + assert!(assert_no_key_backup("nCrYpTsEc1qgg9947r", "test").is_ok()); +} + +#[test] +fn passes_clean_payloads_including_raw_nsec() { + assert!(assert_no_key_backup("hello world", "test").is_ok()); + assert!(assert_no_key_backup("", "test").is_ok()); + // Scope is ncryptsec1 ONLY: raw nsec intentionally transits the encrypted + // pairing session and must NOT be blocked (plan D4 / pairing.rs). + let nsec = nostr::ToBech32::to_bech32(nostr::Keys::generate().secret_key()).unwrap(); + assert!(assert_no_key_backup(&nsec, "test").is_ok()); + // Near-miss prefixes are not blocked. + assert!(assert_no_key_backup("ncryptsec", "test").is_ok()); +} + +#[test] +fn byte_variant_matches_text_variant() { + assert_guard_error(&assert_no_key_backup_bytes(NCRYPTSEC.as_bytes(), "test").unwrap_err()); + assert!(assert_no_key_backup_bytes(b"clean body", "test").is_ok()); + // Invalid UTF-8 around an intact ncryptsec substring must still trip the + // guard (from_utf8_lossy preserves the ASCII run). + let mut body = vec![0xff, 0xfe]; + body.extend_from_slice(NCRYPTSEC.as_bytes()); + body.push(0xff); + assert_guard_error(&assert_no_key_backup_bytes(&body, "test").unwrap_err()); +} + +#[test] +fn error_names_the_boundary_context() { + let err = assert_no_key_backup(NCRYPTSEC, "huddle STT publish").unwrap_err(); + assert!(err.contains("huddle STT publish"), "{err}"); +} + +// ── Runtime injection per boundary ──────────────────────────────────────────── +// +// Each test drives the real production function with an ncryptsec-bearing +// payload and asserts the guard aborts the operation before any network I/O +// (no listener exists at the target address; a distinctive guard error — not +// a connection error — proves the abort happened first). +// +// Boundaries 6 and 7 (`submit_engram_event` twins) are module-private inside +// `commands`; their injection tests live next to them: +// - commands/team_snapshot/tests.rs::egress_guard_boundary +// - commands/personas/snapshot/import.rs::egress_guard_tests + +/// Boundary 1: `relay/submit.rs` `submit_event_at_with_keys` (the funnel for +/// all `submit_event*` variants). +#[tokio::test] +async fn boundary_submit_event_at_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let builder = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC); + let err = crate::relay::submit_event_at_with_keys( + builder, + &state, + "http://127.0.0.1:9", // discard port — must never be reached + &keys, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 2: `relay.rs` `sync_managed_agent_profile` (agent kind:0 profile). +#[tokio::test] +async fn boundary_sync_managed_agent_profile_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let err = crate::relay::sync_managed_agent_profile( + &state, + "ws://127.0.0.1:9", + &keys, + &format!("agent {NCRYPTSEC}"), + None, + None, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 3: `relay/submit.rs` `submit_signed_event_at_with_keys` — the +/// pre-signed entry into the boundary-1 funnel (main's submit refactor +/// replaced `relay.rs` `submit_signed_event` with this scoped form). +#[tokio::test] +async fn boundary_submit_signed_event_at_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC) + .sign_with_keys(&keys) + .unwrap(); + let err = crate::relay::submit_signed_event_at_with_keys( + &event, + &state, + "http://127.0.0.1:9", // discard port — must never be reached + &keys, + ) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 4: `relay.rs` `submit_signed_event_with_keys`. +#[tokio::test] +async fn boundary_submit_signed_event_with_keys_blocks_ncryptsec() { + let state = crate::app_state::build_app_state(); + *state.relay_url_override.lock().unwrap() = Some("ws://127.0.0.1:9".to_string()); + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), NCRYPTSEC) + .sign_with_keys(&keys) + .unwrap(); + let err = crate::relay::submit_signed_event_with_keys(&event, &state, &keys, None) + .await + .unwrap_err(); + assert_guard_error(&err); +} + +/// Boundary 5: huddle STT publisher (`huddle/pipeline.rs`). +#[test] +fn boundary_huddle_stt_blocks_ncryptsec() { + let keys = nostr::Keys::generate(); + let channel = uuid::Uuid::new_v4(); + let builder = + crate::events::build_message(channel, NCRYPTSEC, None, &[], &[], &[], &[]).unwrap(); + let err = crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).unwrap_err(); + assert_guard_error(&err); + + // Clean transcripts pass through the same seam. + let builder = + crate::events::build_message(channel, "hello huddle", None, &[], &[], &[], &[]).unwrap(); + assert!(crate::huddle::pipeline::sign_and_guard_stt_body(builder, &keys).is_ok()); +} + +/// Boundary 8: native websocket send loop — the single choke point for all +/// webview-originated relay websocket frames. +#[tokio::test] +async fn boundary_native_websocket_blocks_ncryptsec() { + let manager = crate::native_websocket::WebSocketManager::default(); + // Text frame: guard fires before the connection lookup, so no connection + // is needed — and the error must be the guard's, not "not found". + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Text(format!( + "[\"EVENT\",{{\"content\":\"{NCRYPTSEC}\"}}]" + )), + ) + .await + .unwrap_err(); + assert_guard_error(&err); + + // Binary frame variant. + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Binary(NCRYPTSEC.as_bytes().to_vec()), + ) + .await + .unwrap_err(); + assert_guard_error(&err); + + // Clean frames fall through to normal handling ("connection not found" + // here — the guard did not reject them). + let err = crate::native_websocket::send_message( + &manager, + 1, + crate::native_websocket::WebSocketMessage::Text("[\"REQ\",\"sub\",{}]".to_string()), + ) + .await + .unwrap_err(); + assert!(err.contains("not found"), "{err}"); +} + +// ── Structural tripwires ────────────────────────────────────────────────────── + +fn src_rust_files() -> Vec { + fn walk(dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, out); + } else if path.extension().and_then(|e| e.to_str()) == Some("rs") { + out.push(path); + } + } + } + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut out = Vec::new(); + walk(&root, &mut out); + out +} + +/// Site-granular `/events` inventory: `(file suffix, expected non-comment +/// `/events` occurrences, expected guard call sites — full-path calls into +/// the egress-guard module)`. +/// +/// Every entry pairs the URL-construction count with the guard-call count for +/// that file, so BOTH of these fail the scan (not just a brand-new file): +/// - adding an unguarded ninth `/events` site inside an already-listed file +/// (count goes up without a matching table update), and +/// - removing/refactoring away a guard call while its egress site remains. +/// +/// Updating a row here is the deliberate act that must accompany wiring the +/// guard + adding an injection test for the new site. +const EVENTS_INVENTORY: &[(&str, usize, usize)] = &[ + // Production egress boundaries (see egress_guard.rs table): + ("src/relay.rs", 2, 2), // boundaries 2, 4 + ("src/relay/submit.rs", 1, 1), // boundaries 1 + 3 (shared funnel) + ("src/huddle/pipeline.rs", 1, 1), // boundary 5 + ("src/commands/team_snapshot.rs", 1, 1), // boundary 6 + ("src/commands/personas/snapshot/import.rs", 2, 1), // boundary 7 + its in-file injection-test fixture URL + ("src/native_websocket.rs", 0, 2), // boundary 8 (WS frames; no events URL) + // Test-only fixtures — no production egress, no guard: + ("src/relay_admission.rs", 1, 0), + ("src/archive/mod_tests.rs", 1, 0), + ("src/managed_agents/persona_events/tests.rs", 1, 0), + ("src/commands/team_snapshot/tests.rs", 1, 0), + // Mock-relay route in its in-file tests; production publish goes through + // the guarded boundary-1 funnel (`submit_signed_event_at_with_keys`). + ("src/commands/personas/sharing.rs", 1, 0), +]; + +// Needles are assembled at runtime so this scan file itself contains no +// contiguous match and needs no self-referential inventory row. +fn events_needle() -> String { + ["/ev", "ents"].concat() +} +fn guard_needle() -> String { + ["egress_guard::", "assert_no_key_backup"].concat() +} + +/// Pure scan core over `(relative path, content)` pairs. Returns violations; +/// empty means every file matches its inventory row exactly (files absent +/// from the table are expected to have zero `/events` sites and zero guard +/// calls). +fn events_inventory_violations(files: &[(String, String)]) -> Vec { + let events = events_needle(); + let guard = guard_needle(); + let mut violations = Vec::new(); + + for (rel, content) in files { + let expected = EVENTS_INVENTORY + .iter() + .find(|(suffix, _, _)| rel.ends_with(suffix)) + .map(|&(_, e, g)| (e, g)) + .unwrap_or((0, 0)); + + let mut event_sites = Vec::new(); + for (i, line) in content.lines().enumerate() { + if line.trim_start().starts_with("//") { + continue; // doc/comment mentions + } + if line.contains(&events) { + event_sites.push(format!(" {rel}:{}: {}", i + 1, line.trim())); + } + } + let guard_count = content.matches(&guard).count(); + + if (event_sites.len(), guard_count) != expected { + violations.push(format!( + "{rel}: found {} events-URL site(s) + {} guard call(s), inventory \ + expects {} + {}. Sites found:\n{}", + event_sites.len(), + guard_count, + expected.0, + expected.1, + if event_sites.is_empty() { + " (none)".to_string() + } else { + event_sites.join("\n") + }, + )); + } + } + violations +} + +fn read_src_files() -> Vec<(String, String)> { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + src_rust_files() + .into_iter() + .map(|path| { + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + let content = std::fs::read_to_string(&path).unwrap(); + (rel, content) + }) + .collect() +} + +/// Inventory completeness: every `/events` URL-construction site in +/// `desktop/src-tauri/src` must match the site-granular inventory above. A +/// future ninth submission path — in a NEW file or an ALREADY-LISTED one — +/// fails this test until its guard is wired, its injection test exists, and +/// its inventory row is updated. +#[test] +fn events_url_inventory_is_fully_guarded() { + let violations = events_inventory_violations(&read_src_files()); + assert!( + violations.is_empty(), + "events-URL egress inventory drift — wire crate::egress_guard, add an \ + injection test, then update EVENTS_INVENTORY:\n{}", + violations.join("\n") + ); +} + +/// Mutation-style proof of the tripwire's guarantee: an unguarded ninth +/// `/events` site added to an already-inventoried file (relay.rs) is caught. +#[test] +fn inventory_scan_catches_new_site_in_allowlisted_file() { + let mut files = read_src_files(); + let relay = files + .iter_mut() + .find(|(rel, _)| rel.ends_with("src/relay.rs")) + .expect("relay.rs must be in the scan set"); + relay.1.push_str(&format!( + "\nfn sneaky_ninth_site(base: &str) -> String {{ format!(\"{{base}}{}\") }}\n", + events_needle() + )); + let violations = events_inventory_violations(&files); + assert!( + violations.iter().any(|v| v.contains("src/relay.rs")), + "an unguarded ninth events-URL site in relay.rs must trip the scan: {violations:?}" + ); +} + +/// The pairing also fires in reverse: a guard call deleted while its egress +/// site remains is caught. +#[test] +fn inventory_scan_catches_removed_guard_call() { + let mut files = read_src_files(); + let relay = files + .iter_mut() + .find(|(rel, _)| rel.ends_with("src/relay.rs")) + .expect("relay.rs must be in the scan set"); + relay.1 = relay.1.replacen(&guard_needle(), "removed_guard", 1); + let violations = events_inventory_violations(&files); + assert!( + violations.iter().any(|v| v.contains("src/relay.rs")), + "a removed guard call in relay.rs must trip the scan: {violations:?}" + ); +} + +/// A brand-new file with an `/events` site (no inventory row) is caught. +#[test] +fn inventory_scan_catches_new_unlisted_file() { + let mut files = read_src_files(); + files.push(( + "src/brand_new_egress.rs".to_string(), + format!("let url = format!(\"{{}}{}\", base);", events_needle()), + )); + let violations = events_inventory_violations(&files); + assert!( + violations + .iter() + .any(|v| v.contains("src/brand_new_egress.rs")), + "{violations:?}" + ); +} + +/// Source allowlist: NIP-49 material handling is confined to the identity / +/// backup / import / guard files. Anything else touching ncryptsec or the +/// nip49 codec is structural drift. +#[test] +fn ncryptsec_handling_is_confined_to_allowlisted_files() { + let allowlist: &[&str] = &[ + "src/key_backup.rs", + "src/key_backup_tests.rs", + "src/egress_guard.rs", + "src/egress_guard_tests.rs", + "src/commands/identity.rs", + "src/commands/identity_key_backup_tests.rs", + "src/lib.rs", // module registration + invoke handler + // boundary wiring (guard call sites name the module, not the codec): + "src/relay.rs", + "src/relay/submit.rs", + "src/huddle/pipeline.rs", + "src/commands/team_snapshot.rs", + "src/commands/team_snapshot/tests.rs", + "src/commands/personas/snapshot/import.rs", + "src/native_websocket.rs", + ]; + + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + let mut violations = Vec::new(); + for path in src_rust_files() { + let rel = path + .strip_prefix(root) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if allowlist.iter().any(|a| rel.ends_with(a)) { + continue; + } + let content = std::fs::read_to_string(&path).unwrap(); + for needle in ["ncryptsec", "EncryptedSecretKey", "nip49"] { + if content.contains(needle) { + violations.push(format!("{rel}: contains {needle:?}")); + } + } + } + assert!( + violations.is_empty(), + "NIP-49 material outside allowlisted files:\n{}", + violations.join("\n") + ); +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index ceccedd8b6..6a4cf26201 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -251,6 +251,23 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result Result, String> { + let event = builder + .sign_with_keys(keys) + .map_err(|e| format!("sign event: {e}"))?; + let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "huddle STT publish")?; + Ok(body_bytes) +} + /// Spawn a tokio task that reads text_rx and posts kind:9 events. /// /// Fix 1: `agent_pubkeys_arc` is an `Arc>>` cloned from @@ -310,14 +327,13 @@ pub(crate) fn spawn_transcription_task( // the kind event and build NIP-98 auth after the wait so both // timestamps are fresh — single clean order: wait → sign → auth → send. crate::relay_admission::wait_for_rate_limit().await; - let event = match builder.sign_with_keys(&keys) { - Ok(e) => e, + let body_bytes = match sign_and_guard_stt_body(builder, &keys) { + Ok(b) => b, Err(e) => { - eprintln!("buzz-desktop: STT sign event: {e}"); + eprintln!("buzz-desktop: STT publish: {e}"); continue; } }; - let body_bytes = event.as_json().into_bytes(); let url = format!("{relay_base_url}/events"); let auth_header = match crate::relay::build_nip98_auth_header_for_keys( &keys, diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs new file mode 100644 index 0000000000..6396911aef --- /dev/null +++ b/desktop/src-tauri/src/key_backup.rs @@ -0,0 +1,187 @@ +//! NIP-49 encrypted local key backup. +//! +//! Creates a password-encrypted `ncryptsec` backup of the user's identity key +//! for a user-selected local file. The blob is **local-only by contract**: it must +//! never be transmitted to a relay on any path. That contract is enforced at +//! runtime by [`crate::egress_guard`] (wired into every relay event-body +//! constructor and the native websocket send loop) and structurally by the +//! source-allowlist scan in this module's tests. +//! +//! Creation decrypt-verifies the fresh blob against the live identity before +//! returning it. Portable copies use atomic, owner-only file writes. + +use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; +use nostr::{FromBech32, Keys, ToBech32}; + +/// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB). +/// The blob self-describes its cost, so this can be raised later without +/// breaking existing backups. +pub const BACKUP_LOG_N: u8 = 18; + +/// Highest scrypt cost accepted when decrypting an untrusted backup. +/// +/// NIP-49 intentionally leaves `log_n` client-selected. Capping it at the tier +/// Buzz itself emits keeps generated and upstream-compatible lower-cost backups +/// readable without allowing a crafted payload to request unbounded memory +/// before password authentication. +pub const MAX_VERIFY_LOG_N: u8 = BACKUP_LOG_N; + +/// Filename of the app-managed canonical backup inside the app data dir. +pub const BACKUP_FILE_NAME: &str = "identity.ncryptsec"; + +/// Default number of words in a generated backup passphrase. Three words +/// from a 1296-word list ≈ 31 bits of entropy before the scrypt work factor. +pub const DEFAULT_PASSPHRASE_WORDS: usize = 3; + +/// Bounds for the generator's word-count control. At the lower bound a draw +/// can fall below [`MIN_PASSPHRASE_LEN`] (three 3-char words), so +/// [`generate_passphrase`] re-draws until the phrase meets the minimum. +pub const MIN_PASSPHRASE_WORDS: usize = 3; +pub const MAX_PASSPHRASE_WORDS: usize = 10; + +/// EFF short wordlist 2.0 (1296 words, one per line). +const WORDLIST: &str = include_str!("assets/eff_short_wordlist_2_0.txt"); + +/// Minimum length for a user-chosen passphrase. +pub const MIN_PASSPHRASE_LEN: usize = 12; + +/// Encrypt the identity secret key under `password` and verify the result. +/// +/// Returns the bech32 `ncryptsec1…` string. The fresh blob is decrypted and +/// its derived pubkey compared to the live identity **before** returning, so +/// a returned blob is always provably recoverable with the same password. +pub fn create_backup_blob(keys: &Keys, password: &str, log_n: u8) -> Result { + let secret_key = keys.secret_key(); + + let encrypted = EncryptedSecretKey::new(secret_key, password, log_n, KeySecurity::Unknown) + .map_err(|e| format!("encrypt key backup: {e}"))?; + + let ncryptsec = encrypted + .to_bech32() + .map_err(|e| format!("encode ncryptsec: {e}"))?; + + // Integrity check: decrypt the fresh blob and confirm it recovers the + // exact live identity. A corrupted or mis-encrypted blob must never be + // shown to the user as a "backup". This is the second, deliberate KDF + // invocation of the one-artifact-per-action contract. + verify_backup_blob(&ncryptsec, password, &keys.public_key())?; + + Ok(ncryptsec) +} + +/// Decrypt `ncryptsec` with `password` and assert it recovers a key whose +/// public key equals `expected_pubkey`. +pub fn verify_backup_blob( + ncryptsec: &str, + password: &str, + expected_pubkey: &nostr::PublicKey, +) -> Result<(), String> { + let encrypted = parse_ncryptsec(ncryptsec)?; + let recovered = encrypted + .decrypt(password) + .map_err(|e| format!("verify key backup (decrypt): {e}"))?; + let recovered_keys = Keys::new(recovered); + if recovered_keys.public_key() != *expected_pubkey { + return Err("verify key backup: decrypted key does not match identity".to_string()); + } + Ok(()) +} + +/// Parse a bech32 `ncryptsec1…` string, rejecting anything that is not a +/// structurally valid NIP-49 payload. +pub fn parse_ncryptsec(input: &str) -> Result { + EncryptedSecretKey::from_bech32(input.trim()).map_err(|e| format!("invalid ncryptsec: {e}")) +} + +/// Decrypt an `ncryptsec1…` string with `password` into identity keys. +pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { + let encrypted = parse_ncryptsec(input)?; + let log_n = encrypted.log_n(); + if log_n > MAX_VERIFY_LOG_N { + return Err(format!( + "unsupported backup KDF cost: log_n {log_n} exceeds maximum {MAX_VERIFY_LOG_N}" + )); + } + let secret_key = encrypted + .decrypt(password) + .map_err(|_| "wrong backup password or damaged key backup".to_string())?; + Ok(Keys::new(secret_key)) +} + +/// Atomically write `ncryptsec` to `path` with owner-only permissions, then +/// reread and byte-compare. Same crash-safety pattern as +/// `app_state::save_key_file`. +pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { + use atomic_write_file::AtomicWriteFile; + use std::io::Write; + + let mut file = AtomicWriteFile::open(path) + .map_err(|e| format!("open backup file for atomic write: {e}"))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600)) + .map_err(|e| format!("set backup file permissions: {e}"))?; + } + + file.write_all(ncryptsec.as_bytes()) + .map_err(|e| format!("write backup file: {e}"))?; + file.commit() + .map_err(|e| format!("commit backup file: {e}"))?; + + // Reread and byte-compare: only report success for bytes that are + // actually on disk. + let on_disk = std::fs::read_to_string(path).map_err(|e| format!("reread backup file: {e}"))?; + if on_disk != ncryptsec { + return Err("backup file verification failed: on-disk bytes differ".to_string()); + } + + Ok(()) +} + +/// Generate a passphrase of `word_count` EFF short-wordlist words joined by +/// `separator`, using OS entropy. +/// +/// `word_count` is clamped to `MIN_PASSPHRASE_WORDS..=MAX_PASSPHRASE_WORDS`. +/// Because a low-word-count draw can land under [`MIN_PASSPHRASE_LEN`] +/// (e.g. three 3-char words), whole phrases below the minimum are rejected +/// and re-drawn — the result always passes the same length gate applied to +/// user-chosen passphrases. Uses rejection sampling for a uniform +/// distribution over the 1296 words. +pub fn generate_passphrase(word_count: usize, separator: &str) -> Result { + let word_count = word_count.clamp(MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS); + let words: Vec<&str> = WORDLIST.lines().filter(|l| !l.is_empty()).collect(); + if words.len() != 1296 { + return Err(format!( + "wordlist corrupted: expected 1296 words, found {}", + words.len() + )); + } + + // At 3 words the under-length probability per draw is small, so a few + // attempts always suffice; the cap only guards against a logic bug + // becoming an infinite loop. + for _ in 0..128 { + let mut chosen: Vec<&str> = Vec::with_capacity(word_count); + while chosen.len() < word_count { + let mut buf = [0u8; 2]; + getrandom::getrandom(&mut buf).map_err(|e| format!("entropy source: {e}"))?; + let value = u16::from_le_bytes(buf); + // Rejection sampling: accept only values below the largest + // multiple of 1296 that fits in u16 (65536 - 65536 % 1296 = 64800). + if value < 64800 { + chosen.push(words[(value as usize) % 1296]); + } + } + let phrase = chosen.join(separator); + if phrase.chars().count() >= MIN_PASSPHRASE_LEN { + return Ok(phrase); + } + } + Err("could not generate a passphrase meeting the minimum length".to_string()) +} + +#[cfg(test)] +#[path = "key_backup_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs new file mode 100644 index 0000000000..e5892ad99e --- /dev/null +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -0,0 +1,155 @@ +use super::*; + +/// NIP-49 spec vector (same as rust-nostr's upstream test): decrypts with +/// password "nostr" at our call sites. +const SPEC_NCRYPTSEC: &str = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; +const SPEC_SECRET_HEX: &str = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683"; + +/// Fast scrypt tier for tests. log_n 18 is exercised once in +/// `round_trip_at_production_cost`. +const FAST_LOG_N: u8 = 16; + +// ── Codec ───────────────────────────────────────────────────────────────────── + +#[test] +fn spec_vector_decrypts_at_our_call_site() { + let keys = decrypt_ncryptsec(SPEC_NCRYPTSEC, "nostr").unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); +} + +#[test] +fn round_trip_fast_tier() { + let keys = Keys::generate(); + let blob = create_backup_blob(&keys, "correct horse battery", FAST_LOG_N).unwrap(); + assert!(blob.starts_with("ncryptsec1")); + let recovered = decrypt_ncryptsec(&blob, "correct horse battery").unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn round_trip_at_production_cost() { + // One log_n 18 round trip: proves the production constant works end to + // end (slow — several seconds — but deliberate; see plan D5). + let keys = Keys::generate(); + let blob = create_backup_blob(&keys, "production cost tier check", BACKUP_LOG_N).unwrap(); + let recovered = decrypt_ncryptsec(&blob, "production cost tier check").unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn wrong_password_is_a_friendly_error() { + let keys = Keys::generate(); + let blob = create_backup_blob(&keys, "right password", FAST_LOG_N).unwrap(); + let err = decrypt_ncryptsec(&blob, "wrong password").unwrap_err(); + assert_eq!(err, "wrong backup password or damaged key backup"); +} + +#[test] +fn nfkc_cross_form_passphrase_round_trips() { + // "é" composed (U+00E9) vs decomposed (e + U+0301): NIP-49 mandates NFKC + // normalization, so a passphrase entered in either form must decrypt. + let keys = Keys::generate(); + let composed = "caf\u{00e9} passphrase"; + let decomposed = "cafe\u{0301} passphrase"; + assert_ne!(composed, decomposed); + let blob = create_backup_blob(&keys, composed, FAST_LOG_N).unwrap(); + let recovered = decrypt_ncryptsec(&blob, decomposed).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); +} + +#[test] +fn parse_rejects_garbage_and_wrong_hrp() { + assert!(parse_ncryptsec("garbage").is_err()); + assert!(parse_ncryptsec("").is_err()); + // Valid bech32, wrong HRP (an nsec is not an encrypted backup). + let nsec = Keys::generate().secret_key().to_bech32().unwrap(); + assert!(parse_ncryptsec(&nsec).is_err()); + // Truncated blob. + assert!(parse_ncryptsec(&SPEC_NCRYPTSEC[..SPEC_NCRYPTSEC.len() - 10]).is_err()); +} + +#[test] +fn verify_backup_blob_catches_pubkey_mismatch() { + // Corrupted-blob simulation: the blob decrypts fine but recovers a key + // that is not the live identity — verification must fail. + let other = Keys::generate(); + let blob = create_backup_blob(&other, "some password", FAST_LOG_N).unwrap(); + let live = Keys::generate(); + let err = verify_backup_blob(&blob, "some password", &live.public_key()).unwrap_err(); + assert!(err.contains("does not match identity"), "{err}"); +} + +// ── File lifecycle ──────────────────────────────────────────────────────────── + +#[test] +fn write_backup_file_persists_0600_and_verifies() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(BACKUP_FILE_NAME); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + + let on_disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!(on_disk, SPEC_NCRYPTSEC); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "backup file must be owner-only"); + } +} + +#[test] +fn write_backup_file_overwrites_atomically() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(BACKUP_FILE_NAME); + write_backup_file(&path, "ncryptsec1old").unwrap(); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); + // No leftover temp files from the atomic write. + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); +} + +#[test] +fn generated_passphrase_respects_word_count_and_separator() { + let words: std::collections::HashSet<&str> = + WORDLIST.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(words.len(), 1296, "EFF short wordlist 2.0 has 1296 words"); + + for (count, separator) in [(3, "-"), (4, "-"), (6, " "), (5, "."), (10, "")] { + let phrase = generate_passphrase(count, separator).unwrap(); + if separator.is_empty() { + // No separator to split on; length gate below still applies. + } else { + let parts: Vec<&str> = phrase.split(separator).collect(); + assert_eq!(parts.len(), count); + for w in &parts { + assert!(words.contains(w), "unknown word {w:?}"); + } + } + assert!(phrase.chars().count() >= MIN_PASSPHRASE_LEN); + } +} + +#[test] +fn generated_passphrase_clamps_word_count() { + // Below the floor: clamped up to MIN_PASSPHRASE_WORDS, never shorter. + let phrase = generate_passphrase(1, "-").unwrap(); + assert_eq!(phrase.split('-').count(), MIN_PASSPHRASE_WORDS); + // Above the ceiling: clamped down to MAX_PASSPHRASE_WORDS. + let phrase = generate_passphrase(50, "-").unwrap(); + assert_eq!(phrase.split('-').count(), MAX_PASSPHRASE_WORDS); +} + +#[test] +fn generated_passphrases_are_not_repeated() { + // 3 words × ~10.3 bits each — a collision across 8 draws would indicate a + // broken entropy source, not bad luck. + let mut seen = std::collections::HashSet::new(); + for _ in 0..8 { + assert!(seen.insert(generate_passphrase(DEFAULT_PASSPHRASE_WORDS, "-").unwrap())); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c005d511e6..7dcc5994ae 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4,9 +4,11 @@ mod archive; mod builderlab; mod commands; mod deep_link; +mod egress_guard; mod event_sync; mod events; mod huddle; +mod key_backup; mod linux_media; mod managed_agents; mod media_proxy; @@ -669,6 +671,10 @@ pub fn run() { title_bar_double_click, get_identity, get_nsec, + generate_backup_passphrase, + create_ncryptsec_backup, + verify_ncryptsec_backup, + save_ncryptsec_copy, import_identity, persist_current_identity, get_profile, diff --git a/desktop/src-tauri/src/native_websocket.rs b/desktop/src-tauri/src/native_websocket.rs index c0cf2e76f1..128f2df79d 100644 --- a/desktop/src-tauri/src/native_websocket.rs +++ b/desktop/src-tauri/src/native_websocket.rs @@ -24,7 +24,7 @@ type Id = u32; #[derive(Debug, Deserialize)] #[serde(tag = "type", content = "data")] -enum WebSocketMessage { +pub(crate) enum WebSocketMessage { Text(String), Binary(Vec), Ping(Vec), @@ -33,7 +33,7 @@ enum WebSocketMessage { } #[derive(Debug, Deserialize)] -struct CloseFramePayload { +pub(crate) struct CloseFramePayload { code: u16, reason: String, } @@ -82,7 +82,7 @@ struct ConnectionHandle { } #[derive(Clone)] -struct WebSocketManager { +pub(crate) struct WebSocketManager { connections: Arc>>>, connect_cancel: Arc>, } @@ -182,11 +182,23 @@ async fn connect( open_connection(manager.inner(), &url, on_message).await } -async fn send_message( +pub(crate) async fn send_message( manager: &WebSocketManager, id: Id, message: WebSocketMessage, ) -> Result<(), String> { + // Egress guard: the NIP-49 local key backup must never reach a relay. + // This is the single choke point for all webview-originated websocket + // frames (see `crate::egress_guard`). + match &message { + WebSocketMessage::Text(text) => { + crate::egress_guard::assert_no_key_backup(text, "websocket text frame")? + } + WebSocketMessage::Binary(bytes) => { + crate::egress_guard::assert_no_key_backup_bytes(bytes, "websocket binary frame")? + } + _ => {} + } let handle = manager .connections .lock() diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index f896695624..71aa21c413 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -450,6 +450,7 @@ pub async fn sync_managed_agent_profile( let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; let event_json = event.as_json(); let body_bytes = event_json.into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "agent profile sync")?; let url = format!("{}/events", relay_http_base_url(relay_url)); let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; @@ -566,6 +567,7 @@ pub async fn submit_signed_event_with_keys( crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", relay_api_base_url_with_override(state)); let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "signed event submit (keys)")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; let mut request = state diff --git a/desktop/src-tauri/src/relay/submit.rs b/desktop/src-tauri/src/relay/submit.rs index 2a42d86c2b..eaad29d3b1 100644 --- a/desktop/src-tauri/src/relay/submit.rs +++ b/desktop/src-tauri/src/relay/submit.rs @@ -25,6 +25,7 @@ pub async fn submit_signed_event_at_with_keys( crate::relay_admission::wait_for_rate_limit().await; let url = format!("{}/events", api_base_url.trim_end_matches('/')); let body_bytes = event.as_json().into_bytes(); + crate::egress_guard::assert_no_key_backup_bytes(&body_bytes, "relay event submit")?; let auth_header = build_nip98_auth_header_for_keys(keys, &Method::POST, &url, &body_bytes)?; let response = state diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 90b5bf5ebc..44618f2c72 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -56,6 +56,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync"; +import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; @@ -270,9 +271,18 @@ function AppReady({ } return ( - - - + + void router.navigate({ + to: "/settings", + search: { section: "profile" }, + }) + } + > + + + + ); } diff --git a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx index 111bdefd71..26f538da52 100644 --- a/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx +++ b/desktop/src/features/onboarding/ui/NsecMaskedDisplay.tsx @@ -1,7 +1,23 @@ -import { Check, Copy, Eye, EyeOff } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, MoreHorizontal } from "lucide-react"; import * as React from "react"; import { Button } from "@/shared/ui/button"; -import { writeTextToClipboard } from "@/shared/lib/clipboard"; +import { + copyTextToClipboard, + writeTextToClipboard, +} from "@/shared/lib/clipboard"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; + +type NsecAction = { + icon?: React.ReactNode; + label: string; + onSelect: () => void; + testId?: string; +}; type NsecMaskedDisplayProps = { nsec: string; @@ -12,6 +28,8 @@ type NsecMaskedDisplayProps = { * a backup (e.g. sign-out) gate on actual interaction with the key. */ onKeyInteraction?: () => void; + /** Replaces the copy icon with an overflow menu containing Copy plus these actions. */ + actions?: readonly NsecAction[]; }; export const ONBOARDING_KEY_FRAME_CLASS = @@ -31,6 +49,7 @@ export function NsecMaskedDisplay({ nsec, variant = "boxed", onKeyInteraction, + actions, }: NsecMaskedDisplayProps) { const [isRevealed, setIsRevealed] = React.useState(false); const [isCopied, setIsCopied] = React.useState(false); @@ -58,6 +77,11 @@ export function NsecMaskedDisplay({ copyTimerRef.current = setTimeout(() => setIsCopied(false), 2000); } + function handleMenuCopy() { + copyTextToClipboard(nsec); + onKeyInteraction?.(); + } + const isBare = variant === "bare"; // Mask every character (no plaintext prefix leak), matching the real key's // length so toggling reveal never reflows the monospace text (no layout shift). @@ -121,24 +145,60 @@ export function NsecMaskedDisplay({
diff --git a/desktop/src/features/settings/EncryptedBackupProvider.tsx b/desktop/src/features/settings/EncryptedBackupProvider.tsx new file mode 100644 index 0000000000..7375a85cfb --- /dev/null +++ b/desktop/src/features/settings/EncryptedBackupProvider.tsx @@ -0,0 +1,251 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + createNcryptsecBackup, + saveNcryptsecCopy, +} from "@/shared/api/tauriIdentity"; +import { + type EncryptedBackupEvent, + type EncryptedBackupState, + encryptedBackupReducer, + initialEncryptedBackupState, + pendingEncryptPassphrase, +} from "./lib/encryptedBackup"; + +const ENCRYPT_DEBOUNCE_MS = 400; +/** How long a completed encrypted backup remains available in memory. */ +export const BACKUP_AVAILABILITY_MS = 5 * 60 * 1000; +const BACKUP_READY_TOAST_ID = "encrypted-key-backup-ready"; + +type EncryptedBackupContextValue = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + backupAvailable: boolean; + availableUntil: number | null; + isSaving: boolean; + saveError: string | null; + downloadBackup: () => Promise; + startNewBackup: () => void; +}; + +const EncryptedBackupContext = + React.createContext(null); + +export function EncryptedBackupProvider({ + children, + onOpenSettings, +}: { + children: React.ReactNode; + onOpenSettings: () => void; +}) { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [availableUntil, setAvailableUntil] = React.useState( + null, + ); + const [isSaving, setIsSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const autoSaveStartedForRef = React.useRef(null); + const mountedRef = React.useRef(true); + const onOpenSettingsRef = React.useRef(onOpenSettings); + + React.useEffect(() => { + onOpenSettingsRef.current = onOpenSettings; + }, [onOpenSettings]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + React.useEffect(() => { + if (availableUntil === null) return; + const expiresIn = Math.max(0, availableUntil - Date.now()); + const timer = window.setTimeout(() => { + autoSaveStartedForRef.current = null; + setAvailableUntil(null); + setSaveError(null); + dispatch({ type: "start-new-backup" }); + toast.dismiss(BACKUP_READY_TOAST_ID); + }, expiresIn); + return () => window.clearTimeout(timer); + }, [availableUntil]); + + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let started = false; + let cancelledBeforeStart = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelledBeforeStart) return; + started = true; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => { + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }); + }) + .catch((err: unknown) => { + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }); + }); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + if (!started) cancelledBeforeStart = true; + window.clearTimeout(timer); + }; + }, [pendingPassphrase, skipDebounce, state.nextRequestId]); + + React.useEffect(() => { + if (state.downloadPending) { + toast.loading("Preparing backup…", { + description: "You can close this window while Buzz finishes.", + duration: Number.POSITIVE_INFINITY, + id: BACKUP_READY_TOAST_ID, + }); + return; + } + if ( + state.createError && + state.passphrase.length === 0 && + !state.ncryptsec + ) { + toast.error("Couldn’t create backup", { + description: state.createError, + id: BACKUP_READY_TOAST_ID, + }); + } + }, [ + state.createError, + state.downloadPending, + state.ncryptsec, + state.passphrase.length, + ]); + + const showAvailableToast = React.useCallback( + (description: string, error = false) => { + const options = { + action: { + label: "Open settings", + onClick: () => onOpenSettingsRef.current(), + }, + description, + id: BACKUP_READY_TOAST_ID, + }; + if (error) toast.error("Backup ready to download", options); + else toast.success("Backup ready to download", options); + }, + [], + ); + + const saveBackup = React.useCallback( + async (ncryptsec: string) => { + if (isSaving) return; + setIsSaving(true); + setSaveError(null); + toast("Saving backup…", { + description: "The download window will open when it’s ready.", + id: BACKUP_READY_TOAST_ID, + }); + try { + const path = await saveNcryptsecCopy(ncryptsec); + if (mountedRef.current) { + showAvailableToast( + path === null + ? "Your backup will be available to download for 5 minutes." + : "You can download another copy for 5 minutes.", + ); + } + } catch (err) { + if (!mountedRef.current) return; + const message = + err instanceof Error ? err.message : "Failed to save your key."; + setSaveError(message); + showAvailableToast( + `${message} It will be available to download for 5 minutes.`, + true, + ); + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, + [isSaving, showAvailableToast], + ); + + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || autoSaveStartedForRef.current === ncryptsec) return; + autoSaveStartedForRef.current = ncryptsec; + setAvailableUntil(Date.now() + BACKUP_AVAILABILITY_MS); + void saveBackup(ncryptsec); + }, [saveBackup, state.ncryptsec]); + + const downloadBackup = React.useCallback(async () => { + if (!state.ncryptsec) return; + await saveBackup(state.ncryptsec); + }, [saveBackup, state.ncryptsec]); + + const startNewBackup = React.useCallback(() => { + autoSaveStartedForRef.current = null; + setAvailableUntil(null); + setSaveError(null); + toast.dismiss(BACKUP_READY_TOAST_ID); + dispatch({ type: "start-new-backup" }); + }, []); + + const value = React.useMemo( + () => ({ + state, + dispatch, + backupAvailable: + state.savedPassword && + state.ncryptsec !== null && + availableUntil !== null, + availableUntil, + isSaving, + saveError, + downloadBackup, + startNewBackup, + }), + [ + availableUntil, + downloadBackup, + isSaving, + saveError, + startNewBackup, + state, + ], + ); + + return ( + + {children} + + ); +} + +export function useEncryptedBackup(): EncryptedBackupContextValue { + const value = React.useContext(EncryptedBackupContext); + if (!value) { + throw new Error( + "useEncryptedBackup must be used within EncryptedBackupProvider", + ); + } + return value; +} diff --git a/desktop/src/features/settings/lib/encryptedBackup.test.mjs b/desktop/src/features/settings/lib/encryptedBackup.test.mjs new file mode 100644 index 0000000000..306e937ec5 --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; +const reduce = (events, from = initialEncryptedBackupState) => + events.reduce(encryptedBackupReducer, from); +test("password validation mirrors Rust character counting", () => { + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + assert.equal( + effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])), + emoji, + ); +}); +test("valid password requests encryption without copying it into events", () => { + const ready = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + ]); + assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four"); + const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready); + assert.equal(isEncrypting(started), true); + assert.equal(started.requestId, 1); + assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); +}); +test("background success retains the password without committing the download", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.passphrase, "one-two-three-four"); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.ncryptsec, null); + assert.equal(state.savedPassword, false); + assert.equal(state.requestId, null); + assert.equal(downloadDisabled(state), false); +}); +test("submit commits a completed preload immediately", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("stale async completions cannot replace current request", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + { type: "encrypt-started", requestId: 2 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" }, + ]); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); +}); +test("failure clears submitted password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.createError, "keychain unavailable"); + assert.equal(state.downloadPending, false); + assert.equal(downloadDisabled(state), true); +}); +test("background failure stays silent until submit retries encryption", () => { + const failed = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(failed.passphrase, "one-two-three-four"); + assert.equal(failed.createError, "keychain unavailable"); + assert.equal(pendingEncryptPassphrase(failed), null); + + const retrying = reduce([{ type: "download-clicked" }], failed); + assert.equal(retrying.createError, null); + assert.equal(retrying.downloadPending, true); + assert.equal(pendingEncryptPassphrase(retrying), "one-two-three-four"); +}); +test("queued download commits and clears password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("starting over discards blob and invalidates late requests", () => { + const made = { + ...initialEncryptedBackupState, + ncryptsec: "ncryptsec1abc", + encrypted: "ncryptsec1abc", + savedPassword: true, + nextRequestId: 3, + }; + const fresh = reduce([{ type: "start-new-backup" }], made); + assert.equal(fresh.ncryptsec, null); + assert.equal(fresh.nextRequestId, 4); + assert.equal( + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, + ); +}); diff --git a/desktop/src/features/settings/lib/encryptedBackup.ts b/desktop/src/features/settings/lib/encryptedBackup.ts new file mode 100644 index 0000000000..e54189769a --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.ts @@ -0,0 +1,143 @@ +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; + createError: string | null; + downloadPending: boolean; + ncryptsec: string | null; + savedPassword: boolean; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, + createError: null, + downloadPending: false, + ncryptsec: null, + savedPassword: false, +}; + +export type EncryptedBackupEvent = + | { type: "set-passphrase"; value: string } + | { type: "encrypt-started"; requestId: number } + | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } + | { type: "encrypt-failed"; requestId: number; message: string } + | { type: "download-clicked" } + | { type: "start-new-backup" }; + +export function encryptedBackupReducer( + state: EncryptedBackupState, + event: EncryptedBackupEvent, +): EncryptedBackupState { + switch (event.type) { + case "set-passphrase": + return { + ...state, + passphrase: event.value, + requestId: null, + encrypted: null, + createError: null, + }; + case "encrypt-started": + return { + ...state, + requestId: event.requestId, + nextRequestId: Math.max(state.nextRequestId, event.requestId + 1), + createError: null, + }; + case "encrypt-succeeded": + if (event.requestId !== state.requestId) return state; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: event.ncryptsec, + downloadPending: false, + savedPassword: true, + } + : { + ...state, + requestId: null, + encrypted: event.ncryptsec, + }; + case "encrypt-failed": + if (event.requestId !== state.requestId) return state; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + } + : { + ...state, + requestId: null, + createError: event.message, + }; + case "download-clicked": + if ( + state.ncryptsec || + state.downloadPending || + (!state.encrypted && !effectivePassphrase(state)) + ) + return state; + return state.encrypted + ? { + ...state, + ncryptsec: state.encrypted, + passphrase: "", + savedPassword: true, + } + : { ...state, createError: null, downloadPending: true }; + case "start-new-backup": + return { + ...initialEncryptedBackupState, + nextRequestId: state.nextRequestId + 1, + }; + } +} + +export function passphraseIssue(passphrase: string): string | null { + if (passphrase.length === 0) return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; +} +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + return [...state.passphrase].length < MIN_PASSPHRASE_LEN + ? null + : state.passphrase; +} +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if ( + state.savedPassword || + state.encrypted || + state.requestId !== null || + (state.createError !== null && !state.downloadPending) + ) + return null; + return effectivePassphrase(state); +} +export function isEncrypting(state: EncryptedBackupState): boolean { + return state.requestId !== null; +} +export function downloadDisabled(state: EncryptedBackupState): boolean { + if (state.savedPassword && state.ncryptsec) return false; + return ( + state.downloadPending || + (!state.encrypted && effectivePassphrase(state) === null) + ); +} diff --git a/desktop/src/features/settings/ui/BackupTestFlow.tsx b/desktop/src/features/settings/ui/BackupTestFlow.tsx new file mode 100644 index 0000000000..65ef40a98a --- /dev/null +++ b/desktop/src/features/settings/ui/BackupTestFlow.tsx @@ -0,0 +1,459 @@ +import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; + +type BackupTestStage = "drop" | "password" | "success"; + +/** + * Progress through the Settings backup-test flow. The password attempt is + * deliberately NOT part of this state — it lives only in short-lived + * moment it's submitted or the component unmounts. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** Contents of the accepted file, pending or past verification. */ + ncryptsec: string | null; + /** The Rust-verified public identity once decryption succeeded. */ + result: BackupVerification | null; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + ncryptsec: null, + result: null, +}; + +type BackupTestFlowProps = { + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; + +type BurstParticle = { + id: number; + x: number; + y: number; + emoji: string; + delay: number; + scale: number; + rotate: number; +}; + +/** + * One-shot radial emoji burst behind the success badge. Purely decorative — + * skipped entirely under reduced motion. + */ +function SuccessBurst() { + const particles = React.useMemo( + () => + Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => { + const angle = + (i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5; + const distance = 70 + Math.random() * 80; + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + emoji: BURST_EMOJIS[i % BURST_EMOJIS.length], + delay: Math.random() * 0.18, + scale: 0.8 + Math.random() * 0.7, + rotate: -120 + Math.random() * 240, + }; + }), + [], + ); + + return ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +/** + * "Test your backup" flow: the user drops a backup file onto a large + * dropzone, then enters its password. Verification is a real NIP-49 decrypt + * in Rust — the submitted password is cleared immediately after the result + * and only the derived public identity ever comes back. + */ +export function BackupTestFlow({ + progress, + onProgressChange, +}: BackupTestFlowProps) { + const reduceMotion = useReducedMotion() ?? false; + const { stage, fileName, ncryptsec, result } = progress; + // True while a file drag is anywhere over the window — the drop overlay + // takes over the host surface only for the duration of the drag. + const [isWindowDragging, setIsWindowDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); + + React.useEffect(() => { + // dragenter/dragleave fire per nested element, so track depth to know + // when the drag has actually left the window. + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsWindowDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsWindowDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsWindowDragging(false); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + // The password attempt is component-local, never host state: it is cleared + // when verification is submitted and when this component unmounts. + const [attempt, setAttempt] = React.useState(""); + const [error, setError] = React.useState(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Opaque correlation id so a stale in-flight verification can't commit + // after "Use a different file" or unmount. + const requestRef = React.useRef(0); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestRef.current += 1; + setAttempt(""); + }; + }, []); + + React.useEffect(() => { + if (stage === "password") passwordInputRef.current?.focus(); + }, [stage]); + + const handleFile = React.useCallback( + async (file: File) => { + let text: string; + try { + text = (await file.text()).trim(); + } catch { + if (mountedRef.current) setError("Could not read that file."); + return; + } + if (!mountedRef.current) return; + if (!text.toLowerCase().startsWith("ncryptsec1")) { + setError("That doesn't look like a key backup file."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [onProgressChange], + ); + + const handleVerify = React.useCallback(async () => { + if (!ncryptsec || !attempt || isVerifying) return; + const password = attempt; + const requestId = ++requestRef.current; + setIsVerifying(true); + setError(null); + setIsRevealed(false); + // Clear the attempt the moment it's handed to Rust — success or failure, + // the typed password never lingers in the field. + setAttempt(""); + try { + const verified = await verifyNcryptsecBackup(ncryptsec, password); + if (!mountedRef.current || requestId !== requestRef.current) return; + onProgressChange((prev) => ({ + ...prev, + stage: "success", + result: verified, + })); + } catch (err) { + if (mountedRef.current && requestId === requestRef.current) + setError( + err instanceof Error ? err.message : "Could not verify this backup.", + ); + } finally { + if (mountedRef.current && requestId === requestRef.current) + setIsVerifying(false); + } + }, [attempt, isVerifying, ncryptsec, onProgressChange]); + + if (stage === "success" && result) { + return ( +
+ {reduceMotion ? null : } + + + +

+ This backup works +

+

+ {result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+
+ +
+
+ +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + <> + { + const file = event.target.files?.[0]; + // Allow re-selecting the same file after an error. + event.target.value = ""; + if (file) void handleFile(file); + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> + + {isWindowDragging ? ( + /* + * Composer-style takeover: fills the nearest positioned host + * surface (the settings backup row) and is + * itself the drop target, so anywhere on that surface accepts + * the file. + */ + // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + ) : ( + <> +
+
+

+ That's the one. Now enter your password to prove you can unlock it. +

+
+ setAttempt(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleVerify(); + } + }} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + +
+ + )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx new file mode 100644 index 0000000000..fb20eb9c65 --- /dev/null +++ b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx @@ -0,0 +1,375 @@ +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; +import * as React from "react"; + +import { generateBackupPassphrase } from "@/shared/api/tauriIdentity"; +import { useEncryptedBackup } from "@/features/settings/EncryptedBackupProvider"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { downloadDisabled, MIN_PASSPHRASE_LEN } from "../lib/encryptedBackup"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Indeterminate KDF progress. Scrypt does not expose intermediate progress, + * so randomized increments consume a shrinking fraction of the remaining + * distance. The bar moves quickly at first and can never reach completion. + */ +function FakeKdfProgressBar() { + const [progress, setProgress] = React.useState(0); + + React.useEffect(() => { + let animationFrame = 0; + let nextAdvanceAt = 0; + const advance = (now: number) => { + if (now >= nextAdvanceAt) { + setProgress((current) => { + const remaining = 90 - current; + const fraction = 0.08 + Math.random() * 0.22; + return Math.min(90, current + Math.max(0.25, remaining * fraction)); + }); + nextAdvanceAt = now + 180 + Math.random() * 420; + } + animationFrame = window.requestAnimationFrame(advance); + }; + animationFrame = window.requestAnimationFrame(advance); + return () => window.cancelAnimationFrame(animationFrame); + }, []); + + return ( +
+
+
+ ); +} + +/** + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow for Settings. The raw private + * key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly; clicking mid-encryption queues the + * download until the KDF finishes. + */ +export function EncryptedBackupCreator({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const { state, dispatch, isSaving, saveError } = useEncryptedBackup(); + const [isRevealed, setIsRevealed] = React.useState(false); + + // A queued download hides the form; mask the password before it can return + // in any error state. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + React.useEffect(() => { + if (state.ncryptsec) onOpenChange(false); + }, [onOpenChange, state.ncryptsec]); + + return ( + + + + Create a key backup + + You can close this window while Buzz finishes the backup in the + background. + + +
+ {state.downloadPending ? ( + + ) : !state.savedPassword ? ( +
+ + dispatch({ + type: "set-passphrase", + value: event.target.value, + }) + } + placeholder={`Password (min ${MIN_PASSPHRASE_LEN} characters)`} + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + + { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + /> +
+ ) : null} + + {!state.downloadPending && !state.savedPassword ? ( +

+ Keep the file private and save its password somewhere safe — Buzz + cannot reset it. Once ready, the backup remains available to + download for 5 minutes. +

+ ) : null} + + {state.createError && state.passphrase.length === 0 ? ( +

+ {state.createError} +

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

+ {saveError} +

+ ) : null} + + {!state.downloadPending ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx new file mode 100644 index 0000000000..8eb0256a55 --- /dev/null +++ b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx @@ -0,0 +1,215 @@ +import { Download, Eye, EyeOff, ShieldCheck } from "lucide-react"; +import * as React from "react"; + +import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay"; +import { + BACKUP_AVAILABILITY_MS, + useEncryptedBackup, +} from "@/features/settings/EncryptedBackupProvider"; +import { + BackupTestFlow, + initialBackupTestProgress, +} from "@/features/settings/ui/BackupTestFlow"; +import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator"; +import { getNsec } from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +function BackupAvailabilityFill({ + availableUntil, +}: { + availableUntil: number; +}) { + const [{ durationMs, initialWidth }] = React.useState(() => { + const remainingMs = Math.max(0, availableUntil - Date.now()); + return { + durationMs: remainingMs, + initialWidth: Math.min(100, (remainingMs / BACKUP_AVAILABILITY_MS) * 100), + }; + }); + const [width, setWidth] = React.useState(initialWidth); + + React.useEffect(() => { + const frame = window.requestAnimationFrame(() => setWidth(0)); + return () => window.cancelAnimationFrame(frame); + }, []); + + return ( +
diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index e6056a4a58..e6ec266bff 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -49,3 +49,52 @@ export async function persistCurrentIdentity(): Promise { export async function signOut(): Promise { await invokeTauri("sign_out"); } + +export type GeneratePassphraseOptions = { + /** Word count; Rust clamps to its allowed range (currently 3–10). */ + words?: number; + /** Separator joined between words. Defaults to a space in Rust. */ + separator?: string; +}; + +/** Generate a word passphrase (EFF short wordlist, OS entropy) in Rust. */ +export async function generateBackupPassphrase( + options?: GeneratePassphraseOptions, +): Promise { + return invokeTauri("generate_backup_passphrase", { + words: options?.words, + separator: options?.separator, + }); +} + +/** Encrypt the current identity as an in-memory NIP-49 backup for native save. */ +export async function createNcryptsecBackup(password: string): Promise { + return invokeTauri("create_ncryptsec_backup", { password }); +} + +/** Save a portable backup copy. Returns null when the native dialog is cancelled. */ +export async function saveNcryptsecCopy( + ncryptsec: string, +): Promise { + return ( + (await invokeTauri("save_ncryptsec_copy", { ncryptsec })) ?? + null + ); +} + +export type BackupVerification = { + pubkey: string; + npub: string; + matchesCurrentIdentity: boolean; +}; + +/** Decrypt locally and return only the backup's public identity and match state. */ +export async function verifyNcryptsecBackup( + ncryptsec: string, + password: string, +): Promise { + return invokeTauri("verify_ncryptsec_backup", { + ncryptsec, + password, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index d15f2269d3..4b19004965 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; -import { decode } from "nostr-tools/nip19"; +import { decode, npubEncode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; import { @@ -417,6 +417,14 @@ type E2eConfig = { * autosave behaviour while a request is in flight. 0/undefined = instant. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message instead of returning the * mock nsec string. Use `nsecErrors` for sequenced failure/success. @@ -7204,6 +7212,8 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +let backupVerificationCallCount = 0; +let backupSaveCallCount = 0; // Per-page explicit catalog publication outcomes. let personaSharePublicationCallCount = 0; @@ -9843,6 +9853,43 @@ export function maybeInstallE2eTauriMocks() { // harness there is nothing to wipe; resolving is enough — specs // assert invocation via __BUZZ_E2E_COMMANDS__ and the pending UI. return; + case "generate_backup_passphrase": + return "correct horse battery staple"; + case "create_ncryptsec_backup": { + const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return "ncryptsec1mockbackupmaterial"; + } + case "save_ncryptsec_copy": { + const paths = activeConfig?.mock?.backupSavePaths ?? [ + "/tmp/buzz-identity.ncryptsec", + ]; + const index = Math.min(backupSaveCallCount, paths.length - 1); + backupSaveCallCount += 1; + return paths[index]; + } + case "verify_ncryptsec_backup": { + const errors = activeConfig?.mock?.backupVerificationErrors ?? [null]; + const index = Math.min(backupVerificationCallCount, errors.length - 1); + const error = errors[index]; + if (error) { + backupVerificationCallCount += 1; + throw new Error(error); + } + const pubkeys = activeConfig?.mock?.backupVerificationPubkeys ?? [ + identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, + ]; + const pubkey = pubkeys[Math.min(index, pubkeys.length - 1)]; + backupVerificationCallCount += 1; + return { + pubkey, + npub: npubEncode(pubkey), + matchesCurrentIdentity: + pubkey === (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey), + }; + } case "get_nsec": { const nsecSequence = activeConfig?.mock?.nsecErrors; if (nsecSequence && nsecSequence.length > 0) { diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts new file mode 100644 index 0000000000..edd66170da --- /dev/null +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -0,0 +1,259 @@ +import { expect, test, type Page } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; + +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const CURRENT_PUBKEY = "deadbeef".repeat(8); +const DIFFERENT_PUBKEY = "c0ffee00".repeat(8); +const BACKUP_FILE = { + name: "identity.ncryptsec", + mimeType: "text/plain", + buffer: Buffer.from("ncryptsec1mockbackupmaterial"), +}; + +async function openIdentity(page: Page) { + const identity = page.getByTestId("profile-identity-card"); + if ( + !(await identity.evaluate( + (element) => element instanceof HTMLDetailsElement && element.open, + )) + ) { + await page.getByTestId("profile-identity-toggle").click(); + } +} + +async function openBackupSettings( + page: Page, + mock?: Parameters[1], +) { + await installMockBridge(page, mock); + await page.goto("/"); + await openSettings(page, "profile"); + await openIdentity(page); +} + +async function openPrivateKeyMenu(page: Page) { + const reveal = page.getByTestId("profile-private-key-toggle"); + if ((await reveal.textContent())?.trim() === "Reveal") { + await reveal.click(); + } + await page.getByTestId("nsec-actions").click(); + await expect(page.getByTestId("private-key-create-backup")).toBeVisible(); +} + +async function openCreateBackup(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-create-backup").click(); + const dialog = page.getByTestId("encrypted-backup-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function openTestBackup(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-test-backup").click(); + const dialog = page.getByTestId("backup-test-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function selectBackupFile(page: Page) { + await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE); + await expect(page.getByTestId("backup-test-file-accepted")).toContainText( + BACKUP_FILE.name, + ); +} + +async function verifyBackup(page: Page, password: string) { + await page.getByTestId("backup-test-password").fill(password); + await page.getByTestId("backup-test-verify").click(); +} + +async function backupSaveCallCount(page: Page) { + return page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "save_ncryptsec_copy", + ).length ?? 0, + ); +} + +test("private key menu replaces the backup settings rows", async ({ page }) => { + await openBackupSettings(page); + + await expect(page.getByTestId("profile-encrypted-backup-row")).toHaveCount(0); + await expect(page.getByTestId("profile-backup-test-row")).toHaveCount(0); + + await openPrivateKeyMenu(page); + await expect(page.getByTestId("nsec-copy")).toContainText("Copy"); + await expect(page.getByTestId("private-key-create-backup")).toHaveText( + "Create backup", + ); + await expect(page.getByTestId("private-key-test-backup")).toHaveText( + "Test backup", + ); + + await page.getByTestId("nsec-copy").click(); + await expect(page.getByText(/clipboard$/i)).toBeVisible(); + await expect(page.getByTestId("private-key-create-backup")).toHaveCount(0); + + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-test-backup").click(); + const testDialog = page.getByTestId("backup-test-dialog"); + await expect(testDialog).toContainText("Test a key backup"); + await expect(testDialog.getByText("Select your backup file")).toBeVisible(); + await expect(testDialog).toContainText("standard NIP-49 format"); +}); + +test("creation requires a sufficiently long password and exposes a temporary header download", async ({ + page, +}) => { + await openBackupSettings(page, { + backupSavePaths: [ + "/Users/test/Downloads/identity.ncryptsec", + "/Users/test/Desktop/identity-copy.ncryptsec", + ], + }); + const dialog = await openCreateBackup(page); + + const password = dialog.getByTestId("backup-passphrase-input"); + const submit = dialog.getByTestId("encrypted-backup-create"); + await expect(password).toHaveAttribute( + "placeholder", + "Password (min 12 characters)", + ); + await expect(submit).toBeDisabled(); + await password.fill("short"); + await expect(submit).toBeDisabled(); + + await password.fill("custom password"); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + await expect(dialog).toBeHidden(); + + const keyRow = page.getByTestId("profile-private-key-row"); + const download = keyRow.getByTestId("encrypted-backup-download"); + await expect(download).toBeVisible(); + await expect(download).toHaveText("Download backup"); + await expect(download).toHaveClass(/bg-primary/); + await expect( + download.getByTestId("encrypted-backup-availability-fill"), + ).toBeVisible(); + await expect(keyRow.getByTestId("profile-private-key-toggle")).toBeVisible(); + + await download.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(2); +}); + +test("encryption and native save continue after closing the dialog and settings", async ({ + page, +}) => { + await openBackupSettings(page, { + backupEncryptionDelayMs: 750, + backupSavePaths: [null], + }); + const dialog = await openCreateBackup(page); + await dialog + .getByTestId("backup-passphrase-input") + .fill("background password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await expect(dialog.getByTestId("encrypted-backup-progress")).toBeVisible(); + + await dialog.getByRole("button", { name: "Close" }).click(); + await page.getByTestId("settings-back-to-app").click(); + await expect(page.getByTestId("settings-back-to-app")).toHaveCount(0); + await expect( + page.getByText("Preparing backup…", { exact: true }), + ).toBeVisible(); + + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + const readyToast = page.getByText("Backup ready to download", { + exact: true, + }); + await expect(readyToast).toBeVisible(); + await expect( + page.getByText("Your backup will be available to download for 5 minutes.", { + exact: true, + }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Open settings" }).click(); + await expect(page.getByTestId("settings-back-to-app")).toBeVisible(); + await openIdentity(page); + await expect(page.getByTestId("encrypted-backup-download")).toBeVisible(); +}); + +test("the temporary download expires after five minutes", async ({ page }) => { + await page.clock.install({ time: new Date("2026-07-29T12:00:00Z") }); + await openBackupSettings(page); + const dialog = await openCreateBackup(page); + await dialog.getByTestId("backup-passphrase-input").fill("expiring password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await page.clock.fastForward(1); + + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + const download = page.getByTestId("encrypted-backup-download"); + await expect(download).toHaveText("Download backup"); + await expect( + download.getByTestId("encrypted-backup-availability-fill"), + ).toBeVisible(); + await page.clock.fastForward(5 * 60 * 1000 + 1); + await expect(page.getByTestId("encrypted-backup-download")).toHaveCount(0); +}); + +test("wrong backup password permits a successful retry in the test modal", async ({ + page, +}) => { + await openBackupSettings(page, { + backupVerificationErrors: ["Wrong password.", null], + }); + const dialog = await openTestBackup(page); + await selectBackupFile(page); + + await verifyBackup(page, "wrong password"); + await expect(dialog.getByTestId("backup-test-error")).toHaveText( + "Wrong password.", + ); + await expect(dialog.getByTestId("backup-test-password")).toHaveValue(""); + await expect(dialog.getByTestId("backup-test-verify")).toBeDisabled(); + + await verifyBackup(page, "correct password"); + await expect(dialog.getByTestId("backup-test-success")).toContainText( + "It restores your current Buzz identity.", + ); +}); + +for (const identity of [ + { + label: "current", + pubkey: CURRENT_PUBKEY, + message: "It restores your current Buzz identity.", + }, + { + label: "different", + pubkey: DIFFERENT_PUBKEY, + message: "It restores a different identity than the one signed in here.", + }, +]) { + test(`successful modal verification identifies the ${identity.label} identity using only its npub`, async ({ + page, + }) => { + await openBackupSettings(page, { + backupVerificationPubkeys: [identity.pubkey], + }); + const dialog = await openTestBackup(page); + await selectBackupFile(page); + await verifyBackup(page, "one-time password"); + + const success = dialog.getByTestId("backup-test-success"); + await expect(success).toContainText(identity.message); + await expect(success.getByTestId("backup-test-npub")).toContainText( + npubEncode(identity.pubkey), + ); + await expect(success).not.toContainText(identity.pubkey); + await expect(success).not.toContainText("one-time password"); + await expect(success).not.toContainText(BACKUP_FILE.buffer.toString()); + }); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index b48a75914b..468f860203 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -441,6 +441,14 @@ type MockBridgeOptions = { /** Delay (ms) for `set_global_agent_config` — hold saves open in tests. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message. For a single always-fail * scenario. Use `nsecErrors` for sequenced fail/succeed. From cca8839034eb571a7ce943c3ace7f85a82330898 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 12:25:08 -0600 Subject: [PATCH 68/99] Make relay reconnect backoff authoritative (#3774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - make the relay reconnect coordinator authoritative during outages so query, publish, and subscription traffic waits for the scheduled attempt instead of cancelling backoff - release waiting operations after the coordinated AUTH + live-subscription replay attempt, while preserving one explicit manual reconnect fast path - suppress duplicate notification side effects when reconnect replay overlaps previously delivered events ## Root cause `resetConnection()` scheduled exponential backoff, but `ensureConnected()` cleared any pending reconnect timer. Operation-level retry paths immediately called `ensureConnected()`, so ordinary app traffic could repeatedly bypass the reconnect policy during an outage. The resulting churn also replayed overlapping live events into notification side effects without a shared event-ID guard. ## Validation - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,823 passed - pre-push: `desktop-check`, `desktop-test`, and `branch-skew` passed - file-size, px-text, and pubkey-truncation ratchets passed --------- Signed-off-by: Wes Co-authored-by: Carl --- .../channels/unreadReadMarker.test.mjs | 11 ++ .../channels/useLiveChannelUpdates.ts | 98 ++++++----- desktop/src/shared/api/relayClientSession.ts | 94 +++++------ .../shared/api/relayReconnectPolicy.test.mjs | 12 ++ .../src/shared/api/relayReconnectPolicy.ts | 6 + .../shared/api/relayReconnectWaiters.test.mjs | 26 +++ .../src/shared/api/relayReconnectWaiters.ts | 21 +++ desktop/src/testing/e2eBridge.ts | 24 +++ desktop/tests/e2e/helpers/twoRelayHarness.ts | 1 + desktop/tests/e2e/relay-reconnect.spec.ts | 74 ++++++++ desktop/tests/e2e/relay-restart.live.spec.ts | 158 +++++++++++++++++- 11 files changed, 429 insertions(+), 96 deletions(-) create mode 100644 desktop/src/shared/api/relayReconnectWaiters.test.mjs create mode 100644 desktop/src/shared/api/relayReconnectWaiters.ts diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index e05e2ba0b2..6ea0916413 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -18,6 +18,7 @@ import { } from "./useUnreadChannels.ts"; import { isChannelUnreadTriggerKind, + trackSeenEvent, withChannelTagFallback, } from "./useLiveChannelUpdates.ts"; import { @@ -90,6 +91,16 @@ test("live event with h tag is preserved", () => { assert.equal(withChannelTagFallback(message, "other-channel"), message); }); +test("notification event guard suppresses reconnect replay and stays bounded", () => { + const seen = new Set(); + + assert.equal(trackSeenEvent(seen, "event-a", 2), true); + assert.equal(trackSeenEvent(seen, "event-a", 2), false); + assert.equal(trackSeenEvent(seen, "event-b", 2), true); + assert.equal(trackSeenEvent(seen, "event-c", 2), true); + assert.deepEqual([...seen], ["event-b", "event-c"]); +}); + test("dmHuddleStart_isDmOnlyUnreadTrigger", () => { assert.equal( isChannelUnreadTriggerKind(KIND_HUDDLE_STARTED, true), diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index aeb3abb905..800467b6ea 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -110,13 +110,19 @@ function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { ); } -function trackSeenEvent(seenEventIds: Set, eventId: string): boolean { +const SEEN_NOTIFICATION_EVENT_LIMIT = 5_000; + +export function trackSeenEvent( + seenEventIds: Set, + eventId: string, + limit = 200, +): boolean { if (seenEventIds.has(eventId)) { return false; } seenEventIds.add(eventId); - if (seenEventIds.size > 200) { + if (seenEventIds.size > limit) { const oldestEventId = seenEventIds.values().next().value; if (oldestEventId) { seenEventIds.delete(oldestEventId); @@ -135,6 +141,11 @@ export function useLiveChannelUpdates( const normalizedCurrentPubkey = options.currentPubkey?.trim().toLowerCase() ?? ""; const seenMentionEventIdsRef = React.useRef(new Set()); + // Reconnect replay overlaps each live filter by five seconds so no message is + // lost at the boundary. Keep one shared guard for every notification side + // effect: the same event can be replayed repeatedly while a relay flaps, and + // mention events also arrive through both the channel and mention filters. + const seenNotificationEventIdsRef = React.useRef(new Set()); const channelsInvalidateRef = React.useRef(null); if (channelsInvalidateRef.current === null) { channelsInvalidateRef.current = createTrailingDebounce(() => { @@ -164,7 +175,6 @@ export function useLiveChannelUpdates( ), [channels], ); - const seenDmEventIdsRef = React.useRef(new Set()); const dmSubscriptionStartedAtRef = React.useRef(0); // Reset subscription timestamp when identity changes. @@ -181,44 +191,42 @@ export function useLiveChannelUpdates( [channels], ); - const handleDmEvent = React.useEffectEvent((event: RelayEvent) => { - // Only human-visible message kinds should fire DM notifications. - if (!isDmNotifiableKind(event.kind)) { - return; - } - - // Suppress backlog events that predate our subscription — these are - // historical replays, not live messages. - if (event.created_at < dmSubscriptionStartedAtRef.current) { - return; - } + const handleDmEvent = React.useEffectEvent( + (event: RelayEvent, isFirstNotificationDelivery: boolean) => { + // Only human-visible message kinds should fire DM notifications. + if (!isDmNotifiableKind(event.kind) || !isFirstNotificationDelivery) { + return; + } - const channelId = getChannelIdFromTags(event.tags); - if (!channelId) { - return; - } + // Suppress backlog events that predate our subscription — these are + // historical replays, not live messages. + if (event.created_at < dmSubscriptionStartedAtRef.current) { + return; + } - if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { - return; - } + const channelId = getChannelIdFromTags(event.tags); + if (!channelId) { + return; + } - const dmChannel = dmChannelMap.get(channelId); - if (!dmChannel) { - return; - } + if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { + return; + } - if (!trackSeenEvent(seenDmEventIdsRef.current, event.id)) { - return; - } + const dmChannel = dmChannelMap.get(channelId); + if (!dmChannel) { + return; + } - // Don't fire a notification for the channel the user is already viewing, - // unless the notify-while-viewing setting opts in. - if (channelId === activeChannelId && !options.notifyForActiveChannel) { - return; - } + // Don't fire a notification for the channel the user is already viewing, + // unless the notify-while-viewing setting opts in. + if (channelId === activeChannelId && !options.notifyForActiveChannel) { + return; + } - options.onDmMessage?.(event, dmChannel); - }); + options.onDmMessage?.(event, dmChannel); + }, + ); const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { const channelId = getChannelIdFromTags(event.tags); @@ -226,12 +234,6 @@ export function useLiveChannelUpdates( return; } - // Track DM events even for the active channel so the dedup set stays - // current. The handler itself skips firing the notification callback - // when the user is already viewing the DM (unless opted in via - // notifyForActiveChannel). - handleDmEvent(event); - if (!liveChannelIds.has(channelId)) { if (channelId !== activeChannelId) { invalidateChannelsDebounced(); @@ -263,9 +265,21 @@ export function useLiveChannelUpdates( isUnreadTriggerKind && (normalizedCurrentPubkey.length === 0 || event.pubkey.toLowerCase() !== normalizedCurrentPubkey); + const isFirstNotificationDelivery = + !isExternalTriggerEvent || + trackSeenEvent( + seenNotificationEventIdsRef.current, + event.id, + SEEN_NOTIFICATION_EVENT_LIMIT, + ); const isThreadedReply = isThreadReply(event.tags); - if (isExternalTriggerEvent) { + // DM alerts and every other notification side effect share this delivery + // decision, preventing a replayed event from escaping through a second + // callback path. + handleDmEvent(event, isFirstNotificationDelivery); + + if (isExternalTriggerEvent && isFirstNotificationDelivery) { const shouldNotify = shouldNotifyForEvent( event, normalizedCurrentPubkey, diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 84ee10b68d..8274034ed5 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -49,7 +49,9 @@ import { isWebSocketClose, shouldRefuseConnect, shouldScheduleReconnect, + shouldWaitForScheduledReconnect, } from "@/shared/api/relayReconnectPolicy"; +import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters"; import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; @@ -57,26 +59,12 @@ const RECONNECT_BASE_DELAY_MS = 1_000, RECONNECT_MAX_DELAY_MS = 30_000, EVENT_BATCH_MS = 16; -/** - * Op-level timeout constants. Raised from 8 s to 25 s to survive degraded - * networks where TLS handshakes and DNS resolution can take 3–10 s. - */ export const AUTH_TIMEOUT_MS = 25_000; export const HISTORY_TIMEOUT_MS = 25_000; export const PUBLISH_TIMEOUT_MS = 25_000; -/** - * The connection must remain stable for this long after a successful AUTH - * before the reconnect backoff delay resets to its base value. Stability- - * gated reset prevents repeated fast reconnects (flapping) from erasing the - * backoff that throttles them. - */ export const BACKOFF_RESET_STABLE_MS = 60_000; -/** - * Passive liveness check. The relay sends heartbeat pings every 30s; if no - * inbound frame arrives for two heartbeat windows, treat the socket as stalled. - */ const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_IDLE_TIMEOUT_MS = 60_000; @@ -85,6 +73,7 @@ export class RelayClient { private relayUrl: string | null = null; private connectPromise: Promise | null = null; private reconnectTimeout: number | null = null; + private reconnectWaiters = new RelayReconnectWaiters(); private reconnectDelayMs = RECONNECT_BASE_DELAY_MS; private keepAliveRequested = false; private authRequest: { @@ -105,16 +94,6 @@ export class RelayClient { private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; - /** - * Sticky terminal flag. Set when `resetConnection` is called with - * `reconnect: false` (today: auth rejection). Acts as a hard guard against - * the reconnect-timer / retry-wrapper paths racing back to "reconnecting" - * after we've already declared the session dead. - * - * Cleared only on explicit user re-engagement: `disconnect()` (community - * switch — the singleton is being reused for a different community) and - * `preconnect()` (caller is asking us to come back up). - */ private terminal = false; private connectionStateEmitter = new RelayConnectionStateEmitter("idle"); @@ -127,21 +106,10 @@ export class RelayClient { }, }); - /** - * Track which channel the user is currently viewing so its subscriptions - * are sent first during reconnect replay — reducing visible latency on - * degraded networks where the relay REQ storm would otherwise delay all - * channels equally. - */ setVisibleChannelId(id: string | null) { this.visibleChannelId = id; } - /** - * Cleanly tear down the connection without scheduling a reconnect. - * Used during community switches to reset the singleton before the - * new community applies. - */ disconnect() { const error = new Error("Relay disconnected for community switch."); @@ -169,6 +137,7 @@ export class RelayClient { } this.connectPromise = null; + this.reconnectWaiters.settle(error); if (this.authRequest) { window.clearTimeout(this.authRequest.timeout); @@ -247,10 +216,6 @@ export class RelayClient { return this.fetchHistory(filter); } - /** - * Return the first event matching `filter` as soon as it arrives, without - * waiting for EOSE. Resolves to `null` when EOSE arrives before any event. - */ async fetchFirstEvent( filter: RelaySubscriptionFilter, ): Promise { @@ -462,10 +427,24 @@ export class RelayClient { async preconnect() { // Explicit re-engagement. If the session went terminal (auth rejection) - // the caller is asking us to try again, so clear the latch. + // the caller is asking us to try again, so clear the latch. A manual + // reconnect also bypasses the current delay once; ordinary operations do + // not, so background traffic cannot continuously defeat backoff. this.terminal = false; this.keepAliveRequested = true; - await this.ensureConnected(); + if (this.reconnectTimeout !== null) { + window.clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + try { + await this.ensureConnected(); + this.reconnectWaiters.settle(); + } catch (error) { + this.reconnectWaiters.settle( + this.normalizeRelayError(error, "Relay reconnect failed."), + ); + throw error; + } } subscribeToReconnects(listener: () => void) { @@ -508,9 +487,15 @@ export class RelayClient { return; } - if (this.reconnectTimeout) { - window.clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; + if ( + shouldWaitForScheduledReconnect({ + hasPendingReconnect: this.reconnectTimeout !== null, + }) + ) { + // The reconnect coordinator owns outage pacing. Query, publish, and + // subscription callers must wait for its scheduled attempt instead of + // clearing the timer and creating an immediate reconnect storm. + return this.waitForScheduledReconnect(); } const connectPromise = this.connect(); @@ -964,6 +949,13 @@ export class RelayClient { } } + private waitForScheduledReconnect(): Promise { + if (this.reconnectTimeout === null) { + return this.ensureConnected(); + } + return this.reconnectWaiters.wait(); + } + private scheduleReconnect() { if ( !shouldScheduleReconnect({ @@ -989,9 +981,14 @@ export class RelayClient { this.reconnectTimeout = window.setTimeout(() => { this.reconnectTimeout = null; - void this.ensureConnected().catch(() => { - this.scheduleReconnect(); - }); + void this.ensureConnected() + .then(() => this.reconnectWaiters.settle()) + .catch((error) => { + this.reconnectWaiters.settle( + this.normalizeRelayError(error, "Relay reconnect failed."), + ); + this.scheduleReconnect(); + }); }, delay); } @@ -1049,6 +1046,9 @@ export class RelayClient { window.clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } + if (options?.reconnect === false) { + this.reconnectWaiters.settle(error); + } if (this.wsId !== null) { void closeWebSocket(this.wsId, "connection reset"); diff --git a/desktop/src/shared/api/relayReconnectPolicy.test.mjs b/desktop/src/shared/api/relayReconnectPolicy.test.mjs index 6f375fb436..e6856ede18 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.test.mjs +++ b/desktop/src/shared/api/relayReconnectPolicy.test.mjs @@ -6,6 +6,7 @@ import { isWebSocketClose, shouldRefuseConnect, shouldScheduleReconnect, + shouldWaitForScheduledReconnect, } from "./relayReconnectPolicy.ts"; // The "happy" baseline that *should* schedule a reconnect: not terminal, @@ -79,6 +80,17 @@ test("keep-alive alone is enough to schedule", () => { ); }); +test("ordinary operations wait for a scheduled reconnect instead of bypassing backoff", () => { + assert.equal( + shouldWaitForScheduledReconnect({ hasPendingReconnect: true }), + true, + ); + assert.equal( + shouldWaitForScheduledReconnect({ hasPendingReconnect: false }), + false, + ); +}); + test("shouldRefuseConnect mirrors terminal", () => { assert.equal(shouldRefuseConnect({ terminal: false }), false); assert.equal(shouldRefuseConnect({ terminal: true }), true); diff --git a/desktop/src/shared/api/relayReconnectPolicy.ts b/desktop/src/shared/api/relayReconnectPolicy.ts index a2cf358216..00d8e412bd 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.ts +++ b/desktop/src/shared/api/relayReconnectPolicy.ts @@ -37,6 +37,12 @@ export function shouldScheduleReconnect(inputs: RelayReconnectInputs): boolean { return true; } +export function shouldWaitForScheduledReconnect(inputs: { + hasPendingReconnect: boolean; +}): boolean { + return inputs.hasPendingReconnect; +} + /** Whether `ensureConnected()` should refuse with a terminal error. */ export function shouldRefuseConnect(inputs: { terminal: boolean }): boolean { return inputs.terminal; diff --git a/desktop/src/shared/api/relayReconnectWaiters.test.mjs b/desktop/src/shared/api/relayReconnectWaiters.test.mjs new file mode 100644 index 0000000000..ddf3244526 --- /dev/null +++ b/desktop/src/shared/api/relayReconnectWaiters.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { RelayReconnectWaiters } from "./relayReconnectWaiters.ts"; + +test("settle releases every operation waiting on a successful reconnect", async () => { + const waiters = new RelayReconnectWaiters(); + const first = waiters.wait(); + const second = waiters.wait(); + + waiters.settle(); + + await Promise.all([first, second]); +}); + +test("settle rejects every operation after a failed reconnect", async () => { + const waiters = new RelayReconnectWaiters(); + const first = waiters.wait(); + const second = waiters.wait(); + const error = new Error("relay unavailable"); + + waiters.settle(error); + + await assert.rejects(first, error); + await assert.rejects(second, error); +}); diff --git a/desktop/src/shared/api/relayReconnectWaiters.ts b/desktop/src/shared/api/relayReconnectWaiters.ts new file mode 100644 index 0000000000..06ad1daa3f --- /dev/null +++ b/desktop/src/shared/api/relayReconnectWaiters.ts @@ -0,0 +1,21 @@ +export class RelayReconnectWaiters { + private waiters = new Set<{ + resolve: () => void; + reject: (error: Error) => void; + }>(); + + wait(): Promise { + return new Promise((resolve, reject) => { + this.waiters.add({ resolve, reject }); + }); + } + + settle(error?: Error) { + const waiters = [...this.waiters]; + this.waiters.clear(); + for (const waiter of waiters) { + if (error) waiter.reject(error); + else waiter.resolve(); + } + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4b19004965..07eaa77902 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1108,6 +1108,11 @@ declare global { __BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void; __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; __BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number; + __BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__?: ( + unavailable: boolean, + ) => void; + __BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[]; + __BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void; __BUZZ_E2E_SET_MESH__?: (mesh: { admitted?: boolean; models?: Array<{ id: string; name: string | null }>; @@ -2820,6 +2825,8 @@ const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); +let mockWebsocketUnavailable = false; +const relayWebsocketConnectAttemptStarts: number[] = []; let mockWebsocketSendMutexWedged = false; let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); @@ -8932,6 +8939,7 @@ async function resolveGetEvent( } async function connectRealSocket(args: { url?: string; onMessage: unknown }) { + relayWebsocketConnectAttemptStarts.push(Date.now()); const wsId = nextSocketId++; const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL); const handler = resolveHandler(args.onMessage); @@ -8960,6 +8968,10 @@ async function connectRealSocket(args: { url?: string; onMessage: unknown }) { } async function connectMockSocket(args: { onMessage: unknown }) { + relayWebsocketConnectAttemptStarts.push(Date.now()); + if (mockWebsocketUnavailable) { + throw new Error("mock relay unavailable"); + } const connectError = getConfig()?.mock?.websocketConnectErrors?.shift(); if (connectError) { throw new Error(connectError); @@ -9405,6 +9417,8 @@ export function maybeInstallE2eTauriMocks() { } mockClosedChannelLiveSubscription = false; + mockWebsocketUnavailable = false; + relayWebsocketConnectAttemptStarts.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } : null; @@ -9628,6 +9642,16 @@ export function maybeInstallE2eTauriMocks() { } return sockets.length; }; + window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__ = (unavailable) => { + mockWebsocketUnavailable = unavailable; + if (unavailable) relayWebsocketConnectAttemptStarts.length = 0; + }; + window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [ + ...relayWebsocketConnectAttemptStarts, + ]; + window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => { + relayWebsocketConnectAttemptStarts.length = 0; + }; // Tests vary mesh admission and models to exercise provider discovery and // the managed-agent start preflight. window.__BUZZ_E2E_SET_MESH__ = (mesh) => { diff --git a/desktop/tests/e2e/helpers/twoRelayHarness.ts b/desktop/tests/e2e/helpers/twoRelayHarness.ts index 98acd86398..43eb458417 100644 --- a/desktop/tests/e2e/helpers/twoRelayHarness.ts +++ b/desktop/tests/e2e/helpers/twoRelayHarness.ts @@ -162,6 +162,7 @@ export class TwoRelayHarness { BUZZ_METRICS_PORT: String(relay.ports.metrics), BUZZ_REQUIRE_AUTH_TOKEN: "false", BUZZ_RECONCILE_CHANNELS: "true", + BUZZ_AUTO_MIGRATE: "true", }); await this.waitForHealth(relay, child); } diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 6240cca0ba..67ce725da8 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -49,6 +49,31 @@ async function restartMockWebsockets(page: import("@playwright/test").Page) { expect(restarted).toBeGreaterThan(0); } +async function setMockWebsocketUnavailable( + page: import("@playwright/test").Page, + unavailable: boolean, +) { + await page.evaluate((value) => { + const setUnavailable = window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__; + if (!setUnavailable) { + throw new Error("E2E websocket availability seam is not installed."); + } + setUnavailable(value); + }, unavailable); +} + +async function getMockWebsocketConnectAttempts( + page: import("@playwright/test").Page, +) { + return page.evaluate(() => { + const getAttempts = window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__; + if (!getAttempts) { + throw new Error("E2E websocket attempt seam is not installed."); + } + return getAttempts(); + }); +} + async function emitMockMessages( page: import("@playwright/test").Page, messages: Array<{ content: string; createdAt: number }>, @@ -121,6 +146,55 @@ test("failed initial relay dial retries automatically", async ({ page }) => { await expect(page.getByTestId("channel-general")).toBeVisible(); }); +test("routine traffic cannot bypass outage backoff and recovery stays automatic", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await setMockWebsocketUnavailable(page, true); + await disconnectMockWebsockets(page); + + // Exercise the production query path throughout the outage. Before the + // coordinator fix, each rejected query called ensureConnected(), cancelled + // the scheduled timer, and dialed immediately. The fixed session keeps these + // callers behind its single jittered exponential-backoff attempt. + await page.evaluate(async () => { + const deadline = Date.now() + 4_200; + while (Date.now() < deadline) { + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + }); + + const attempts = await getMockWebsocketConnectAttempts(page); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts.length).toBeLessThanOrEqual(3); + for (let index = 1; index < attempts.length; index += 1) { + expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700); + } + + await setMockWebsocketUnavailable(page, false); + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 10_000 }, + ) + .toBe("connected"); + + const afterRecovery = `automatic outage recovery ${Date.now()}`; + await emitMockMessages(page, [ + { content: afterRecovery, createdAt: Math.floor(Date.now() / 1_000) }, + ]); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("message-timeline")).toContainText( + afterRecovery, + ); +}); + test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"], diff --git a/desktop/tests/e2e/relay-restart.live.spec.ts b/desktop/tests/e2e/relay-restart.live.spec.ts index 057ef46f52..f3c80e69a1 100644 --- a/desktop/tests/e2e/relay-restart.live.spec.ts +++ b/desktop/tests/e2e/relay-restart.live.spec.ts @@ -1,8 +1,13 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + import { expect, test, type Page } from "@playwright/test"; -import { installBridge } from "../helpers/bridge"; +import { installBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness"; +const exec = promisify(execFile); + // Live gate: boots a REAL buzz-relay process, points the app at it, SIGTERMs // the relay mid-session, restarts it on the same port, and asserts the client // converges back to "connected". This proves the full restart story end to @@ -20,6 +25,55 @@ function required(name: string, value: string | undefined): string { return value; } +async function runCli(args: string[], relayUrl: string, privateKey: string) { + const binary = required("BUZZ_E2E_CLI_BIN", process.env.BUZZ_E2E_CLI_BIN); + const { stdout } = await exec(binary, args, { + cwd: "..", + env: { + ...process.env, + BUZZ_AUTH_TAG: "", + BUZZ_PRIVATE_KEY: privateKey, + BUZZ_RELAY_URL: relayUrl, + }, + }); + return stdout; +} + +async function seedLiveChannel(relayUrl: string) { + const name = `reconnect-live-${process.pid}`; + const created = JSON.parse( + await runCli( + [ + "channels", + "create", + "--name", + name, + "--type", + "stream", + "--visibility", + "open", + ], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ), + ) as { channel_id: string }; + await runCli( + [ + "channels", + "add-member", + "--channel", + created.channel_id, + "--pubkey", + TEST_IDENTITIES.tyler.pubkey, + "--role", + "member", + ], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ); + return { id: created.channel_id, name }; +} + async function connectionState(page: Page): Promise { return page.evaluate(() => { const win = window as Window & { @@ -29,13 +83,61 @@ async function connectionState(page: Page): Promise { }); } +async function exerciseBackgroundTraffic(page: Page, durationMs: number) { + await page.evaluate(async (duration) => { + const deadline = Date.now() + duration; + while (Date.now() < deadline) { + void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + }, durationMs); +} + +async function resetConnectAttempts(page: Page) { + await page.evaluate(() => { + window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?.(); + }); +} + +async function assertConnectAttemptsArePaced(page: Page) { + const attempts = await page.evaluate( + () => window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?.() ?? [], + ); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts.length).toBeLessThanOrEqual(4); + for (let index = 1; index < attempts.length; index += 1) { + expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700); + } +} + +async function proveLiveDelivery( + page: Page, + relayUrl: string, + channel: { id: string; name: string }, + label: string, +) { + await page.getByTestId(`channel-${channel.name}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channel.name); + const message = `${label} ${Date.now()}`; + await runCli( + ["messages", "send", "--channel", channel.id, "--content", message], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ); + await expect(page.getByTestId("message-timeline")).toContainText(message, { + timeout: 30_000, + }); +} + test.describe("relay restart live gate", () => { test.skip(!enabled, "set BUZZ_E2E_RELAY_RESTART=1 to run live gate"); test("client reconnects after the relay is SIGTERMed and restarted", async ({ page, }) => { - test.setTimeout(180_000); + test.setTimeout(240_000); const portBase = 26_000 + (process.pid % 3_000); const spec: RelaySpec = { name: "relay-restart", @@ -56,6 +158,8 @@ test.describe("relay restart live gate", () => { await harness.startRelays(); const relayHttpUrl = `http://127.0.0.1:${spec.ports.main}`; + const channel = await test.step("seed live channel and membership", () => + seedLiveChannel(relayHttpUrl)); await installBridge(page, { mode: "relay", user: "tyler", @@ -64,28 +168,68 @@ test.describe("relay restart live gate", () => { }); await page.goto("/"); - // Baseline: the app converges to a live authenticated session. - await expect - .poll(() => connectionState(page), { timeout: 60_000 }) - .toBe("connected"); + // Baseline: the app converges to a live authenticated session and sees + // the channel created for this fresh database. + await test.step("wait for initial authenticated connection", async () => { + await expect + .poll(() => connectionState(page), { timeout: 60_000 }) + .toBe("connected"); + await expect(page.getByTestId(`channel-${channel.name}`)).toBeVisible({ + timeout: 30_000, + }); + }); // Roll the pod. Graceful drain: readiness 503 → 5s grace → 1012 close // broadcast → process exit. The client must observe the close (not a // silent stall) and start retrying. + await resetConnectAttempts(page); await harness.terminateRelayGracefully(spec.name); await expect .poll(() => connectionState(page), { timeout: 30_000 }) .not.toBe("connected"); + // Keep the real relay unavailable across several reconnect windows while + // ordinary app traffic continues. This is the production-shaped race: + // background queries must not bypass the session coordinator's backoff. + await exerciseBackgroundTraffic(page, 8_000); + await expect.poll(() => connectionState(page)).not.toBe("connected"); + await assertConnectAttemptsArePaced(page); + // Bring the "new pod" up on the same address, exactly like a k8s // restart behind a stable service endpoint. await harness.restartRelay(spec.name); // The client's retry loop must find the fresh relay and converge back - // to connected without any user interaction. + // to connected without any user interaction, then prove that AUTH and + // live-subscription replay finished by receiving an event published by + // a second identity through the real CLI/relay boundary. await expect .poll(() => connectionState(page), { timeout: 60_000 }) .toBe("connected"); + await proveLiveDelivery( + page, + relayHttpUrl, + channel, + "first automatic recovery", + ); + + // Flap the fresh pod once more. A second recovery catches stale timer, + // waiter, generation, and subscription state that a single cycle cannot. + await harness.terminateRelayGracefully(spec.name); + await expect + .poll(() => connectionState(page), { timeout: 30_000 }) + .not.toBe("connected"); + await exerciseBackgroundTraffic(page, 4_000); + await harness.restartRelay(spec.name); + await expect + .poll(() => connectionState(page), { timeout: 60_000 }) + .toBe("connected"); + await proveLiveDelivery( + page, + relayHttpUrl, + channel, + "second automatic recovery", + ); } catch (error) { console.error(await harness.logs()); throw error; From 1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 12:27:56 -0600 Subject: [PATCH 69/99] feat(release): make desktop releases immutable (#3568) ## Summary - add a manual desktop release preparer that regenerates one version-only candidate from current `origin/main` - validate deterministic complete changelog accounting, candidate authorship, allowed files, exact-head approval, required checks, and two-parent merge topology before tagging the reviewed candidate - move desktop tags/releases from `v*` to `desktop-v*` while preserving relay, chart, push-chart, and mobile behavior - stage all four platform outputs in Actions artifacts and grant GitHub release write access only to one final all-platform-gated publisher - publish the versioned release only after complete artifact assembly; update stable `latest.json` last; never promote prereleases or published rebuild outputs ## Safety properties - desktop tags point to the reviewed candidate SHA, not the merge commit - release builds remain tag-bound and reverify tag == checked-out HEAD - one final writer fails closed on artifact basename collisions - per-tag concurrency serializes publication without cancellation - published reruns do not replace immutable versioned assets or promote signatures from a rebuild - candidate branches use an explicit remote OID lease when regenerated ## Validation - `scripts/test-desktop-release-candidate.sh` - `scripts/test-release-ref-contract.sh` - `scripts/test-mobile-release-contract.sh` - changed workflow YAML parsing (Ruby Psych) - changed shell syntax (`bash -n`) - `git diff --check` - push hooks: branch-skew, Rust workspace tests (1,853 passed), desktop Tauri tests (3 passed) ## Coordinated companion - squareup/buzz-releases#79 updates the manually entered desktop source-tag contract to stable-only `desktop-v*` - merge the private contract companion before the first namespaced desktop release ## Rollout blockers (no settings changed here) Before the first candidate/release: 1. enable merge commits in repository settings 2. allow `merge` in ruleset `13596885` 3. require approval after the last push in ruleset `13596885` 4. include `refs/tags/desktop-v*` explicitly in release ruleset `14378754` 5. prove the non-publishing candidate/merge/tag/artifact validation path before any production release Do not test the old workflow with a prerelease: it can still mutate the production rolling updater release. --------- Signed-off-by: Wes Co-authored-by: Carl --- .../auto-tag-on-release-pr-merge.yml | 43 ++- .github/workflows/ci.yml | 2 + .github/workflows/prepare-desktop-release.yml | 38 +++ .github/workflows/release.yml | 318 ++++++++---------- Justfile | 4 +- RELEASING.md | 23 +- scripts/desktop_release.py | 214 ++++++++++++ scripts/prepare-desktop-release.sh | 83 +++++ scripts/required-check-succeeded.jq | 14 + scripts/review-decision-approved.jq | 1 + scripts/test-desktop-release-candidate.sh | 84 +++++ scripts/test-release-ref-contract.sh | 75 ++++- scripts/verify-desktop-release-merge.sh | 62 ++++ 13 files changed, 763 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/prepare-desktop-release.yml create mode 100755 scripts/desktop_release.py create mode 100755 scripts/prepare-desktop-release.sh create mode 100644 scripts/required-check-succeeded.jq create mode 100644 scripts/review-decision-approved.jq create mode 100755 scripts/test-desktop-release-candidate.sh create mode 100755 scripts/verify-desktop-release-merge.sh diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index db34fddc2c..a69eafb404 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -4,7 +4,7 @@ name: Auto-tag on Release PR Merge # prefix; the main chart lane also auto-detects a Chart.yaml version bump so # a chart feature PR can publish its own new version when merged: # -# version-bump/ → tag v → release.yml (desktop app) +# version-bump/ → tag desktop-v → release.yml (desktop app) # relay-release/ → tag relay-v → docker.yml (relay image) # chart-release/ → tag chart-v → helm-chart.yml (main helm chart) # push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml @@ -35,6 +35,11 @@ permissions: jobs: auto-tag: + permissions: + contents: read + pull-requests: read + checks: read + statuses: read if: > github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository @@ -57,7 +62,7 @@ jobs: case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" - TAG_PREFIX="v" ;; + TAG_PREFIX="desktop-v" ;; relay-release/*) VERSION="${BRANCH#relay-release/}" TAG_PREFIX="relay-v" ;; @@ -85,9 +90,34 @@ jobs: { echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" + if [[ "$TAG_PREFIX" == desktop-v ]]; then + echo "target_sha=${{ github.event.pull_request.head.sha }}" + echo "desktop=true" + else + echo "target_sha=$GITHUB_SHA" + echo "desktop=false" + fi } >> "$GITHUB_OUTPUT" echo "Tagging ${TAG_PREFIX}${VERSION}" + + - name: Verify immutable reviewed desktop candidate + if: steps.release.outputs.desktop == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.tag }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_PUSHER: ${{ github.event.pull_request.head.user.login }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + VERSION="${VERSION#desktop-v}" + export VERSION + scripts/verify-desktop-release-merge.sh + - name: Create release tagger token if: steps.release.outputs.enabled == 'true' id: release-tagger @@ -102,21 +132,22 @@ jobs: env: GH_TOKEN: ${{ steps.release-tagger.outputs.token }} TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} run: | set -euo pipefail # Check gh's exit status, not its output. A missing ref returns a 404 # JSON body on stdout, which must not be mistaken for an existing tag. if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" - if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation" exit 0 else - echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)" + echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)" exit 1 fi fi gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ - -f sha="$GITHUB_SHA" \ + -f sha="$TARGET_SHA" \ --silent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4826d985f..59d63f28da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Desktop release candidate contract + run: scripts/test-desktop-release-candidate.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml new file mode 100644 index 0000000000..7cc480b93b --- /dev/null +++ b/.github/workflows/prepare-desktop-release.yml @@ -0,0 +1,38 @@ +name: Prepare Desktop Release + +on: + workflow_dispatch: + inputs: + version: + description: Semver to prepare (for example 0.5.1) + required: true + +env: + RELEASE_AUTOMATION_NAME: Carl + RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz + +jobs: + prepare: + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Create short-lived release preparer token + id: preparer + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }} + private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ steps.preparer.outputs.token }} + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Prepare immutable candidate and open or update PR + env: + GH_TOKEN: ${{ steps.preparer.outputs.token }} + VERSION: ${{ inputs.version }} + run: scripts/prepare-desktop-release.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b87e9c8c08..07951ef81d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,13 @@ name: Release +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: - - 'v[0-9]*' - workflow_dispatch: - inputs: - version: - description: "Semver version matching the v-prefixed dispatch tag" - required: true + - 'desktop-v[0-9]*' jobs: # Shared setup: verify the immutable release tag, determine the version, and @@ -19,23 +18,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write + contents: read outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.source.outputs.source_sha }} steps: - name: Determine version id: version - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} - run: | - if [[ "$EVENT_NAME" == "push" ]]; then - VERSION="${GITHUB_REF_NAME#v}" - else - VERSION="$INPUT_VERSION" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT" - name: Validate version env: @@ -56,42 +46,9 @@ jobs: env: VERSION: ${{ steps.version.outputs.version }} run: | - scripts/verify-release-ref.sh v "$VERSION" + scripts/verify-release-ref.sh desktop-v "$VERSION" echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT" - - name: Create versioned GitHub release - env: - VERSION: ${{ steps.version.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - RELEASE_SHA=$(git rev-parse HEAD) - NOTES="" - if [[ -f CHANGELOG.md ]]; then - NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md) - fi - if [[ -z "$NOTES" ]]; then - NOTES="Buzz Desktop v${VERSION}" - fi - PRERELEASE_FLAGS=() - if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then - PRERELEASE_FLAGS=(--prerelease --latest=false) - fi - gh release create "v${VERSION}" \ - --target "$RELEASE_SHA" \ - --title "Buzz Desktop v${VERSION}" \ - --notes "$NOTES" \ - "${PRERELEASE_FLAGS[@]}" - - - name: Create rolling auto-update release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create buzz-desktop-latest \ - --prerelease \ - --title "Buzz Desktop Auto-Update" \ - --notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \ - 2>/dev/null || true - release: name: Release if: github.repository == 'block/buzz' @@ -99,7 +56,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -114,7 +71,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -272,13 +229,19 @@ jobs: fi echo "dmg=$DMG" >> "$GITHUB_OUTPUT" - # Find the updater .tar.gz and .sig + # Find the updater .tar.gz and .sig. Give each architecture a unique + # release basename before artifacts are merged by the final writer. ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1) SIG="${ARCHIVE}.sig" if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -289,23 +252,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload arm64 DMG to versioned GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.artifacts.outputs.dmg }} - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Apple Silicon release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-arm64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-macos-x64: name: Release macOS (Intel) @@ -314,7 +269,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -330,7 +285,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -443,6 +398,11 @@ jobs: echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -453,23 +413,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Intel DMG to versioned GitHub release - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.unsigned.outputs.dmg }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Intel macOS release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-x64 + if-no-files-found: error + path: | + ${{ steps.unsigned.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-linux: name: Release Linux @@ -480,7 +432,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read env: # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # Containers lack FUSE, so we must use the extract-and-run fallback. @@ -555,7 +507,7 @@ jobs: - name: Verify tag-bound release source env: VERSION: ${{ needs.setup.outputs.version }} - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -689,29 +641,16 @@ jobs: SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} # NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux) - - name: Upload Linux artifacts to versioned GitHub release - env: - VERSION: ${{ needs.setup.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }} - APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }} - run: | - gh release upload "v$VERSION" \ - "$DEB_PATH" \ - "$APPIMAGE_PATH" \ - --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }} - SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} + - name: Stage Linux release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-linux-x64 + if-no-files-found: error + path: | + ${{ steps.linux-artifacts.outputs.deb }} + ${{ steps.linux-artifacts.outputs.appimage }} + ${{ steps.linux-artifacts.outputs.archive }} + ${{ steps.linux-artifacts.outputs.sig }} release-windows: name: Release Windows @@ -719,7 +658,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} sig: ${{ steps.read-sig.outputs.sig }} @@ -735,7 +674,7 @@ jobs: - name: Verify tag-bound release source shell: bash - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: @@ -745,7 +684,7 @@ jobs: with: node-version: 24.14.1 # Disable dependency caching: a writable cache in this release workflow - # (contents: write, feeds a signed installer) is a poisoning vector. pnpm + # (contents: read, feeds a signed installer) is a poisoning vector. pnpm # install runs uncached below. package-manager-cache: false @@ -827,25 +766,14 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Windows installer to versioned GitHub release - shell: bash - run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXE_PATH: ${{ steps.artifacts.outputs.exe }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - shell: bash - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Windows release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-windows-x64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.exe }} + ${{ steps.artifacts.outputs.sig }} assemble-manifest: name: Assemble multi-platform latest.json @@ -853,7 +781,11 @@ jobs: if: | always() && needs.setup.result == 'success' && - github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) + needs.release.result == 'success' && + needs.release-macos-x64.result == 'success' && + needs.release-linux.result == 'success' && + needs.release-windows.result == 'success' && + github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version) runs-on: ubuntu-latest needs: [setup, release, release-macos-x64, release-linux, release-windows] timeout-minutes: 10 @@ -870,7 +802,26 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" + + - name: Download staged release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-release-* + path: staged-by-platform + + - name: Flatten staged artifacts without basename collisions + run: | + set -euo pipefail + mkdir staged + while IFS= read -r -d '' file; do + name="$(basename "$file")" + [[ ! -e "staged/$name" ]] || { + echo "::error::release artifact basename collision: $name" + exit 1 + } + cp "$file" "staged/$name" + done < <(find staged-by-platform -type f -print0) - name: Write signature files env: @@ -899,7 +850,7 @@ jobs: write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX" write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN" - - name: Verify archive URLs are accessible + - name: Verify draft release has every updater archive env: RESULT_ARM64: ${{ needs.release.result }} RESULT_X64: ${{ needs.release-macos-x64.result }} @@ -911,39 +862,19 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" - ARCHIVES=() - - add_archive() { - local result="$1" platform="$2" archive="$3" - if [[ "$result" == "success" ]]; then - [[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; } - ARCHIVES+=("$archive") - fi - } - - add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64" - add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64" - add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX" - add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN" - - for name in "${ARCHIVES[@]}"; do - echo "Checking $BASE/$name ..." - success=false - for attempt in 1 2 3; do - if curl -fsI "$BASE/$name" > /dev/null 2>&1; then - success=true - break - fi - echo "Attempt $attempt failed for $name, retrying in 10s..." - sleep 10 - done - if [ "$success" != "true" ]; then - echo "::error::Archive not accessible after 3 attempts: $BASE/$name" - exit 1 + assets=$(find staged -type f -exec basename {} \;) + for spec in \ + "$RESULT_ARM64:$ARCHIVE_ARM64" \ + "$RESULT_X64:$ARCHIVE_X64" \ + "$RESULT_LINUX:$ARCHIVE_LINUX" \ + "$RESULT_WIN:$ARCHIVE_WIN"; do + result="${spec%%:*}" + archive="${spec#*:}" + if [[ "$result" == success ]]; then + [[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; } + grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; } fi done - echo "All archive URLs verified." - name: Generate unified latest.json env: @@ -957,7 +888,7 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" + BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}" TRIPLES=() add_triple() { @@ -977,6 +908,45 @@ jobs: bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json - - name: Upload latest.json to rolling release + - name: Create or verify versioned draft run: | - gh release upload buzz-desktop-latest latest.json --clobber + set -euo pipefail + NOTES_FILE="${RUNNER_TEMP}/release-notes.md" + awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE" + [[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; } + PRERELEASE_FLAGS=() + if [[ "$VERSION" == *-* ]]; then + PRERELEASE_FLAGS=(--prerelease --latest=false) + fi + if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then + EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish) + IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft) + [[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || { + echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1; + } + if [[ "$IS_DRAFT" != true ]]; then + echo "already_published=true" >> "$GITHUB_ENV" + fi + else + gh release create "desktop-v${VERSION}" \ + --draft \ + --target "${{ needs.setup.outputs.source_sha }}" \ + --title "Buzz Desktop v${VERSION}" \ + --notes-file "$NOTES_FILE" \ + "${PRERELEASE_FLAGS[@]}" + fi + + - name: Upload complete artifact set to versioned draft + if: env.already_published != 'true' + run: | + mapfile -t files < <(find staged -type f -print) + [[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; } + gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber + + - name: Publish complete versioned release + if: env.already_published != 'true' + run: gh release edit "desktop-v${VERSION}" --draft=false + + - name: Upload latest.json to rolling release last + if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }} + run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/Justfile b/Justfile index a2fa408e7f..2d76f1a7b9 100644 --- a/Justfile +++ b/Justfile @@ -725,7 +725,7 @@ bump-relay-version version: cargo update -p buzz-relay echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" -# Open or update the desktop release PR (signed desktop app) +# Open or update the desktop release PR from an immutable origin/main snapshot release-desktop *ARGS: #!/usr/bin/env bash set -euo pipefail @@ -735,7 +735,7 @@ release-desktop *ARGS: else VERSION="$ARG" fi - just _release-pr desktop "$VERSION" + scripts/prepare-desktop-release.sh "$VERSION" # Open or update the relay release PR (ghcr.io/block/buzz image) release-relay *ARGS: diff --git a/RELEASING.md b/RELEASING.md index 063b813e2c..45f0f8638f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | +| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -31,7 +31,7 @@ just release-relay 0.4.0 scripts/mobile-release.sh candidate 0.5.0 ``` -Desktop and relay releases use metadata PRs. Mobile does not. Each +Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,12 +42,11 @@ or mobile GitHub Release. ### Desktop -1. **`just release-desktop`** runs locally on `main`, creates or updates a - `version-bump/` PR, bumps the desktop manifests, regenerates - lockfiles, and updates `CHANGELOG.md`. -2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v`. -3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and - publishes the desktop app for macOS and Linux. +1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR. +2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval. +3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs. +4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`. +5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions. ### Relay @@ -147,8 +146,8 @@ for distributable builds or builds from an immutable release tag. ## Manual Release Retry The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `v` tag. Select that tag in the ref picker and -provide the matching semver version without the `v` prefix. It cannot build +existing immutable `desktop-v` tag. Select that tag in the ref picker and +provide the matching semver version without the `desktop-v` prefix. It cannot build from `main` or another caller-selected source ref. Mobile intentionally has no branch or arbitrary-ref fallback. The private @@ -171,7 +170,7 @@ for the private pipeline contract. Desktop publishes two GitHub releases: -1. **`v`**: the user-facing release with installers. +1. **`desktop-v`**: the user-facing release with installers. 2. **`buzz-desktop-latest`**: the rolling auto-updater release. Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts @@ -186,7 +185,7 @@ The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel (`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and `.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `v` release. Intel users download the `_x64.dmg`. +the same `desktop-v` release. Intel users download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py new file mode 100755 index 0000000000..d6518b26b1 --- /dev/null +++ b/scripts/desktop_release.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Generate and validate immutable desktop release candidates.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHANGELOG = ROOT / "CHANGELOG.md" +METADATA = ROOT / ".release" / "desktop-candidate.json" +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") +DESKTOP_PATHS = ( + "desktop/", + "crates/buzz-core/", + "crates/buzz-persona/", + "crates/buzz-sdk/", + "crates/buzz-agent/", + "crates/buzz-media/", +) +CANDIDATE_FILES = { + ".release/desktop-candidate.json", + "CHANGELOG.md", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", + "desktop/src-tauri/Cargo.lock", + "pnpm-lock.yaml", +} +REQUIRED_CANDIDATE_FILES = { + ".release/desktop-candidate.json", + "CHANGELOG.md", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", +} + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[dict[str, str]]: + args = ["log", range_spec, "--no-merges", "--format=%H%x00%s"] + if paths: + args += ["--", *paths] + out = git(*args) + if not out: + return [] + return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()] + + +def stable_tags(base_sha: str) -> list[tuple[int, str, str]]: + tags: list[tuple[int, str, str]] = [] + for tag in git("tag", "--merged", base_sha, "--list").splitlines(): + if not re.fullmatch(r"(?:desktop-)?v[0-9]+\.[0-9]+\.[0-9]+", tag): + continue + sha = git("rev-list", "-n", "1", tag) + distance = int(git("rev-list", "--count", f"{sha}..{base_sha}")) + tags.append((distance, tag, sha)) + return tags + + +def previous_tag(base_sha: str) -> str: + tags = stable_tags(base_sha) + if not tags: + return "" + min_distance = min(item[0] for item in tags) + nearest = [item for item in tags if item[0] == min_distance] + commits = {item[2] for item in nearest} + if len(commits) != 1: + detail = ", ".join(f"{tag}@{sha}" for _, tag, sha in nearest) + raise SystemExit(f"ambiguous previous desktop release tags: {detail}") + # During migration, prefer the namespaced tag when aliases share a commit. + nearest.sort(key=lambda item: (not item[1].startswith("desktop-v"), item[1])) + return nearest[0][1] + + +def bullet(commit: dict[str, str], repo: str) -> str: + sha, subject = commit["sha"], commit["subject"] + short = sha[:12] + pr_match = re.search(r" \(#([0-9]+)\)$", subject) + if pr_match: + pr = pr_match.group(1) + subject = subject[: pr_match.start()] + return f"- {subject} ([#{pr}](https://github.com/{repo}/pull/{pr})) ([`{sha}`](https://github.com/{repo}/commit/{sha}))" + return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))" + + +def expected(base_sha: str, previous: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + # With no prior desktop tag, account for the repository's root commit too. + # A ``root..base`` range silently drops that first commit. + range_spec = f"{previous}..{base_sha}" if previous else base_sha + all_commits = commit_list(range_spec) + relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} + relevant = [c for c in all_commits if c["sha"] in relevant_shas] + other = [c for c in all_commits if c["sha"] not in relevant_shas] + return relevant, other + + +def render(version: str, base_sha: str, previous: str, repo: str) -> tuple[str, list[str]]: + relevant, other = expected(base_sha, previous) + lines = [f"## v{version}", "", "### Desktop and shared changes", ""] + lines += [bullet(c, repo) for c in relevant] or ["- None"] + lines += ["", "### Other repository changes", ""] + lines += [bullet(c, repo) for c in other] or ["- None"] + compare_start = previous or git("rev-list", "--max-parents=0", base_sha).splitlines()[0] + lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"] + return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other] + + +def generate(args: argparse.Namespace) -> None: + if not SEMVER.fullmatch(args.version): + raise SystemExit(f"invalid semver: {args.version}") + base_sha = git("rev-parse", args.base) + previous = previous_tag(base_sha) + repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git") + block, commits = render(args.version, base_sha, previous, repo) + old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n" + if not old.startswith("# Changelog"): + raise SystemExit("CHANGELOG.md must begin with '# Changelog'") + remainder = old.split("\n", 1)[1].lstrip("\n") if "\n" in old else "" + CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}") + METADATA.parent.mkdir(parents=True, exist_ok=True) + METADATA.write_text(json.dumps({ + "schema": 1, + "version": args.version, + "base_sha": base_sha, + "previous_tag": previous or None, + "tag": f"desktop-v{args.version}", + "commit_count": len(commits), + }, indent=2) + "\n") + + +def validate(args: argparse.Namespace) -> None: + data = json.loads(METADATA.read_text()) + version = args.version or data["version"] + if data != {**data, "version": version}: + raise SystemExit("candidate version does not match metadata") + if data["tag"] != f"desktop-v{version}": + raise SystemExit("candidate tag does not match version") + candidate = git("rev-parse", args.candidate) + parents = git("show", "-s", "--format=%P", candidate).split() + if len(parents) != 1 or parents[0] != data["base_sha"]: + raise SystemExit("candidate must be one commit directly above recorded base_sha") + changed = set(git("diff-tree", "--no-commit-id", "--name-only", "-r", candidate).splitlines()) + unexpected = changed - CANDIDATE_FILES + missing = REQUIRED_CANDIDATE_FILES - changed + if unexpected or missing: + detail = [] + if unexpected: + detail.append(f"unexpected files: {', '.join(sorted(unexpected))}") + if missing: + detail.append(f"missing required files: {', '.join(sorted(missing))}") + raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")") + previous = data["previous_tag"] or "" + actual_previous = previous_tag(data["base_sha"]) + if previous != actual_previous: + raise SystemExit( + f"recorded previous tag {previous or ''} does not match " + f"nearest release tag {actual_previous or ''}" + ) + repo = args.repo or "block/buzz" + expected_block, shas = render(version, data["base_sha"], previous, repo) + text = CHANGELOG.read_text() + blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text) + if len(blocks) != 1: + raise SystemExit(f"expected exactly one changelog block for v{version}") + if blocks[0].rstrip() != expected_block.rstrip(): + raise SystemExit("changelog block is not deterministic for recorded candidate base") + found = re.findall(r"\[`([0-9a-f]{40})`\]", blocks[0]) + if len(found) != len(set(found)) or set(found) != set(shas) or len(found) != data["commit_count"]: + raise SystemExit("changelog does not account for every expected non-merge commit exactly once") + manifests = { + ROOT / "desktop/package.json": json.loads((ROOT / "desktop/package.json").read_text())["version"], + ROOT / "desktop/src-tauri/tauri.conf.json": json.loads((ROOT / "desktop/src-tauri/tauri.conf.json").read_text())["version"], + } + cargo = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "desktop/src-tauri/Cargo.toml").read_text()) + manifests[ROOT / "desktop/src-tauri/Cargo.toml"] = cargo.group(1) if cargo else "" + bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version] + if bad: + raise SystemExit(f"version mismatch in: {', '.join(bad)}") + author = git("show", "-s", "--format=%an <%ae>", candidate) + body = git("show", "-s", "--format=%B", candidate) + if author != "Wes ": + raise SystemExit(f"unexpected candidate author: {author}") + if "Signed-off-by: Wes " not in body: + raise SystemExit("candidate is missing Wes Signed-off-by trailer") + if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body): + raise SystemExit("candidate is missing automation Co-authored-by trailer") + print(f"validated immutable desktop candidate {candidate} for desktop-v{version}") + + +def main() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + gen = sub.add_parser("generate") + gen.add_argument("version") + gen.add_argument("--base", required=True) + gen.add_argument("--repo") + val = sub.add_parser("validate") + val.add_argument("--candidate", default="HEAD") + val.add_argument("--version") + val.add_argument("--repo") + args = parser.parse_args() + generate(args) if args.command == "generate" else validate(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh new file mode 100755 index 0000000000..c477a84cda --- /dev/null +++ b/scripts/prepare-desktop-release.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${1:-}" +mode="${2:-publish}" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { + echo "usage: $0 [publish|validate-only]" >&2 + exit 1 +} + +remote="${RELEASE_REMOTE:-origin}" +git fetch "$remote" refs/heads/main:refs/remotes/origin/main --no-tags +git fetch "$remote" '+refs/tags/v*:refs/tags/v*' '+refs/tags/desktop-v*:refs/tags/desktop-v*' +base_sha="$(git rev-parse refs/remotes/origin/main)" +branch="version-bump/$version" + +remote_branch="refs/heads/$branch" +remote_oid="" +if remote_oid="$(git ls-remote "$remote" "$remote_branch" | awk '{print $1}')" && [[ -n "$remote_oid" ]]; then + git fetch "$remote" "$remote_branch:refs/remotes/origin/$branch" +fi + +git checkout -B "$branch" "$base_sha" +just bump-desktop-version "$version" +scripts/desktop_release.py generate "$version" --base "$base_sha" --repo block/buzz + +git add \ + .release/desktop-candidate.json \ + CHANGELOG.md \ + desktop/package.json \ + desktop/src-tauri/tauri.conf.json \ + desktop/src-tauri/Cargo.toml \ + desktop/src-tauri/Cargo.lock \ + pnpm-lock.yaml + +agent_name="${RELEASE_AUTOMATION_NAME:-${AGENT_NAME:-Release Automation}}" +agent_email="${RELEASE_AUTOMATION_EMAIL:-${AGENT_EMAIL:-release-automation@users.noreply.github.com}}" +msg="$(mktemp)" +trap 'rm -f "$msg"' EXIT +cat >"$msg" < +EOF +git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \ + commit -s -F "$msg" +scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz + +candidate_sha="$(git rev-parse HEAD)" +previous_tag="$(python3 -c 'import json; print(json.load(open(".release/desktop-candidate.json"))["previous_tag"] or "initial")')" +printf 'base_sha=%s\ncandidate_sha=%s\nprevious_tag=%s\ntag=desktop-v%s\n' \ + "$base_sha" "$candidate_sha" "$previous_tag" "$version" + +if [[ "$mode" == validate-only ]]; then + exit 0 +fi +[[ "$mode" == publish ]] || { echo "unknown mode: $mode" >&2; exit 1; } +if [[ -n "$remote_oid" ]]; then + git push --force-with-lease="$remote_branch:$remote_oid" "$remote" "HEAD:$remote_branch" +else + git push --force-with-lease="$remote_branch:" "$remote" "HEAD:$remote_branch" +fi + +body="$(mktemp)" +trap 'rm -f "$msg" "$body"' EXIT +cat >"$body" < "$tmp/desktop/package.json" +printf '{"version":"1.0.0"}\n' > "$tmp/desktop/src-tauri/tauri.conf.json" +printf '[package]\nversion = "1.0.0"\n' > "$tmp/desktop/src-tauri/Cargo.toml" +echo '# Changelog' > "$tmp/CHANGELOG.md" +echo first > "$tmp/desktop/feature" +git -C "$tmp" add . +git -C "$tmp" commit -qm 'feat: first desktop change' +git -C "$tmp" -c tag.gpgSign=false tag v1.0.0 +echo second >> "$tmp/desktop/feature" +git -C "$tmp" commit -qam 'fix: desktop fix' +echo policy > "$tmp/POLICY.md" +git -C "$tmp" add POLICY.md +git -C "$tmp" commit -qm 'docs: repository policy' +base=$(git -C "$tmp" rev-parse HEAD) +( + cd "$tmp" + scripts/desktop_release.py generate 1.0.1 --base "$base" --repo block/buzz + python3 - <<'PY' +import json +for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'): + data=json.load(open(path)); data['version']='1.0.1'; open(path,'w').write(json.dumps(data)+'\n') +p='desktop/src-tauri/Cargo.toml'; open(p,'w').write('[package]\nversion = "1.0.1"\n') +PY + rm -f msg + git add . + cat >msg <<'EOF' +chore(release): release Buzz Desktop version 1.0.1 + +Co-authored-by: Test Automation +EOF + git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -F msg + rm msg + scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + grep -Fq '### Other repository changes' CHANGELOG.md + grep -Fq "$(git rev-parse HEAD~1)" CHANGELOG.md + grep -Fq "$(git rev-parse HEAD~2)" CHANGELOG.md + + # Metadata cannot lie about the prior release boundary. + cp .release/desktop-candidate.json metadata.json + python3 - <<'PY' +import json +p='.release/desktop-candidate.json'; d=json.load(open(p)); d['previous_tag']=None; open(p,'w').write(json.dumps(d)+'\n') +PY + if scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a forged previous release tag" >&2 + exit 1 + fi + mv metadata.json .release/desktop-candidate.json +) + +# An initial release must account for the root commit, not silently omit it. +initial=$(mktemp -d) +cp "$repo_root/scripts/desktop_release.py" "$initial/desktop_release.py" +git -C "$initial" init -q +git -C "$initial" config user.name test +git -C "$initial" config user.email test@example.com +mkdir -p "$initial/scripts" "$initial/desktop/src-tauri" +mv "$initial/desktop_release.py" "$initial/scripts/desktop_release.py" +printf '{"version":"0.1.0"}\n' > "$initial/desktop/package.json" +printf '{"version":"0.1.0"}\n' > "$initial/desktop/src-tauri/tauri.conf.json" +printf '[package]\nversion = "0.1.0"\n' > "$initial/desktop/src-tauri/Cargo.toml" +printf '# Changelog\n' > "$initial/CHANGELOG.md" +echo root > "$initial/ROOT.md" +git -C "$initial" add . +git -C "$initial" commit -qm 'feat: root release content' +root_sha=$(git -C "$initial" rev-parse HEAD) +(cd "$initial" && scripts/desktop_release.py generate 0.1.0 --base "$root_sha" --repo block/buzz) +grep -Fq "$root_sha" "$initial/CHANGELOG.md" +rm -rf "$initial" + +echo "desktop release candidate contract passed" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 0d810819d9..bd4eb75275 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -12,16 +12,16 @@ git -C "$tmp" config user.email test@example.com echo first >"$tmp/file" git -C "$tmp" add file git -C "$tmp" commit -qm first -git -C "$tmp" tag -m "desktop release" v1.2.3 +git -C "$tmp" tag -m "desktop release" desktop-v1.2.3 ( cd "$tmp" - GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 + GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3 ) if ( cd "$tmp" - GITHUB_REF=refs/heads/main "$verify" v 1.2.3 + GITHUB_REF=refs/heads/main "$verify" desktop-v 1.2.3 ); then echo "branch-backed desktop release was accepted" >&2 exit 1 @@ -31,7 +31,7 @@ echo second >>"$tmp/file" git -C "$tmp" commit -qam second if ( cd "$tmp" - GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 + GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3 ); then echo "release accepted HEAD after the tag commit" >&2 exit 1 @@ -61,6 +61,73 @@ grep -q 'private-key:.*secrets\.BUZZ_RELEASE_TAGGER_PRIVATE_KEY' "$auto_tag" grep -q 'permission-contents: write' "$auto_tag" grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag" grep -Fq 'git/refs' "$auto_tag" +grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag" +grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag" +grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag" +review_filter="$repo_root/scripts/review-decision-approved.jq" +for fixture in \ + '{"reviewDecision":"CHANGES_REQUESTED"}' \ + '{"reviewDecision":"REVIEW_REQUIRED"}' \ + '{"reviewDecision":null}' \ + '{}'; do + if jq -e -f "$review_filter" <<<"$fixture" >/dev/null; then + echo "review-decision filter accepted non-approved fixture: $fixture" >&2 + exit 1 + fi +done +jq -e -f "$review_filter" >/dev/null <<'JSON' || { +{"reviewDecision":"APPROVED"} +JSON + echo "review-decision filter rejected approved GraphQL response" >&2 + exit 1 +} +required_check_filter="$repo_root/scripts/required-check-succeeded.jq" +check_fixture() { + local expected="$1" conclusion="$2" status="${3:-completed}" + local payload + payload=$(jq -n --arg status "$status" --arg conclusion "$conclusion" '{check_runs: [{name: "Web", status: $status, conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z"}]}') + if jq -e --arg name Web -f "$required_check_filter" <<<"[$payload]" >/dev/null; then + actual=pass + else + actual=fail + fi + [[ "$actual" == "$expected" ]] || { + echo "required-check filter: expected $conclusion/$status to $expected" >&2 + exit 1 + } +} +check_fixture pass success +check_fixture pass skipped +check_fixture pass neutral +check_fixture fail failure +check_fixture fail success in_progress +# A newer failure must not be hidden by an older successful run of the same check. +jq -e --arg name Web -f "$required_check_filter" >/dev/null <<'JSON' && { +[{"check_runs":[ + {"name":"Web","status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z"}, + {"name":"Web","status":"completed","conclusion":"failure","started_at":"2026-01-02T00:00:00Z"} +]}] +JSON + echo "required-check filter accepted a stale pass over a newer failure" >&2 + exit 1 +} +release_workflow="$repo_root/.github/workflows/release.yml" +[[ "$(grep -c 'contents: write' "$release_workflow")" -eq 1 ]] || { + echo "desktop release must have exactly one GitHub contents writer" >&2; exit 1; +} +grep -Fq "needs.release.result == 'success'" "$release_workflow" +grep -Fq "needs.release-macos-x64.result == 'success'" "$release_workflow" +grep -Fq "needs.release-linux.result == 'success'" "$release_workflow" +grep -Fq "needs.release-windows.result == 'success'" "$release_workflow" +grep -Fq "refs/tags/desktop-v{0}" "$release_workflow" +grep -Fq "if: \${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" +grep -Fq 'group: desktop-release-${{ github.ref }}' "$release_workflow" +grep -Fq 'cancel-in-progress: false' "$release_workflow" +grep -Fq 'release artifact basename collision' "$release_workflow" +[[ "$(grep -c 'gh release upload' "$release_workflow")" -eq 2 ]] || { + echo "only the final writer may upload versioned and rolling release assets" >&2; exit 1; +} +grep -Fq 'if: env.already_published' "$release_workflow" grep -Fq 'if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then' "$auto_tag" if grep -F 'git/ref/tags/$TAG' "$auto_tag" | grep -Fq '|| true'; then echo "auto-tag ignores a failed tag lookup, so a 404 body can look like an existing tag" >&2 diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh new file mode 100755 index 0000000000..17bf2f4dcb --- /dev/null +++ b/scripts/verify-desktop-release-merge.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PR_HEAD_SHA:?}" +: "${MERGE_SHA:?}" +: "${VERSION:?}" +: "${PR_NUMBER:?}" +: "${GH_TOKEN:?}" + +required_checks=( + "Desktop E2E Integration" + "Desktop" + "Rust Lint" + "Security" + "Unit Tests" + "Windows Rust (x86_64-pc-windows-msvc)" + "Mobile" + "Web" + "Backend Integration (relay e2e)" + "Desktop E2E Relay" + "Relay E2E" + "Desktop Build (macOS)" + "DCO Check" +) + +expected_branch="version-bump/$VERSION" +[[ "${PR_HEAD_REF:-}" == "$expected_branch" ]] || { echo "unexpected release branch" >&2; exit 1; } +[[ "${PR_BASE_REF:-}" == main ]] || { echo "desktop release must target main" >&2; exit 1; } +[[ "${PR_HEAD_REPO:-}" == "$GITHUB_REPOSITORY" ]] || { echo "desktop release must be internal" >&2; exit 1; } + +git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags +mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n') +[[ "${#parents[@]}" -eq 2 ]] || { echo "desktop release was not merged with a true merge commit" >&2; exit 1; } +[[ "${parents[1]}" == "$PR_HEAD_SHA" ]] || { echo "merge parent 2 is not the reviewed candidate" >&2; exit 1; } +git merge-base --is-ancestor "$PR_HEAD_SHA" origin/main || { echo "candidate is not reachable from current main" >&2; exit 1; } + +git checkout --detach "$PR_HEAD_SHA" +scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" + +review=$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest') +jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || { + echo "pull request effective review decision is not APPROVED" >&2 + exit 1 +} +reviews="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" +valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" +[[ "$valid_approvals" -gt 0 ]] || { echo "candidate lacks an exact-head approval from a repository member or collaborator" >&2; exit 1; } + +checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")" +for required in "${required_checks[@]}"; do + jq -e --arg name "$required" -f scripts/required-check-succeeded.jq <<<"$checks" >/dev/null || { + echo "required check is missing or unsuccessful: $required" >&2 + exit 1 + } +done +status="$(gh api "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/status")" +jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null || { + echo "candidate has a failing or pending combined commit status" >&2 + exit 1 +} + +echo "verified reviewed desktop candidate $PR_HEAD_SHA at merge $MERGE_SHA" From f48f3f055fdd6030d3832f615f8c0d8e5a81261a Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 19:36:30 +0100 Subject: [PATCH 70/99] Fix video reviews in thread replies (#3719) ## Summary - Show video review comments when a video is opened from a thread reply. - Reuse review-context construction across timeline and thread views. ## Validation - `pnpm run build:e2e && pnpm exec playwright test tests/e2e/video-attachment.spec.ts --project smoke --grep "video replies in threads open the review comments view"` - `pnpm test` --------- Signed-off-by: kenny lopez --- .../src/features/channels/ui/ChannelPane.tsx | 34 +++++----- .../features/channels/ui/ChannelPane.types.ts | 1 + .../features/channels/ui/ChannelScreen.tsx | 2 + .../messages/lib/independentThreadPanel.ts | 16 ++--- .../messages/lib/videoReviewContext.test.mjs | 28 +++++++++ .../messages/lib/videoReviewContext.ts | 45 +++++++++++++ .../messages/ui/MessageThreadPanel.tsx | 11 +++- .../messages/ui/TimelineMessageList.tsx | 47 ++++---------- desktop/tests/e2e/video-attachment.spec.ts | 63 +++++++++++++++++-- 9 files changed, 182 insertions(+), 65 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 92fa172ff5..70877875f7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -21,10 +21,7 @@ import { getDmHuddleMemberPubkeys, hasOtherDmParticipant, } from "@/features/channels/lib/dmHuddleMembers"; -import { - buildVideoReviewCommentsByRootId, - buildVideoReviewContextForMessage, -} from "@/features/messages/lib/videoReviewContext"; +import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; @@ -150,6 +147,7 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + threadAllMessages, threadHeadMessage, threadMessages, threadMessagesPending = false, @@ -472,25 +470,26 @@ export const ChannelPane = React.memo(function ChannelPane({ threadHeadMessage, threadMessages, }); - const videoReviewCommentsByRootId = React.useMemo( - () => buildVideoReviewCommentsByRootId(messages), - [messages], - ); const activeVideoReviewCommentSender = activeChannel?.archivedAt ? undefined : onSendVideoReviewComment; - const threadHeadVideoReviewContext = React.useMemo(() => { - if (!threadHeadMessage) { - return undefined; + const threadVideoReviewContextsByMessageId = React.useMemo(() => { + const messagesById = new Map( + messages.map((message) => [message.id, message]), + ); + if (threadHeadMessage) { + messagesById.set(threadHeadMessage.id, threadHeadMessage); + } + for (const message of threadAllMessages) { + messagesById.set(message.id, message); } - return buildVideoReviewContextForMessage({ + return buildVideoReviewContextsByMessageId({ channelId: activeChannel?.id ?? null, channelName: activeChannel?.name, channelType: activeChannel?.channelType ?? null, - comments: videoReviewCommentsByRootId.get(threadHeadMessage.id) ?? [], isSendingVideoReviewComment: isSending, - message: threadHeadMessage, + messages: [...messagesById.values()], onSendVideoReviewComment: activeVideoReviewCommentSender, onToggleReaction, profiles, @@ -499,10 +498,11 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, activeVideoReviewCommentSender, isSending, + messages, onToggleReaction, profiles, + threadAllMessages, threadHeadMessage, - videoReviewCommentsByRootId, ]); const isOverlay = useIsThreadPanelOverlay(); @@ -876,7 +876,9 @@ export const ChannelPane = React.memo(function ChannelPane({ scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} threadHead={threadHeadMessage} - threadHeadVideoReviewContext={threadHeadVideoReviewContext} + videoReviewContextsByMessageId={ + threadVideoReviewContextsByMessageId + } widthPx={threadPanelWidthPx} threadReplies={threadMessages} threadRepliesPending={threadMessagesPending} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5a27d85c4d..7257d8cd55 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -151,6 +151,7 @@ export type ChannelPaneProps = { profilePanelTab: ProfilePanelTab; profilePanelView: ProfilePanelView; threadHeadMessage: TimelineMessage | null; + threadAllMessages: TimelineMessage[]; threadMessages: MainTimelineEntry[]; threadMessagesPending?: boolean; threadPanelWidthPx: number; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 52b3ae7fb7..7b750daa4f 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -691,6 +691,7 @@ export function ChannelScreen({ channelManagementOpen, ); const displayedThreadHeadMessage = threadPanelData.threadHead; + const displayedThreadAllMessages = threadPanelData.messages; const displayedThreadMessages = threadPanelData.visibleReplies; const displayedThreadReplyTargetMessage = threadPanelData.replyTargetMessage; const displayedThreadFirstUnreadReplyId = displayedThreadHeadMessage @@ -944,6 +945,7 @@ export function ChannelScreen({ firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} threadHeadMessage={displayedThreadHeadMessage} threadMessages={displayedThreadMessages} threadMessagesPending={threadRepliesQuery.isPending} diff --git a/desktop/src/features/messages/lib/independentThreadPanel.ts b/desktop/src/features/messages/lib/independentThreadPanel.ts index 4562928d8b..7652c2508c 100644 --- a/desktop/src/features/messages/lib/independentThreadPanel.ts +++ b/desktop/src/features/messages/lib/independentThreadPanel.ts @@ -11,16 +11,18 @@ export function buildIndependentThreadPanel( ...formatArgs: Tail> ) { if (!rootId) { - return buildThreadPanelData([], null, replyTargetId, expandedReplyIds); + return { + ...buildThreadPanelData([], null, replyTargetId, expandedReplyIds), + messages: [], + }; } const head = channelEvents.find((event) => event.id === rootId); const events = head ? [head, ...replyEvents] : replyEvents; - return buildThreadPanelData( - formatTimelineMessages(events, ...formatArgs), - rootId, - replyTargetId, - expandedReplyIds, - ); + const messages = formatTimelineMessages(events, ...formatArgs); + return { + ...buildThreadPanelData(messages, rootId, replyTargetId, expandedReplyIds), + messages, + }; } type Tail = T extends readonly [ diff --git a/desktop/src/features/messages/lib/videoReviewContext.test.mjs b/desktop/src/features/messages/lib/videoReviewContext.test.mjs index de35d53ce3..8ecb5f5798 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.test.mjs +++ b/desktop/src/features/messages/lib/videoReviewContext.test.mjs @@ -5,6 +5,7 @@ import { buildVideoReviewCommentsByRootId, buildVideoReviewCommentsForRoot, buildVideoReviewContextForMessage, + buildVideoReviewContextsByMessageId, hasVideoAttachment, } from "./videoReviewContext.ts"; @@ -209,3 +210,30 @@ test("buildVideoReviewContextForMessage posts against the source video", async ( }, ]); }); + +test("buildVideoReviewContextsByMessageId includes video replies", () => { + const root = message({ id: "root", body: "Review request" }); + const videoReply = message({ + id: "video-reply", + body: "![video](https://relay/media/a.mp4)", + parentId: root.id, + rootId: root.id, + }); + const comment = message({ + id: "comment", + body: "[00:01] tighten this", + parentId: videoReply.id, + rootId: root.id, + }); + + const contexts = buildVideoReviewContextsByMessageId({ + channelId: "channel", + messages: [root, videoReply, comment], + }); + + assert.deepEqual([...contexts.keys()], [videoReply.id]); + assert.deepEqual( + contexts.get(videoReply.id)?.comments.map((item) => item.id), + [comment.id], + ); +}); diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index 8d0798db40..f605952f5a 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -148,3 +148,48 @@ export function buildVideoReviewContextForMessage({ rootEventId: message.id, }; } + +export function buildVideoReviewContextsByMessageId({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment = false, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, +}: { + channelId?: string | null; + channelName?: string; + channelType?: ChannelType | null; + isSendingVideoReviewComment?: boolean; + messages: TimelineMessage[]; + onSendVideoReviewComment?: SendVideoReviewComment; + onToggleReaction?: ToggleMessageReaction; + profiles?: UserProfileLookup; +}): ReadonlyMap { + const contexts = new Map(); + if (!messages.some(hasVideoAttachment)) { + return contexts; + } + + const commentsByRootId = buildVideoReviewCommentsByRootId(messages); + for (const message of messages) { + const context = buildVideoReviewContextForMessage({ + channelId, + channelName, + channelType, + comments: commentsByRootId.get(message.id) ?? [], + isSendingVideoReviewComment, + message, + onSendVideoReviewComment, + onToggleReaction, + profiles, + }); + if (context) { + contexts.set(message.id, context); + } + } + + return contexts; +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 08a57fa4c6..6234af22d1 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -108,7 +108,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { threadUnreadCount?: number; threadReplyUnreadCounts?: ReadonlyMap; threadTypingPubkeys: string[]; - threadHeadVideoReviewContext?: VideoReviewContext; + videoReviewContextsByMessageId?: ReadonlyMap; activityAccessoryContent?: React.ReactNode; activityAccessoryVisible: boolean; widthPx: number; @@ -221,7 +221,7 @@ export function MessageThreadPanel({ scrollTargetId, scrollTargetHighlights = true, threadHead, - threadHeadVideoReviewContext, + videoReviewContextsByMessageId, threadReplies, threadRepliesPending = false, threadUnreadCount, @@ -600,7 +600,9 @@ export function MessageThreadPanel({ } profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} - videoReviewContext={threadHeadVideoReviewContext} + videoReviewContext={videoReviewContextsByMessageId?.get( + threadHead.id, + )} /> @@ -756,6 +758,9 @@ export function MessageThreadPanel({ onToggleReaction={onToggleReaction} profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} + videoReviewContext={videoReviewContextsByMessageId?.get( + entry.message.id, + )} /> {entry.summary ? ( - messages.some(hasVideoAttachment) - ? buildVideoReviewCommentsByRootId(messages) - : new Map(), - [messages], - ); // Contexts are memoized per message id so MessageRow/Markdown memo // comparisons hold across unrelated timeline re-renders (typing // indicators, presence updates) — a fresh context object per render would // defeat the memo and re-render every video message on every pass. const videoReviewContextById = React.useMemo(() => { - const contexts = new Map< - string, - NonNullable> - >(); - for (const message of messages) { - const comments = reviewCommentsByRootId.get(message.id) ?? []; - const context = buildVideoReviewContextForMessage({ - channelId, - channelName, - channelType, - comments, - isSendingVideoReviewComment, - message, - onSendVideoReviewComment, - onToggleReaction, - profiles, - }); - if (context) { - contexts.set(message.id, context); - } - } - return contexts; + return buildVideoReviewContextsByMessageId({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + }); }, [ channelId, channelName, @@ -213,7 +191,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onSendVideoReviewComment, onToggleReaction, profiles, - reviewCommentsByRootId, ]); // The flattened item stream, memoized on the entries and the unread boundary diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 8f4bb0aa4a..458689a5e0 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -53,25 +53,31 @@ function emitMockMessage( page: Page, channelName: string, content: string, - options: { extraTags?: string[][] } = {}, + options: { extraTags?: string[][]; parentEventId?: string } = {}, ) { return page.evaluate( - ({ channelName, content, extraTags }) => { + ({ channelName, content, extraTags, parentEventId }) => { const emit = ( window as Window & { __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { channelName: string; content: string; extraTags?: string[][]; + parentEventId?: string; }) => unknown; } ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; if (!emit) { throw new Error("Mock message emitter is unavailable."); } - emit({ channelName, content, extraTags }); + return emit({ channelName, content, extraTags, parentEventId }); + }, + { + channelName, + content, + extraTags: options.extraTags, + parentEventId: options.parentEventId, }, - { channelName, content, extraTags: options.extraTags }, ); } @@ -765,6 +771,55 @@ test("video upload previews use poster frames and inline videos open review mode ).toContainText("Color pass looks right"); }); +test("video replies in threads open the review comments view", async ({ + page, +}) => { + await installVideoReviewHarness(page); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + const root = (await emitMockMessage( + page, + "general", + "Can you review this cut?", + )) as { id: string }; + const videoReply = (await emitMockMessage( + page, + "general", + `![video](${VIDEO_URL})`, + { + parentEventId: root.id, + }, + )) as { id: string }; + await emitMockMessage(page, "general", "[00:01] Tighten this transition.", { + parentEventId: videoReply.id, + }); + + const threadSummary = page.locator(`[data-thread-head-id="${root.id}"]`); + await expect(threadSummary).toBeVisible(); + await threadSummary.click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadReplies = threadPanel.getByTestId("message-thread-replies"); + const reviewButton = threadReplies.getByRole("button", { + name: "Open video review", + }); + await expect(reviewButton).toBeVisible(); + await reviewButton.click(); + + const reviewDialog = page.getByTestId("video-review-dialog"); + await expect( + reviewDialog.getByTestId("video-review-comments-panel"), + ).toBeVisible(); + await expect(reviewDialog.getByTestId("message-composer")).toBeVisible(); + await expect(reviewDialog.getByTestId("video-review-comments")).toContainText( + "Tighten this transition.", + ); +}); + test("narrow inline videos hide playback speed control", async ({ page }) => { await installVideoReviewHarness(page); From 6e419b9f1c873549a7b40996970e0da7352adafb Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 19:39:12 +0100 Subject: [PATCH 71/99] Tighten continuation message rows (#3724) ## Summary - use uniform 4px top and bottom padding for continuation rows - keep continuation timestamps top-aligned and remove the thread-only minimum-height gutter - raise continuation hover actions by 12px - align virtualized row estimates with the compact layout ## Validation - `pnpm test` (3,782 tests via pre-push) - `pnpm check` - desktop snapshots ## Screenshots ### Mention-chip continuation ![Mention-chip continuation](https://raw.githubusercontent.com/block/buzz/85b88763ef8147f3376c9bf794bc0973a0211a57/pr-3724--thread-continuation.png) ### Emoji continuation ![Emoji continuation](https://raw.githubusercontent.com/block/buzz/85b88763ef8147f3376c9bf794bc0973a0211a57/pr-3724--channel-continuation.png) --------- Signed-off-by: kenny lopez --- .../src/features/messages/lib/rowHeightEstimate.test.mjs | 7 +++++++ desktop/src/features/messages/lib/rowHeightEstimate.ts | 4 ++-- desktop/src/features/messages/ui/MessageRow.tsx | 8 +++++--- desktop/tests/e2e/messaging.spec.ts | 4 +++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index dc33da4f8f..f17a53c661 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -23,6 +23,13 @@ test("estimateRowHeight: short text is near the floor", () => { assert.ok(h >= 60 && h < 120, `expected small, got ${h}`); }); +test("estimateRowHeight: continuation reserves its uniform padding", () => { + const h = estimateRowHeight(msg({ body: "hello" }), { + isContinuation: true, + }); + assert.equal(h, 28); +}); + test("estimateRowHeight: many lines reserve more", () => { const tall = estimateRowHeight( msg({ body: Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n") }), diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index f2fb268167..acefae95d4 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -26,13 +26,13 @@ const TEXT_LINE_HEIGHT = 20; const CODE_LINE_HEIGHT = 19; const CHARS_PER_LINE = 64; // rough wrap width at the timeline column const ROW_CHROME = 26; // author/time header + denser row padding -const CONTINUATION_ROW_CHROME = 8; // dense row padding only; header/avatar are hidden +const CONTINUATION_ROW_CHROME = 8; // uniform py-1 padding; header/avatar are hidden const MEDIA_BLOCK_MARGIN_TOP = 4; // image/video blocks use mt-1 in markdown const REACTION_ROW = 24; const PREVIEW_CARD = 70; const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5 const MIN_ESTIMATE = 60; // never reserve less than the old flat floor -const CONTINUATION_MIN_ESTIMATE = 34; +const CONTINUATION_MIN_ESTIMATE = 28; function mediaHeightFromDim(dim: string | undefined): number { const dimensions = dimensionsFromDim(dim); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 069232fde4..688b5d5f0d 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -433,8 +433,8 @@ export const MessageRow = React.memo( ) : null} + {install.isPending && installOutputLine ? ( +

+ {installOutputLine} +

+ ) : null} + {installError ? (

{installError} diff --git a/desktop/src/features/settings/ui/HarnessRow.tsx b/desktop/src/features/settings/ui/HarnessRow.tsx index de6666b8c3..c0feef13cc 100644 --- a/desktop/src/features/settings/ui/HarnessRow.tsx +++ b/desktop/src/features/settings/ui/HarnessRow.tsx @@ -10,6 +10,7 @@ import { useManagedAgentsQuery, usePersonasQuery, } from "@/features/agents/hooks"; +import { useInstallOutputLine } from "@/features/agents/lib/useInstallOutputLine"; import { RuntimeIcon } from "@/features/onboarding/ui/RuntimeIcon"; import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; @@ -324,6 +325,7 @@ export function HarnessRow({ }, [resetEpoch]); const isInstalling = installMutation.isPending; const installError = installResult?.error ?? null; + const installOutputLine = useInstallOutputLine(runtime.id, isInstalling); const del = useDeleteCustomHarnessMutation(); // Blast-radius data for the delete confirmation — only fetched while the @@ -348,7 +350,7 @@ export function HarnessRow({ } else { setInstallResult({ success: false, - error: getInstallErrorMessage(result.steps), + error: getInstallErrorMessage(result), }); } }, @@ -479,6 +481,15 @@ export function HarnessRow({

) : null} + {isInstalling && installOutputLine ? ( +

+ {installOutputLine} +

+ ) : null} {installError ? (

({ + step: step.step, + command: step.command, + success: step.success, + stdout: step.stdout, + stderr: step.stderr, + exitCode: step.exit_code, + hint: step.hint, + })), + restartedCount: raw.restarted_count, + failedRestartCount: raw.failed_restart_count, + logPath: raw.log_path ?? null, + }; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c57525480e..69e2e455ec 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -3,6 +3,10 @@ import { activateRateLimit, parseRateLimitHint, } from "@/shared/api/relayRateLimitGate"; +import { + fromRawInstallRuntimeResult, + type RawInstallRuntimeResult, +} from "@/shared/api/installTypes"; import type { AddChannelMembersInput, AddChannelMembersResult, @@ -202,22 +206,10 @@ export type RawAcpRuntimeCatalogEntry = { definition_env?: Record; }; -export type RawInstallStepResult = { - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exit_code: number | null; - hint?: string; -}; - -export type RawInstallRuntimeResult = { - success: boolean; - steps: RawInstallStepResult[]; - restarted_count: number; - failed_restart_count: number; -}; +export type { + RawInstallRuntimeResult, + RawInstallStepResult, +} from "./installTypes"; type RawGitBashPrerequisite = { available: boolean; @@ -772,25 +764,6 @@ export function fromRawAcpRuntimeCatalogEntry( }; } -function fromRawInstallRuntimeResult( - raw: RawInstallRuntimeResult, -): InstallRuntimeResult { - return { - success: raw.success, - steps: raw.steps.map((step) => ({ - step: step.step, - command: step.command, - success: step.success, - stdout: step.stdout, - stderr: step.stderr, - exitCode: step.exit_code, - hint: step.hint, - })), - restartedCount: raw.restarted_count, - failedRestartCount: raw.failed_restart_count, - }; -} - function fromRawCommandAvailability( command: RawCommandAvailability, ): CommandAvailability { diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..877b5b1c61 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -571,22 +571,10 @@ export type AcpRuntime = AcpRuntimeCatalogEntry & { binaryPath: string; }; -export type InstallStepResult = { - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exitCode: number | null; - hint?: string; -}; - -export type InstallRuntimeResult = { - success: boolean; - steps: InstallStepResult[]; - restartedCount: number; - failedRestartCount: number; -}; +export type { + InstallRuntimeResult, + InstallStepResult, +} from "./installTypes"; export type AcpAuthMethod = { id: string; diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts index 82ed8f306a..86c0e16c13 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -32,7 +32,7 @@ export type ConfigNudgeRequirement = * Determines which message and CTA the nudge card shows: * - "available" → tooling installed, needs login * - "adapter_missing" → CLI installed but ACP adapter missing - * - "adapter_outdated" → ACP adapter present but from deprecated package; reinstall required + * - "adapter_outdated" → ACP adapter present but unsupported/outdated; reinstall required * - "cli_missing" → ACP adapter installed but CLI missing * - "not_installed" → neither adapter nor CLI found */ diff --git a/desktop/src/shared/lib/installError.test.mjs b/desktop/src/shared/lib/installError.test.mjs index c6b51186a8..181d7b802b 100644 --- a/desktop/src/shared/lib/installError.test.mjs +++ b/desktop/src/shared/lib/installError.test.mjs @@ -3,84 +3,108 @@ import test from "node:test"; import { getInstallErrorMessage } from "./installError.ts"; +/** A failed install result carrying `steps` and, optionally, a log pointer. */ +function failed(steps, logPath = null) { + return { + success: false, + steps, + restartedCount: 0, + failedRestartCount: 0, + logPath, + }; +} + test("getInstallErrorMessage: empty steps array returns fallback", () => { - assert.equal(getInstallErrorMessage([]), "Install failed with no output."); + assert.equal( + getInstallErrorMessage(failed([])), + "Install failed with no output.", + ); }); test("getInstallErrorMessage: failed step without hint contains step name and stderr", () => { - const message = getInstallErrorMessage([ - { - step: "adapter", - command: "npm install -g @block/buzz-acp", - success: false, - stdout: "", - stderr: "EACCES: permission denied", - exitCode: 1, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "adapter", + command: "npm install -g @block/buzz-acp", + success: false, + stdout: "", + stderr: "EACCES: permission denied", + exitCode: 1, + }, + ]), + ); assert.match(message, /Step "adapter" failed:/); assert.match(message, /EACCES: permission denied/); }); test("getInstallErrorMessage: failed step without hint does not contain hint-ish text", () => { - const message = getInstallErrorMessage([ - { - step: "adapter", - command: "npm install -g @block/buzz-acp", - success: false, - stdout: "", - stderr: "EACCES: permission denied", - exitCode: 1, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "adapter", + command: "npm install -g @block/buzz-acp", + success: false, + stdout: "", + stderr: "EACCES: permission denied", + exitCode: 1, + }, + ]), + ); assert.doesNotMatch(message, /npm config set prefix/); }); test("getInstallErrorMessage: failed step with hint starts with hint and still contains stderr", () => { const hint = "Fix the npm prefix ownership:\n sudo chown -R $USER $(npm config get prefix)"; - const message = getInstallErrorMessage([ - { - step: "adapter", - command: "npm install -g @block/buzz-acp", - success: false, - stdout: "", - stderr: "EACCES: permission denied, mkdir '/usr/local/lib'", - exitCode: 1, - hint, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "adapter", + command: "npm install -g @block/buzz-acp", + success: false, + stdout: "", + stderr: "EACCES: permission denied, mkdir '/usr/local/lib'", + exitCode: 1, + hint, + }, + ]), + ); assert.ok(message.startsWith(hint), "message should start with hint"); assert.match(message, /EACCES: permission denied/); }); test("getInstallErrorMessage: failed step with empty stderr falls back to stdout", () => { - const message = getInstallErrorMessage([ - { - step: "node", - command: "node --version", - success: false, - stdout: "some stdout output", - stderr: "", - exitCode: 1, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "node", + command: "node --version", + success: false, + stdout: "some stdout output", + stderr: "", + exitCode: 1, + }, + ]), + ); assert.match(message, /some stdout output/); }); test("getInstallErrorMessage: hint and step detail are separated by double newline for whitespace-pre-line rendering", () => { const hint = "Git Bash is required. Install it from git-scm.com."; - const message = getInstallErrorMessage([ - { - step: "shell", - command: "bash -l -c 'npm install'", - success: false, - stdout: "", - stderr: "bash: command not found", - exitCode: 127, - hint, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "shell", + command: "bash -l -c 'npm install'", + success: false, + stdout: "", + stderr: "bash: command not found", + exitCode: 127, + hint, + }, + ]), + ); assert.ok( message.includes("\n\n"), "hint and step detail should be separated by a blank line", @@ -89,25 +113,72 @@ test("getInstallErrorMessage: hint and step detail are separated by double newli }); test("getInstallErrorMessage: only reports the last (failing) step when multiple steps present", () => { - const message = getInstallErrorMessage([ - { - step: "node", - command: "node --version", - success: true, - stdout: "v20.0.0", - stderr: "", - exitCode: 0, - }, - { - step: "adapter", - command: "npm install -g @agentclientprotocol/claude-code-acp", - success: false, - stdout: "", - stderr: "npm ERR! code E404", - exitCode: 1, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "node", + command: "node --version", + success: true, + stdout: "v20.0.0", + stderr: "", + exitCode: 0, + }, + { + step: "adapter", + command: "npm install -g @agentclientprotocol/claude-code-acp", + success: false, + stdout: "", + stderr: "npm ERR! code E404", + exitCode: 1, + }, + ]), + ); assert.match(message, /Step "adapter" failed:/); assert.match(message, /npm ERR! code E404/); assert.doesNotMatch(message, /Step "node"/); }); + +test("getInstallErrorMessage: points at the install log when one was written", () => { + const message = getInstallErrorMessage( + failed( + [ + { + step: "cli", + command: "curl … | bash", + success: false, + stdout: "", + stderr: "download failed", + exitCode: 1, + }, + ], + "/logs/install-goose.log", + ), + ); + assert.match(message, /download failed/); + assert.ok( + message.endsWith("\n\nFull log: /logs/install-goose.log"), + `log pointer should close the message, got: ${message}`, + ); +}); + +test("getInstallErrorMessage: omits the log pointer when no log was written", () => { + const message = getInstallErrorMessage( + failed([ + { + step: "cli", + command: "curl … | bash", + success: false, + stdout: "", + stderr: "download failed", + exitCode: 1, + }, + ]), + ); + assert.doesNotMatch(message, /Full log/); +}); + +test("getInstallErrorMessage: a run with no steps at all still points at its log", () => { + const message = getInstallErrorMessage(failed([], "/logs/install-goose.log")); + assert.match(message, /Install failed with no output\./); + assert.match(message, /Full log: \/logs\/install-goose\.log/); +}); diff --git a/desktop/src/shared/lib/installError.ts b/desktop/src/shared/lib/installError.ts index bf72c4d3b2..82bcd4a310 100644 --- a/desktop/src/shared/lib/installError.ts +++ b/desktop/src/shared/lib/installError.ts @@ -1,15 +1,25 @@ -import type { InstallStepResult } from "@/shared/api/types"; +import type { InstallRuntimeResult } from "@/shared/api/types"; /** * Build the user-visible error message for a failed install. * When the last step carries an actionable hint, it is shown first, * followed by the raw step failure detail. + * + * The step detail is truncated for display, so the message ends with a pointer + * to the install log — which holds every attempt of every step, each record + * bounded far above the display truncation — when one was written. */ -export function getInstallErrorMessage(steps: InstallStepResult[]): string { +export function getInstallErrorMessage(result: InstallRuntimeResult): string { + const { steps, logPath } = result; const lastStep = steps[steps.length - 1]; if (!lastStep) { - return "Install failed with no output."; + return withLog("Install failed with no output.", logPath); } const base = `Step "${lastStep.step}" failed: ${lastStep.stderr || lastStep.stdout || "unknown error"}`; - return lastStep.hint ? `${lastStep.hint}\n\n${base}` : base; + const detail = lastStep.hint ? `${lastStep.hint}\n\n${base}` : base; + return withLog(detail, logPath); +} + +function withLog(message: string, logPath: string | null): string { + return logPath ? `${message}\n\nFull log: ${logPath}` : message; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 07eaa77902..841e6ba83f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1,4 +1,5 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { emit } from "@tauri-apps/api/event"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; import { decode, npubEncode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; @@ -203,6 +204,8 @@ type E2eConfig = { acpRuntimesCatalogAfterConnect?: RawAcpRuntimeCatalogEntry[]; activePersonaIds?: string[]; installAcpRuntimeDelayMs?: number; + /** Live output lines the mocked install emits before it settles. */ + installAcpRuntimeOutputLines?: string[]; installAcpRuntimeResult?: RawInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. * Call N returns results[N]; when exhausted the last entry repeats. @@ -1257,6 +1260,8 @@ const REACTION_TARGET_CONTENT = "React to me with a custom emoji"; // REACTION_TARGET_EVENT_ID. const SYSTEM_REACTION_TARGET_EVENT_ID = "e".repeat(64); const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "buzz:e2e-identity-override.v1"; +/** Stands in for `tauri.conf.json`'s version, which no mock IPC call can read. */ +const MOCK_APP_VERSION = "0.0.0-e2e"; const DEFAULT_MOCK_IDENTITY = { pubkey: "deadbeef".repeat(8), display_name: "npub1mock...", @@ -7228,6 +7233,53 @@ let personaSharePublicationCallCount = 0; // Per-page confirm_team_snapshot_import call counter for sequenced error testing. let teamSnapshotConfirmCallCount = 0; +// Live-output sequence for the install currently being replayed. The backend +// counter is per run (`InstallReporter::for_run` starts a fresh one), so this +// restarts too — a bridge that stayed monotonic across installs would hide a UI +// that carried a stale sequence number into the next run and rejected all of it. +let installOutputSeq = 0; + +/** + * Replay the live output the Rust reporter emits while an install runs: a clear + * signal, then one line per entry. `seq` is install-wide and monotonic, matching + * the backend contract the UI's ordering depends on. + * + * The clear and the first line emit synchronously with the install invocation, + * exactly as the backend does — the command is invoked from the click handler, + * so those events land before React has committed the pending install state. + * Delaying them would let a listener that mounts on that state still catch them, + * hiding the very race the UI has to survive. + * + * Later lines are spaced so each is observable rather than collapsing into one + * frame with the next. + */ +const INSTALL_OUTPUT_REPLAY_GAP_MS = 1000; + +async function replayInstallOutput( + runtimeId: string, + lines: string[], +): Promise { + installOutputSeq = 0; + // The leading null is the clear signal the backend sends when an attempt + // starts, so this replays a whole attempt rather than only its output. + const events: (string | null)[] = [null, ...lines]; + for (const [index, line] of events.entries()) { + // Index 0 and 1 are the clear and the first line: no gap before either. + if (index > 1) { + await new Promise((resolve) => + window.setTimeout(resolve, INSTALL_OUTPUT_REPLAY_GAP_MS), + ); + } + // `emit` reaches listeners registered through the real `listen` API, which + // is what the UI hook uses; mockIPC's shouldMockEvents wires the two. + await emit("acp-install-output", { + runtime_id: runtimeId, + seq: installOutputSeq++, + line, + }); + } +} + async function handleInstallAcpRuntime( args: { runtimeId?: string; @@ -7235,6 +7287,10 @@ async function handleInstallAcpRuntime( config: E2eConfig | undefined, ): Promise { const runtimeId = args.runtimeId ?? ""; + const outputLines = config?.mock?.installAcpRuntimeOutputLines; + if (outputLines && outputLines.length > 0) { + await replayInstallOutput(runtimeId, outputLines); + } const perRuntime = config?.mock?.installAcpRuntimeByRuntime?.[runtimeId]; if (perRuntime) { @@ -7291,6 +7347,7 @@ async function handleInstallAcpRuntime( ], restarted_count: 0, failed_restart_count: 0, + log_path: null, }; } @@ -11629,6 +11686,11 @@ export function maybeInstallE2eTauriMocks() { return null; case "plugin:window|is_fullscreen": return false; + // Settings reads the app version through the app plugin. Without this the + // bridge throws an unhandled page error on every Settings render, which + // shows up as noise in unrelated specs. + case "plugin:app|version": + return MOCK_APP_VERSION; case "merge_save_subscription_kinds": { // Mirrors `merge_owner_p_kinds`: union `kind` into the owner_p row's // kinds, creating the row if it doesn't exist yet. diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 77d0fbcb45..2d69a4e0da 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -983,4 +983,91 @@ test.describe("Doctor panel state screenshots", () => { path: `${SHOTS}/08-concurrent-installs-and-stale-clear.png`, }); }); + /** + * 09 — install observability: the live output line appears while the install + * runs and disappears when it settles, and the failure message points at the + * install log rather than only the truncated last step. + */ + test("09-install-output-line-and-log-pointer", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + { + ...CODEX_NOT_INSTALLED, + can_auto_install: true, + node_required: false, + }, + BUZZ_AGENT_AVAILABLE, + ], + installAcpRuntimeDelayMs: 500, + installAcpRuntimeOutputLines: [ + "npm http fetch GET 200 @zed-industries/codex-acp", + "npm warn deprecated a transitive dependency", + ], + installAcpRuntimeResult: { + success: false, + steps: [ + { + step: "adapter", + command: "npm install -g @zed-industries/codex-acp", + success: false, + stdout: "", + stderr: "npm ERR! code E404", + exit_code: 1, + }, + ], + log_path: "/tmp/buzz-install-codex.log", + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + const row = page.getByTestId("doctor-runtime-codex"); + await expect(row).toBeVisible({ timeout: 10_000 }); + + const installButton = page.getByTestId("doctor-runtime-install-codex"); + await expect(installButton).toBeEnabled(); + await installButton.click(); + + // The bridge emits the attempt-start clear and the first line synchronously + // with the install invocation — before React commits the pending state — so + // observing this line proves the listener was already mounted at the click. + // A subscription that waited for the install state would have missed both. + const outputLine = page.getByTestId("doctor-runtime-install-output-codex"); + await expect(outputLine).toContainText("npm http fetch", { + timeout: 5_000, + }); + + // Each new line replaces the previous one rather than accumulating. + await expect(outputLine).toContainText("npm warn deprecated", { + timeout: 5_000, + }); + await expect(outputLine).not.toContainText("npm http fetch"); + + // Settled: the line clears, so a finished install leaves no stale output + // under a fresh Install button. + const installError = page.getByTestId("doctor-runtime-install-error-codex"); + await expect(installError).toBeVisible({ timeout: 5_000 }); + await expect(outputLine).toHaveCount(0); + + // The failure points at the log holding bounded output for every attempt. + await expect(installError).toContainText("npm ERR! code E404"); + await expect(installError).toContainText("/tmp/buzz-install-codex.log"); + + await row.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await row.screenshot({ + path: `${SHOTS}/09-install-output-line-and-log-pointer.png`, + }); + + // A second install shows its own output. The backend sequence restarts per + // run, so a display that kept the previous run's sequence number would + // reject every event of this one and show nothing at all. + await installButton.click(); + await expect(outputLine).toContainText("npm http fetch", { + timeout: 5_000, + }); + }); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 468f860203..c3473ae4f1 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -131,6 +131,22 @@ export type MockAgentMemoryListing = { fetchedAt: number; }; +/** Result returned by the `install_acp_runtime` mock command. */ +type MockInstallRuntimeResult = { + success: boolean; + steps: { + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exit_code: number | null; + hint?: string; + }[]; + /** Install log the failure message points at. Omitted = no log was written. */ + log_path?: string | null; +}; + type MockBridgeOptions = { /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; @@ -171,35 +187,17 @@ type MockBridgeOptions = { connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string; installAcpRuntimeDelayMs?: number; + /** Live output lines the mocked install emits before it settles, in order. + * Each arrives as an `acp-install-output` event, preceded by the clear + * signal the backend sends at the start of an attempt. */ + installAcpRuntimeOutputLines?: string[]; /** Override the result returned by the `install_acp_runtime` mock command. * Pass `{ success: false, steps: [...] }` to exercise error/Retry states. */ - installAcpRuntimeResult?: { - success: boolean; - steps: { - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exit_code: number | null; - hint?: string; - }[]; - }; + installAcpRuntimeResult?: MockInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. Call N * returns results[N]; when exhausted the last entry repeats. Takes precedence * over `installAcpRuntimeResult`. Use for fail-then-succeed Retry tests. */ - installAcpRuntimeResults?: Array<{ - success: boolean; - steps: { - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exit_code: number | null; - hint?: string; - }[]; - }>; + installAcpRuntimeResults?: MockInstallRuntimeResult[]; activePersonaIds?: string[]; /** * Listing returned by the mocked `get_agent_memory` command. Pass a single From b9e4ed616f39b812bc964e79c7a40223c4e93832 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 15:50:51 -0600 Subject: [PATCH 77/99] test(desktop): click visible thread collapse guide (#3800) ## Summary - target the visible thread branch collapse guide in the messaging smoke test - avoid clicking the underlying collapse rail when the guide overlaps it - retain the existing post-click assertions that verify the two-reply branch collapses ## Context `main` CI failed because Playwright repeatedly attempted to click the lower `thread-collapse-rail` while the matching `thread-collapse-guide` intercepted pointer events. Both controls dispatch collapse for the same branch; the guide is the actual topmost user target and is already used by `thread-unread.spec.ts`. Failing run: https://github.com/block/buzz/actions/runs/30575425126 ## Validation - focused Playwright smoke test: 1 passed - pre-push hooks: desktop check passed; 3,835 desktop tests passed - `git diff --check` ## Review Princess Donut reviewed the test-only approach and locator determinism with no blockers. Mongo review is pending. Signed-off-by: Wes Co-authored-by: Carl --- desktop/tests/e2e/messaging.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 43a617ffd4..c6f5aefb9b 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -918,10 +918,10 @@ test("opens a single-level thread panel with inline expansion", async ({ `[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`, ); await expect(firstReplySummaryRow).toHaveCount(0); - const firstReplyBranchRail = threadReplies.locator( - `[data-testid="thread-collapse-rail"][data-thread-head-id="${firstReplyId}"]`, + const firstReplyBranchGuide = threadReplies.locator( + `[data-testid="thread-collapse-guide"][data-thread-head-id="${firstReplyId}"]`, ); - await expect(firstReplyBranchRail).toHaveCount(1); + await expect(firstReplyBranchGuide).not.toHaveCount(0); await expect(rootSummaryRow).toContainText("18 replies"); await expect( @@ -941,7 +941,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await expectThreadReplyUnobscured(nestedReplyRow); - await firstReplyBranchRail.click(); + await firstReplyBranchGuide.first().click(); await expect(firstReplySummaryRow).toHaveCount(1); await expect(firstReplySummaryRow).toContainText("2 replies"); await expect( From 114d40d9d37f05eff83ee90347ed93fb3da512c5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 30 Jul 2026 17:53:30 -0400 Subject: [PATCH 78/99] feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team catalog projections (`kind:30178`) embed every member's system prompt, so they need the same read gate personas already have: only the author sees an unshared event. The gate was hardcoded to `kind:30175` at six read surfaces plus the SQL pushdown, so rather than adding a second special case it becomes kind-generic over `SHARED_GATED_KINDS = {30175, 30178}`. ## Kind 30178 New parameterized-replaceable kind, addressed by `(pubkey_o, 30178, team_id)`. It embeds sanitized member projections instead of referencing `kind:30175` heads — a foreign reader of a shared team could not otherwise hydrate members whose own persona events are unshared or, for built-ins, absent entirely. `kind:30176`'s wire body is untouched, so device sync keeps its contract. ## Kind-generic shared gate `buzz_core::kind` replaces `is_persona_shared_kind` / `is_unshared_persona_event` / `persona_event_is_shared` with `SHARED_GATED_KINDS` and the kind-agnostic `is_shared_gated_kind` / `is_unshared_gated_event` / `event_is_shared`. Every read surface consults the set: | Surface | File | |---|---| | REQ historical delivery + `ids` lookup | `crates/buzz-relay/src/handlers/req.rs` | | Live fan-out | `crates/buzz-relay/src/handlers/event.rs` | | COUNT fallback | `crates/buzz-relay/src/handlers/count.rs` | | NIP-98 HTTP `/query`, `/count`, `/search` | `crates/buzz-relay/src/api/bridge.rs` | | Pre-`LIMIT` SQL pushdown | `crates/buzz-db/src/event.rs` | The SQL clause generalizes from `kind != 30175` to `kind NOT IN (...)` bound from `SHARED_GATED_KINDS`, still applied before `ORDER BY … LIMIT` so a page of newer private events cannot starve an older shared one off the candidate set. `EventQuery::persona_reader` is renamed `shared_gated_reader` and `needs_persona_filtering` to `needs_shared_gate_filtering` to match. Because the `buzz-core` rename has consumers outside the relay, the four desktop call sites of `persona_event_is_shared` travel with it: `desktop/src-tauri/src/commands/personas/pending.rs`, `desktop/src-tauri/src/event_sync.rs`, and two in `desktop/src-tauri/src/managed_agents/persona_events.rs`. Each call is unchanged apart from the name — the persona `shared` projection behaves exactly as before. ## Ingest validation `validate_persona_envelope` splits into two reusable pieces — `validate_shared_tag` (exactly-two-element `["shared","true"]`, at most one occurrence) and `single_bounded_d_tag` (exactly one `d` tag, non-empty, `<=64` chars, no ASCII control characters or whitespace). `validate_team_catalog_envelope` composes both; personas additionally keep the slug grammar `^[a-z0-9][a-z0-9_-]{0,63}$`. `kind:30178` deliberately does **not** get the slug grammar. Team ids are UUIDs or built-in identifiers such as `builtin-team:welcome`, and the colon is not slug-legal; rewriting ids to fit would break NIP-33 addressing against the team's own `kind:30176` head. The non-empty and exactly-one checks are load-bearing regardless — without them generic NIP-33 storage maps a missing `d` onto `(pubkey_o, 30178, "")` and every team overwrites its predecessor. The exact two-element `shared` shape is enforced because the SQL visibility clause is JSONB containment (`tags @> '[["shared","true"]]'`), which would match a three-element superset such as `["shared","true","extra"]`. `kind:30178` is also added to the `Scope::UsersWrite` allowlist and to `is_global_only_kind`, so a stray `h` tag cannot channel-scope an owner-authored definition. ## Deferred `kind:30176` is deliberately not a gate member. Its writers never emit `shared`, so catalog opt-in semantics do not describe it — it needs owner-private reads driven by an authenticated principal set, tracked as a separate follow-up. ## Tests - 19 new `ingest.rs` unit tests covering the 30178 envelope (UUID and colon `d` tags, 64-char boundary, non-ASCII bound, empty/valueless/duplicate/missing `d`, embedded newline, `shared` false/three-element/duplicate, scope and global-only membership). - Persona regressions for the valueless `["d"]` shapes, since the `d`-tag helper is shared by both validators. - Existing `kind.rs` gate tests generalized and extended to assert the gate applies to 30178 as it does to 30175. - New `crates/buzz-test-client/tests/e2e_team_catalog.rs`: 9 WS-level tests over a live relay covering author reads of unshared heads, foreign omission from REQ, `ids`-lookup denial, COUNT existence-leak, share and unshare transitions, and the mixed-kind filter case. - `.github/workflows/ci.yml` adds `--test e2e_team_catalog` to the Relay E2E job so the new suite runs. ## Docs `docs/nips/NIP-AP.md` gains a "Team catalog projection: kind:30178" section and an "Ingest validation: kind:30178" subsection, records the gate as kind-generic, documents 30178 deletion vs. unshare semantics, and adds a security note that sharing a team exposes every member's instructions even when that member's own `kind:30175` head is unshared. Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 2 +- crates/buzz-core/src/kind.rs | 182 +++++-- crates/buzz-db/src/event.rs | 49 +- crates/buzz-relay/src/api/bridge.rs | 38 +- crates/buzz-relay/src/handlers/count.rs | 32 +- crates/buzz-relay/src/handlers/event.rs | 12 +- crates/buzz-relay/src/handlers/ingest.rs | 309 +++++++++-- crates/buzz-relay/src/handlers/req.rs | 42 +- crates/buzz-test-client/tests/e2e_persona.rs | 4 +- .../tests/e2e_team_catalog.rs | 484 ++++++++++++++++++ .../src/commands/personas/pending.rs | 4 +- desktop/src-tauri/src/event_sync.rs | 2 +- .../src/managed_agents/persona_events.rs | 4 +- docs/nips/NIP-AP.md | 48 +- 14 files changed, 1033 insertions(+), 179 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_team_catalog.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59d63f28da..bc594e16ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -739,7 +739,7 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index afec52305a..e5f67f671f 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -182,29 +182,43 @@ pub const P_GATED_KINDS: &[u32] = &[ /// or more than one `shared` tag) so no ambiguous heads can exist. pub const KIND_PERSONA: u32 = 30175; -/// Returns `true` if `kind` uses the author-only-unless-shared read model -/// (currently only `KIND_PERSONA` / 30175). +/// Kinds that use the author-only-unless-shared read model. /// /// Events of these kinds may only be delivered to foreign readers when the -/// event carries exactly `["shared", "true"]`. Used by all relay read -/// chokepoints: REQ historical delivery, live fan-out, COUNT fallback, -/// and the `ids`-lookup result gate. -pub fn is_persona_shared_kind(kind: u32) -> bool { - kind == KIND_PERSONA +/// event carries exactly `["shared", "true"]`. Every relay read chokepoint +/// consults this set: REQ historical delivery, live fan-out, COUNT fallback, +/// the `ids`-lookup result gate, both HTTP surfaces, and the pre-`LIMIT` SQL +/// visibility pushdown in `buzz-db`. +/// +/// Membership is a privacy decision, not a convenience: adding a kind here +/// makes its events invisible to foreign readers until their author opts in, +/// and the opt-in must be a `shared` TAG (not a content field) so that +/// toggling it leaves content bytes — and any content hash derived from them — +/// unchanged. +/// +/// `KIND_TEAM` (30176) is deliberately NOT a member. Its writers never emit +/// `shared`, so catalog opt-in semantics do not describe it; it needs +/// owner-private read semantics instead, which is a separate change. +pub const SHARED_GATED_KINDS: &[u32] = &[KIND_PERSONA, KIND_TEAM_CATALOG]; + +/// Returns `true` if `kind` uses the author-only-unless-shared read model +/// (see [`SHARED_GATED_KINDS`]). +pub fn is_shared_gated_kind(kind: u32) -> bool { + SHARED_GATED_KINDS.contains(&kind) } -/// Returns `true` if the event is a persona-shared-catalog kind AND the -/// requester is NOT the author AND the event does NOT carry `["shared", -/// "true"]`. All three conditions must hold to withhold the event. +/// Returns `true` if the event is a shared-gated kind AND the requester is NOT +/// the author AND the event does NOT carry `["shared", "true"]`. All three +/// conditions must hold to withhold the event. /// /// This is the per-event gate used by REQ historical delivery, live fan-out, /// and COUNT fallback paths. It is intentionally independent of -/// `is_author_only_event` — persona events with `["shared", "true"]` MUST +/// `is_author_only_event` — shared-gated events with `["shared", "true"]` MUST /// reach foreign readers; stripping them at the author-only layer would break /// the catalog query. -pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { +pub fn is_unshared_gated_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { let kind = event.kind.as_u16() as u32; - if !is_persona_shared_kind(kind) { + if !is_shared_gated_kind(kind) { return false; } // Author reads are always allowed. @@ -212,18 +226,23 @@ pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: & return false; } // Foreign reader: allowed only if the event is explicitly shared. - !persona_event_is_shared(event) + !event_is_shared(event) } /// Returns `true` if the event carries exactly one `["shared", "true"]` tag. /// +/// Kind-agnostic: this is purely the tag-shape predicate. The kind check lives +/// in [`is_shared_gated_kind`], so callers that need "is this event shared" +/// for a kind they already know (e.g. a client deciding whether its own +/// retained head is published) can use this directly. +/// /// Requires the tag to have exactly two elements so that a three-element shape /// like `["shared","true","extra"]` is NOT treated as shared. Ingest enforces /// the same exact shape, so a well-stored event either has no `shared` tag /// (author-only) or exactly one with precisely two elements and value `"true"` /// (community-readable). This helper fails closed on any non-exact shape /// independently of ingest guarantees. -pub fn persona_event_is_shared(event: &nostr::Event) -> bool { +pub fn event_is_shared(event: &nostr::Event) -> bool { let mut count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); @@ -258,6 +277,34 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored). +/// +/// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)` +/// where `d_tag` is the team's stable id. Content is a versioned JSON body +/// carrying sanitized team fields plus ordered, EMBEDDED member definition +/// projections. +/// +/// # Why this is not a `shared` tag on [`KIND_TEAM`] +/// +/// A team's members live in kind 30175 events that are author-only unless +/// individually shared, so a foreign reader of a shared team could never +/// hydrate its members. This kind therefore embeds the member projections +/// rather than referencing them: the share is atomic, it covers built-in +/// members that have no 30175 head at all, it is immune to local-id/d-tag +/// divergence, and an unshared 30175 stays private. Kind 30176's wire body is +/// untouched, so device sync keeps its contract. +/// +/// # Access control +/// +/// Member of [`SHARED_GATED_KINDS`]: author-only unless the event carries +/// exactly `["shared", "true"]`. Ingest additionally requires exactly one +/// non-empty, bounded `d` tag — generic NIP-33 storage maps a missing `d` to +/// the empty coordinate, which would collapse every team into one slot. +/// +/// Content carries only sanitized fields: no env vars, no `respond_to` +/// allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. +pub const KIND_TEAM_CATALOG: u32 = 30178; + // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). /// @@ -586,6 +633,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_TEAM_CATALOG, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -784,6 +832,7 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000– const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 @@ -858,64 +907,68 @@ mod tests { } } - // ── persona_event_is_shared / is_unshared_persona_event ────────────── + // ── event_is_shared / is_unshared_gated_event ──────────────────────── - fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + fn make_event_of_kind(kind: u32, tags: &[&[&str]]) -> nostr::Event { use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); let tag_vec: Vec = tags .iter() .map(|parts| Tag::parse(parts.iter().copied()).unwrap()) .collect(); - EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "") + EventBuilder::new(Kind::Custom(kind as u16), "") .tags(tag_vec) .sign_with_keys(&keys) .unwrap() } + fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + make_event_of_kind(KIND_PERSONA, tags) + } + #[test] - fn persona_event_is_shared_true_tag() { + fn event_is_shared_true_tag() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); - assert!(persona_event_is_shared(&ev)); + assert!(event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_no_tag() { + fn event_is_shared_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_wrong_value() { + fn event_is_shared_wrong_value() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "false"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_duplicate_shared_tags() { + fn event_is_shared_duplicate_shared_tags() { // Two ["shared","true"] tags → ambiguous; not considered shared. let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"], &["shared", "true"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_three_element_tag_not_shared() { + fn event_is_shared_three_element_tag_not_shared() { // ["shared","true","extra"] — three elements — must NOT be treated as shared. // The helper fails closed on any non-exact shape independently of ingest guarantees. let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true", "extra"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_one_element_tag_not_shared() { + fn event_is_shared_one_element_tag_not_shared() { // ["shared"] — only one element — not shared (fails the == 2 check). let ev = make_persona_event(&[&["d", "my-agent"], &["shared"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn is_unshared_persona_event_author_always_allowed() { + fn is_unshared_gated_event_author_always_allowed() { // Even without a shared tag the event author should not be blocked. use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); @@ -924,32 +977,83 @@ mod tests { .sign_with_keys(&keys) .unwrap(); let author_bytes = keys.public_key().to_bytes(); - assert!(!is_unshared_persona_event(&ev, &author_bytes)); + assert!(!is_unshared_gated_event(&ev, &author_bytes)); } #[test] - fn is_unshared_persona_event_foreign_no_tag() { + fn is_unshared_gated_event_foreign_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); let foreign = [0u8; 32]; - assert!(is_unshared_persona_event(&ev, &foreign)); + assert!(is_unshared_gated_event(&ev, &foreign)); } #[test] - fn is_unshared_persona_event_foreign_shared_tag() { + fn is_unshared_gated_event_foreign_shared_tag() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); let foreign = [0u8; 32]; - assert!(!is_unshared_persona_event(&ev, &foreign)); + assert!(!is_unshared_gated_event(&ev, &foreign)); } #[test] - fn is_unshared_persona_event_non_persona_kind_passthrough() { + fn is_unshared_gated_event_ungated_kind_passthrough() { use nostr::{EventBuilder, Keys, Kind}; let keys = Keys::generate(); let ev = EventBuilder::new(Kind::Custom(KIND_TEAM as u16), "") .sign_with_keys(&keys) .unwrap(); let foreign = [0u8; 32]; - // Non-persona kinds are never blocked by this gate. - assert!(!is_unshared_persona_event(&ev, &foreign)); + // Kinds outside SHARED_GATED_KINDS are never blocked by this gate. + assert!(!is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_foreign_no_tag() { + // The gate must cover 30178 identically to 30175 — an unshared team + // catalog projection is author-only. + let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"]]); + let foreign = [0u8; 32]; + assert!(is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_foreign_shared_tag() { + let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"], &["shared", "true"]]); + let foreign = [0u8; 32]; + assert!(!is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_author_always_allowed() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + let ev = EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), "") + .tags(vec![Tag::parse(["d", "team-1"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let author_bytes = keys.public_key().to_bytes(); + assert!(!is_unshared_gated_event(&ev, &author_bytes)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_malformed_shared_tag_fails_closed() { + // A three-element `shared` tag can never be stored (ingest rejects it), + // but the read gate must independently treat it as NOT shared. + let ev = make_event_of_kind( + KIND_TEAM_CATALOG, + &[&["d", "team-1"], &["shared", "true", "extra"]], + ); + let foreign = [0u8; 32]; + assert!(is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn shared_gated_kinds_membership() { + assert!(is_shared_gated_kind(KIND_PERSONA)); + assert!(is_shared_gated_kind(KIND_TEAM_CATALOG)); + // 30176 has owner-private semantics, not catalog opt-in semantics: its + // writers never emit `shared`, so gating it here would hide every team + // from its own delegated readers. + assert!(!is_shared_gated_kind(KIND_TEAM)); + assert!(!is_shared_gated_kind(KIND_MANAGED_AGENT)); } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 0e54196d11..c0550e7e22 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, - KIND_HUDDLE_STARTED, + KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; @@ -71,13 +71,15 @@ pub struct EventQuery { /// which needs to fetch all matching events for post-filter counting. /// When None, the default clamp of 1000 applies. pub max_limit: Option, - /// Persona visibility reader: when set, append an SQL visibility clause - /// for kind 30175 before ORDER/LIMIT so private personas are excluded from - /// the candidate page rather than discarded after it. + /// Shared-gated visibility reader: when set, append an SQL visibility + /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so + /// private events are excluded from the candidate page rather than + /// discarded after it. /// - /// The clause is: `AND (kind != 30175 OR pubkey = $reader OR tags @> ?)`, - /// where `?` is the JSONB literal `[["shared","true"]]`. The GIN index on - /// `tags` (migration 0004, jsonb_path_ops) makes the containment check fast. + /// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR tags @> ?)`, + /// where the `IN` list is [`SHARED_GATED_KINDS`] and `?` is the JSONB + /// literal `[["shared","true"]]`. The GIN index on `tags` (migration 0004, + /// jsonb_path_ops) makes the containment check fast. /// /// NOTE: `tags @> '[["shared","true"]]'` uses JSONB containment, which /// matches any tag array that is a superset of `[["shared","true"]]` — it @@ -85,7 +87,7 @@ pub struct EventQuery { /// 2` exact-shape check ensures such malformed tags are never stored, so the /// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter /// defense-in-depth catches any residual mismatch. - pub persona_reader: Option>, + pub shared_gated_reader: Option>, } impl EventQuery { @@ -114,7 +116,7 @@ impl EventQuery { e_tags: None, channel_ids: None, max_limit: None, - persona_reader: None, + shared_gated_reader: None, } } } @@ -512,25 +514,28 @@ pub(crate) async fn query_events_on( } } - // Persona visibility pushdown: exclude kind 30175 events that are neither - // authored by the reader nor explicitly shared. Applied BEFORE ORDER/LIMIT - // so that a page of newer private personas does not push visible shared ones - // off the end of the result set (the catalog query pattern). + // Shared-gated visibility pushdown: exclude SHARED_GATED_KINDS events that + // are neither authored by the reader nor explicitly shared. Applied BEFORE + // ORDER/LIMIT so that a page of newer private events does not push visible + // shared ones off the end of the result set (the catalog query pattern). // - // Clause: AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]') + // Clause: AND (kind NOT IN (30175, 30178) OR pubkey = $reader + // OR tags @> '[["shared","true"]]') // // The JSONB containment check is served by idx_events_tags_gin (migration // 0004, jsonb_path_ops). `tags @> '[["shared","true"]]'` matches any array // that contains exactly the sub-array — a two-element `["shared","true"]` - // tag passes; a tag-absent event does not. Because ingest now requires - // exactly two elements for the shared tag (parts.len() == 2), no stored - // event can carry a three-element superset. - if let Some(ref reader_bytes) = q.persona_reader { - let kind_30175: i32 = 30175; + // tag passes; a tag-absent event does not. Because ingest requires exactly + // two elements for the shared tag (parts.len() == 2), no stored event can + // carry a three-element superset. + if let Some(ref reader_bytes) = q.shared_gated_reader { let shared_containment = serde_json::json!([["shared", "true"]]); - qb.push(format!(" AND ({col_prefix}kind != ")); - qb.push_bind(kind_30175); - qb.push(format!(" OR {col_prefix}pubkey = ")); + qb.push(format!(" AND ({col_prefix}kind NOT IN (")); + let mut sep = qb.separated(", "); + for kind in SHARED_GATED_KINDS { + sep.push_bind(*kind as i32); + } + qb.push(format!(") OR {col_prefix}pubkey = ")); qb.push_bind(reader_bytes.clone()); qb.push(format!(" OR {col_prefix}tags @> ")); qb.push_bind(shared_containment); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 10461d8d46..678199e734 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1236,10 +1236,10 @@ async fn query_events_authed( extract_channel_from_filter(filter), &accessible_channels, ); - // Persona visibility pushdown: must mirror WS REQ so that a page of newer - // private personas does not starve older shared ones off the candidate page. - if crate::handlers::req::filter_can_match_persona_shared_kinds(filter) { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: must mirror WS REQ so that a page of + // newer private events does not starve older shared ones off the page. + if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } match extract_before_id(raw) { @@ -1453,11 +1453,11 @@ async fn count_events_authed( filter, &authed_pubkey_hex, ); - // Force per-event fallback for filters that can match kind:30175 — - // the fast SQL count_events() path has no per-event gate and would - // over-count foreign unshared persona events (existence leak). - let needs_persona_filtering = - crate::handlers::req::filter_can_match_persona_shared_kinds(filter); + // Force per-event fallback for filters that can match a shared-gated + // kind — the fast SQL count_events() path has no per-event gate and + // would over-count foreign unshared events (existence leak). + let needs_shared_gate_filtering = + crate::handlers::req::filter_can_match_shared_gated_kinds(filter); // If filter targets a specific channel, verify access. if let Some(ch_id) = extract_channel_from_filter(filter) { @@ -1472,10 +1472,10 @@ async fn count_events_authed( tenant.community(), ) .await; - // Persona visibility pushdown: same as REQ and /query paths, so the - // fallback's query_events call doesn't over-fetch private persona rows. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: same as REQ and /query paths, so + // the fallback's query_events call doesn't over-fetch private rows. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -1486,7 +1486,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, @@ -1541,10 +1541,10 @@ async fn count_events_authed( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); - // Persona visibility pushdown: pre-filter before ORDER/LIMIT on the - // fallback query_events path. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: pre-filter before ORDER/LIMIT on + // the fallback query_events path. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { @@ -1556,7 +1556,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { query.limit = None; match state.db.count_events_routed("bridge_count", &query).await { diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index dfb44e152f..3eeab5e807 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -7,8 +7,8 @@ use tracing::warn; use crate::connection::{AuthState, ConnectionState}; use crate::handlers::req::{ - event_visible_to_reader, filter_can_match_persona_shared_kinds, - filter_can_match_result_gated_kinds, result_gated_count_safe_for_pushdown, + event_visible_to_reader, filter_can_match_result_gated_kinds, + filter_can_match_shared_gated_kinds, result_gated_count_safe_for_pushdown, }; use crate::protocol::RelayMessage; use crate::state::AppState; @@ -103,11 +103,11 @@ pub async fn handle_count( // fast-path count_events() cannot be used because it doesn't do // per-event author filtering. let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter); - // Determine if this filter can match kind 30175 (persona) — if so, the - // fast-path must be bypassed because it has no per-event shared-tag check. - // A fast count over 30175 would include foreign unshared persona events, - // leaking the existence of private agent activity. - let needs_persona_filtering = filter_can_match_persona_shared_kinds(filter); + // Determine if this filter can match a shared-gated kind (30175, 30178) + // — if so, the fast path must be bypassed because it has no per-event + // shared-tag check. A fast count over those kinds would include foreign + // unshared events, leaking the existence of private agent activity. + let needs_shared_gate_filtering = filter_can_match_shared_gated_kinds(filter); // Determine if this filter can match result-gated kinds (44200, 30622) // that require a per-event owner check. When the fast SQL path would // count matching rows without calling reader_authorized_for_event, a @@ -157,10 +157,10 @@ pub async fn handle_count( conn.tenant.community(), ) .await; - // Persona visibility pushdown: pre-filter the fallback query_events - // candidate page before ORDER/LIMIT. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: pre-filter the fallback + // query_events candidate page before ORDER/LIMIT. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -171,7 +171,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, @@ -230,9 +230,9 @@ pub async fn handle_count( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); - // Persona visibility pushdown for the fallback query_events path. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown for the fallback query_events path. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { @@ -244,7 +244,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { query.limit = None; // COUNT doesn't need a row limit match state.db.count_events_routed("count_req", &query).await { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 88dd5f5180..a9cdffcdec 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -7,7 +7,7 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ - event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS, + event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, }; use buzz_core::observer::{ @@ -151,10 +151,10 @@ pub async fn filter_fanout_by_access( matches }; - // Persona shared-read gate (fan-out): kind 30175 events fan out to all - // connections only when carrying ["shared","true"]. Unshared personas - // are delivered only to the author's own connections, matching REQ semantics. - let matches = if buzz_core::kind::is_persona_shared_kind(event_kind_u32(&stored_event.event)) { + // Shared-read gate (fan-out): SHARED_GATED_KINDS events fan out to all + // connections only when carrying ["shared","true"]. Unshared ones are + // delivered only to the author's own connections, matching REQ semantics. + let matches = if buzz_core::kind::is_shared_gated_kind(event_kind_u32(&stored_event.event)) { let author = stored_event.event.pubkey.to_bytes(); matches .into_iter() @@ -167,7 +167,7 @@ pub async fn filter_fanout_by_access( return true; } // Foreign connection: allowed only if the event is shared. - !is_unshared_persona_event(&stored_event.event, &pk) + !is_unshared_gated_event(&stored_event.event, &pk) }) .collect() } else { diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee644d5a9b..39ecbe18e4 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -31,9 +31,9 @@ use buzz_core::kind::{ KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, - KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, - RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, + KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, + RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -214,7 +214,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT - | super::push_lease::KIND_PUSH_LEASE => { + | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). @@ -419,10 +419,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_PROFILE // NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag). | KIND_PERSONA - // NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored, - // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. + // NIP-AP: team (30176) + managed-agent (30177) definitions and the + // team-catalog projection (30178): owner-authored, keyed by + // (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT @@ -1029,37 +1031,27 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> { Ok(()) } -/// Validate the envelope of a kind:30175 persona event. -/// -/// Enforces: -/// * exactly one `d` tag with a non-empty value matching the slug grammar -/// `^[a-z0-9][a-z0-9_-]{0,63}$`. -/// * at most one `shared` tag; if present, its value must be exactly `"true"`. +/// Enforce the `shared`-tag shape shared by every kind in +/// [`buzz_core::kind::SHARED_GATED_KINDS`]: at most one `shared` tag, and if +/// present it must be exactly `["shared", "true"]`. /// -/// Without the `d`-tag check, an empty d-tag collapses every persona into the -/// `(pubkey, 30175, "")` slot — last-write-wins data loss. +/// This ensures no ambiguous heads: either an event has no `shared` tag +/// (author-only) or exactly `["shared", "true"]` (community-readable). Any +/// other value (`"false"`, `"1"`, extra elements, duplicate tags) is rejected +/// at ingest so read-path helpers — including the SQL-level `tags @> +/// '[["shared","true"]]'` containment clause, which would otherwise match a +/// three-element superset — can treat stored events as unambiguously one or the +/// other. /// -/// The `shared` tag rule ensures no ambiguous heads: either an event has no -/// `shared` tag (author-only) or exactly `["shared", "true"]` (community- -/// readable). Any other value (`"false"`, `"1"`, extra tags) is rejected at -/// ingest so read-path helpers can treat stored events as unambiguously one or -/// the other. -fn validate_persona_envelope(event: &Event) -> Result<(), String> { - let mut d_tags: Vec<&str> = Vec::new(); +/// `label` names the kind in error messages (e.g. `"persona event"`). +fn validate_shared_tag(event: &Event, label: &str) -> Result<(), String> { let mut shared_count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); - if parts.len() >= 2 && parts[0].as_str() == "d" { - d_tags.push(&parts[1]); - } if !parts.is_empty() && parts[0].as_str() == "shared" { - // Exact shape required: ["shared", "true"] — exactly two elements, - // second element exactly "true". Extra elements are rejected so that - // a three-element tag like ["shared","true","extra"] cannot be stored - // and later misread as shared by the SQL-level visibility clause. if parts.len() != 2 || parts[1].as_str() != "true" { return Err(format!( - "persona event `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})", + "{label} `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})", parts.iter().map(|s| s.as_str()).collect::>() )); } @@ -1068,43 +1060,106 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> { } if shared_count > 1 { return Err(format!( - "persona event must have at most one `shared` tag (got {shared_count})" + "{label} must have at most one `shared` tag (got {shared_count})" )); } + Ok(()) +} + +/// Return the event's single `d` tag value, requiring exactly one tag whose +/// value is non-empty, at most 64 characters, and free of Unicode control +/// characters and whitespace. +/// +/// Without this check an empty `d` tag collapses every event of the kind into +/// the `(pubkey, kind, "")` slot — last-write-wins data loss. The character +/// bound keeps the value usable as a NIP-33 coordinate (`::`) +/// and as a log field: an embedded newline or tab would break line-oriented +/// consumers of both. +/// +/// Tags are counted by their first element alone, so a valueless `["d"]` +/// counts. Skipping it would let `["d"]` plus `["d", "team-1"]` pass the +/// exactly-one rule, and a NIP-33 consumer that reads `["d"]` as an +/// empty-valued first `d` tag would then address the event at `""` where this +/// relay addresses it at `"team-1"`. +/// +/// `label` names the kind in error messages (e.g. `"persona event"`). +fn single_bounded_d_tag<'a>(event: &'a Event, label: &str) -> Result<&'a str, String> { + let d_tags: Vec> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(|name| name.as_str()) == Some("d")) + .then(|| parts.get(1).map(|value| value.as_str())) + }) + .collect(); if d_tags.len() != 1 { return Err(format!( - "persona event must have exactly one `d` tag (got {})", + "{label} must have exactly one `d` tag (got {})", d_tags.len() )); } - let d = d_tags[0]; + let d = d_tags[0].unwrap_or_default(); if d.is_empty() { - return Err("persona event `d` tag must not be empty".to_string()); + return Err(format!("{label} `d` tag must not be empty")); } - // Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$ - if d.len() > 64 { + let char_count = d.chars().count(); + if char_count > 64 { return Err(format!( - "persona event `d` tag too long ({} chars, max 64)", - d.len() + "{label} `d` tag too long ({char_count} chars, max 64)" )); } + if d.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err(format!( + "{label} `d` tag must not contain control characters or whitespace" + )); + } + Ok(d) +} + +/// Validate the envelope of a kind:30175 persona event. +/// +/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus +/// exactly one `d` tag matching the persona slug grammar +/// `^[a-z0-9][a-z0-9_-]{0,63}$`. +fn validate_persona_envelope(event: &Event) -> Result<(), String> { + const LABEL: &str = "persona event"; + validate_shared_tag(event, LABEL)?; + let d = single_bounded_d_tag(event, LABEL)?; + // Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$ let bytes = d.as_bytes(); if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { - return Err( - "persona event `d` tag must start with a lowercase letter or digit".to_string(), - ); + return Err(format!( + "{LABEL} `d` tag must start with a lowercase letter or digit" + )); } if !bytes[1..] .iter() .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') { - return Err( - "persona event `d` tag must match [a-z0-9_-] after the first character".to_string(), - ); + return Err(format!( + "{LABEL} `d` tag must match [a-z0-9_-] after the first character" + )); } Ok(()) } +/// Validate the envelope of a kind:30178 team-catalog event. +/// +/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus +/// exactly one non-empty, bounded `d` tag. +/// +/// Deliberately NOT the persona slug grammar: a team's `d` tag is its stable +/// local id, which is either a UUID or a built-in identifier such as +/// `builtin-team:welcome` — the colon is not slug-legal, and rewriting ids to +/// fit would break NIP-33 addressing against the team's own kind:30176 head. +fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> { + const LABEL: &str = "team-catalog event"; + validate_shared_tag(event, LABEL)?; + single_bounded_d_tag(event, LABEL)?; + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2070,6 +2125,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_TEAM_CATALOG { + validate_team_catalog_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -3597,6 +3657,24 @@ mod tests { assert!(err.contains("`d` tag"), "got: {err}"); } + #[test] + fn persona_envelope_rejects_valueless_d_tag() { + // A lone ["d"] carries no value; it must fail as a missing value, not + // be skipped as though the event had no `d` tag at all. + let ev = make_persona(&[&["d"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn persona_envelope_rejects_valueless_plus_valued_d_tags() { + // Counting only tags with a value would see one `d` here and accept the + // event, breaking the exactly-one rule. + let ev = make_persona(&[&["d"], &["d", "slug-a"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + #[test] fn persona_envelope_rejects_too_long() { let slug = "a".repeat(65); @@ -3723,6 +3801,151 @@ mod tests { ); } + // ─── team-catalog (30178) envelope tests ───────────────────────────────── + + fn make_team_catalog(tags: &[&[&str]]) -> Event { + make_event_with_tags( + KIND_TEAM_CATALOG, + r#"{"v":1,"name":"Team","members":[]}"#, + tags, + ) + } + + #[test] + fn team_catalog_envelope_accepts_uuid_d_tag() { + let ev = make_team_catalog(&[&["d", "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_accepts_builtin_colon_d_tag() { + // Built-in team ids carry a colon (`builtin-team:welcome`), which the + // persona slug grammar forbids. The catalog `d` tag must accept them so + // a built-in team can be shared under its real local id. + let ev = make_team_catalog(&[&["d", "builtin-team:welcome"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_accepts_shared_true() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_missing_d_tag() { + let ev = make_team_catalog(&[]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_empty_d_tag() { + // An empty d-tag collapses every team into the (pubkey, 30178, "") slot. + let ev = make_team_catalog(&[&["d", ""]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_duplicate_d_tags() { + let ev = make_team_catalog(&[&["d", "team-1"], &["d", "team-2"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_valueless_d_tag() { + // A lone ["d"] carries no value; it must fail as a missing value, not + // be skipped as though the event had no `d` tag at all. + let ev = make_team_catalog(&[&["d"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_valueless_plus_valued_d_tags() { + // Counting only tags with a value would see one `d` here and accept the + // event. A NIP-33 consumer that reads ["d"] as an empty-valued first + // `d` tag would then address this event at "" where we address it at + // "team-1". + let ev = make_team_catalog(&[&["d"], &["d", "team-1"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_bounds_d_tag_by_chars_not_bytes() { + // 64 multi-byte characters is 192 bytes; the documented bound is + // characters, so this must be accepted. + let d = "é".repeat(64); + assert!(d.len() > 64, "fixture must exceed the bound in bytes"); + let ev = make_team_catalog(&[&["d", &d]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_too_long_d_tag() { + let d = "a".repeat(65); + let ev = make_team_catalog(&[&["d", &d]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("too long"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_accepts_max_length_d_tag() { + let d = "a".repeat(64); + let ev = make_team_catalog(&[&["d", &d]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_whitespace_d_tag() { + // A newline in the d-tag would break the NIP-33 coordinate and any + // line-oriented log consumer. + let ev = make_team_catalog(&[&["d", "team\n1"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("control characters"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_shared_false() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "false"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("\"true\""), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_shared_three_elements() { + // Same exact-shape rule as personas: a three-element tag would match the + // SQL containment clause `tags @> '[["shared","true"]]'` as a superset. + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true", "extra"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("[\"shared\",\"true\"]"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_duplicate_shared_tags() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"], &["shared", "true"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("at most one"), "got: {err}"); + } + + #[test] + fn team_catalog_is_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_TEAM_CATALOG, &dummy).unwrap(), + Scope::UsersWrite, + ); + } + + #[test] + fn team_catalog_is_global_only() { + assert!(is_global_only_kind(KIND_TEAM_CATALOG)); + assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG)); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 51400452d7..35fbf0c892 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,8 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - is_unshared_persona_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, KIND_PERSONA, P_GATED_KINDS, RESULT_GATED_KINDS, + is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, + KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -290,11 +290,11 @@ pub async fn handle_req( let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); - // Persona visibility pushdown: set reader bytes so query_events appends - // the SQL visibility clause before ORDER/LIMIT, preventing newer private - // personas from starving older shared ones off the page. - if filter_can_match_persona_shared_kinds(filter) { - params.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: set reader bytes so query_events + // appends the SQL visibility clause before ORDER/LIMIT, preventing + // newer private events from starving older shared ones off the page. + if filter_can_match_shared_gated_kinds(filter) { + params.shared_gated_reader = Some(pubkey_bytes.clone()); } (idx, per_filter_channel, params) }) @@ -1137,19 +1137,20 @@ pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { }) } -/// Returns `true` if the filter CAN match kind 30175 (persona) — meaning it -/// either has no `kinds` constraint (wildcard) or explicitly includes 30175. +/// Returns `true` if the filter CAN match any kind in [`SHARED_GATED_KINDS`] — +/// meaning it either has no `kinds` constraint (wildcard) or explicitly includes +/// one of them. /// /// Used by the COUNT handler to force the per-event fallback path, which calls -/// `is_unshared_persona_event` on each row. The fast SQL `count_events()` path +/// `is_unshared_gated_event` on each row. The fast SQL `count_events()` path /// has no per-event access check, so it would over-count foreign unshared -/// persona events — leaking the existence of persona activity even without -/// returning content. -pub(crate) fn filter_can_match_persona_shared_kinds(filter: &Filter) -> bool { - filter - .kinds - .as_ref() - .is_none_or(|ks| ks.iter().any(|k| k.as_u16() as u32 == KIND_PERSONA)) +/// events — leaking the existence of private persona/team-catalog activity even +/// without returning content. +pub(crate) fn filter_can_match_shared_gated_kinds(filter: &Filter) -> bool { + filter.kinds.as_ref().is_none_or(|ks| { + ks.iter() + .any(|k| SHARED_GATED_KINDS.contains(&(k.as_u16() as u32))) + }) } /// Returns `true` if the filter CAN match result-gated kinds — meaning it @@ -1208,8 +1209,9 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: /// /// 1. **Author-only kinds** (`AUTHOR_ONLY_KINDS`, e.g. kind 30300/30350): only /// the author may read their own events. -/// 2. **Persona shared-gate** (kind 30175 without `["shared","true"]`): the -/// event is only visible to the author unless explicitly opted into sharing. +/// 2. **Shared-gate** (`SHARED_GATED_KINDS`, e.g. kind 30175/30178 without +/// `["shared","true"]`): the event is only visible to the author unless +/// explicitly opted into sharing. /// 3. **Result-gated kinds** (kind 44200/30622 etc.): `reader_authorized_for_event` /// carries the per-event ownership check. /// @@ -1223,7 +1225,7 @@ pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_byt if is_author_only_event(event, requester_pubkey_bytes) { return false; } - if is_unshared_persona_event(event, requester_pubkey_bytes) { + if is_unshared_gated_event(event, requester_pubkey_bytes) { return false; } let requester_pubkey_hex = hex::encode(requester_pubkey_bytes); diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index b3b1f7f6b2..4f37e22e16 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -1324,7 +1324,7 @@ async fn test_persona_http_query_cross_author_gate() { /// /// A foreign authenticated caller counting `{kinds:[30175],authors:[victim]}` /// must count only shared heads — not unshared ones — on both the fast SQL -/// path (prevented by `needs_persona_filtering`) and the fallback path. +/// path (prevented by `needs_shared_gate_filtering`) and the fallback path. #[tokio::test] #[ignore] async fn test_persona_http_count_cross_author_gate() { @@ -1403,7 +1403,7 @@ async fn test_persona_http_count_cross_author_gate() { /// event is returned. /// /// Verifies at `312014d5e`: this test fails there because `query_events` did -/// not have the `persona_reader` SQL clause and the private rows starved the +/// not have the `shared_gated_reader` SQL clause and the private rows starved the /// shared one off the page. #[tokio::test] #[ignore] diff --git a/crates/buzz-test-client/tests/e2e_team_catalog.rs b/crates/buzz-test-client/tests/e2e_team_catalog.rs new file mode 100644 index 0000000000..ce313d1fe9 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_team_catalog.rs @@ -0,0 +1,484 @@ +//! End-to-end tests for kind:30178 team-catalog events (NIP-AP). +//! +//! Kind 30178 is the shareable projection of a team. It joins kind:30175 in +//! `SHARED_GATED_KINDS`, so these tests assert the wire behaviour of that gate +//! at every read chokepoint (REQ, `ids` lookup, COUNT, live fan-out) plus the +//! ingest envelope rules that make the gate sound: +//! - Exactly one non-empty, bounded `d` tag — the team's stable local id, which +//! may contain a colon (`builtin-team:welcome`) unlike a persona slug. +//! - `shared`, if present, is exactly `["shared", "true"]`. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_team_catalog -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::{BuzzTestClient, RelayMessage}; +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; + +const TEAM_CATALOG_KIND: u16 = 30178; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-team-catalog-{name}-{}", uuid::Uuid::new_v4()) +} + +fn catalog_content(name: &str) -> String { + serde_json::json!({ "v": 1, "name": name, "members": [] }).to_string() +} + +/// Build a kind:30178 event, optionally carrying the `["shared","true"]` opt-in. +fn catalog_event(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event { + catalog_event_at(keys, d_tag, shared, Timestamp::now().as_secs()) +} + +/// Same as [`catalog_event`] with an explicit `created_at`, so NIP-33 head +/// ordering is deterministic instead of resolved by event-id tie-break. +fn catalog_event_at(keys: &Keys, d_tag: &str, shared: bool, created_at: u64) -> nostr::Event { + let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Test Team"), + ) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() +} + +fn author_filter(author: &Keys) -> Filter { + Filter::new() + .kind(Kind::Custom(TEAM_CATALOG_KIND)) + .author(author.public_key()) +} + +fn coordinate_filter(author: &Keys, d_tag: &str) -> Filter { + author_filter(author).custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]) +} + +fn d_tag_of(event: &nostr::Event) -> Option<&str> { + event.tags.iter().find_map(|t| { + let parts = t.as_slice(); + if parts.first().map(|p| p.as_str()) != Some("d") { + return None; + } + Some(parts.get(1)?.as_str()) + }) +} + +/// The author's own unshared projection round-trips at its NIP-33 coordinate. +/// +/// The `d` tag is a UUID, matching the desktop team id — proof the envelope does +/// NOT apply the persona slug grammar. +#[tokio::test] +#[ignore] +async fn test_team_catalog_publish_and_query_own_unshared() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let event = catalog_event(&keys, &d_tag, false); + let event_id = event.id; + let ok = client.send_event(event).await.expect("send catalog"); + assert!(ok.accepted, "relay rejected catalog event: {}", ok.message); + + let sid = sub_id("own-unshared"); + client + .subscribe(&sid, vec![coordinate_filter(&keys, &d_tag)]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!(events.len(), 1, "author must see own unshared projection"); + assert_eq!(events[0].id, event_id); + + client.disconnect().await.expect("disconnect"); +} + +/// A built-in team id (`builtin-team:welcome`) is accepted as the `d` tag. +/// +/// The colon is illegal in a persona slug; rewriting the id to fit would break +/// NIP-33 addressing against the team's own kind:30176 head. +#[tokio::test] +#[ignore] +async fn test_team_catalog_accepts_builtin_colon_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = format!("builtin-team:{}", &uuid::Uuid::new_v4().to_string()[..8]); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client + .send_event(catalog_event(&keys, &d_tag, true)) + .await + .expect("send catalog"); + assert!( + ok.accepted, + "relay rejected colon-bearing team id: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses an empty `d` tag: generic NIP-33 storage maps it to the empty +/// coordinate, collapsing every team into one `(pubkey, 30178, "")` slot. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_empty_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client + .send_event(catalog_event(&keys, "", false)) + .await + .expect("send catalog"); + assert!(!ok.accepted, "empty d-tag must be rejected"); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses a valueless `["d"]` tag alongside a valued one. Counting only +/// tags that carry a value would see exactly one `d` here and accept the event; +/// a NIP-33 consumer that reads `["d"]` as an empty-valued first `d` tag would +/// then address the event at `""` where this relay addresses it at the team id. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_valueless_plus_valued_d_tags() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let event = EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Two d tags"), + ) + .tags(vec![ + Tag::parse(["d"]).unwrap(), + Tag::parse(["d", d_tag.as_str()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client.send_event(event).await.expect("send catalog"); + assert!( + !ok.accepted, + "a valueless `d` tag must count toward the exactly-one rule" + ); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses a malformed `shared` tag. A three-element tag would satisfy +/// the SQL containment clause `tags @> '[["shared","true"]]'` as a superset +/// while the in-process gate reads it as unshared — the two layers must agree, +/// so such an event can never be stored. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_three_element_shared_tag() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let event = EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Malformed"), + ) + .tags(vec![ + Tag::parse(["d", d_tag.as_str()]).unwrap(), + Tag::parse(["shared", "true", "extra"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client.send_event(event).await.expect("send catalog"); + assert!(!ok.accepted, "three-element shared tag must be rejected"); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// REQ historical delivery: a foreign reader receives only shared projections, +/// while the author receives both of their own. +#[tokio::test] +#[ignore] +async fn test_team_catalog_foreign_sees_only_shared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_unshared = format!("priv-{}", uuid::Uuid::new_v4()); + let d_shared = format!("pub-{}", uuid::Uuid::new_v4()); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let shared_event = catalog_event(&author_keys, &d_shared, true); + let shared_id = shared_event.id; + let ok = author + .send_event(catalog_event(&author_keys, &d_unshared, false)) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared ingest rejected: {}", ok.message); + let ok = author.send_event(shared_event).await.expect("send shared"); + assert!(ok.accepted, "shared ingest rejected: {}", ok.message); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fg-all"); + foreign + .subscribe(&sid, vec![author_filter(&author_keys)]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + !events + .iter() + .any(|e| d_tag_of(e) == Some(d_unshared.as_str())), + "foreign reader must NOT see the unshared projection" + ); + assert!( + events.iter().any(|e| e.id == shared_id), + "foreign reader must see the shared projection" + ); + + let sid_author = sub_id("auth-all"); + author + .subscribe(&sid_author, vec![author_filter(&author_keys)]) + .await + .expect("subscribe author"); + let author_events = author + .collect_until_eose(&sid_author, Duration::from_secs(5)) + .await + .expect("collect author"); + assert!( + author_events.len() >= 2, + "author must see both own projections, got {}", + author_events.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// Knowing an event id does NOT grant access: `{ids:[unshared]}` returns nothing +/// to a foreign reader. +#[tokio::test] +#[ignore] +async fn test_team_catalog_ids_lookup_unshared_returns_nothing_to_foreign() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let event = catalog_event(&author_keys, &uuid::Uuid::new_v4().to_string(), false); + let event_id = event.id; + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author.send_event(event).await.expect("send"); + assert!(ok.accepted, "ingest rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("ids-unshared"); + foreign + .subscribe(&sid, vec![Filter::new().id(event_id)]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.is_empty(), + "ids-lookup of an unshared projection must return nothing, got {:?}", + events.iter().map(|e| e.id).collect::>() + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// COUNT must take the per-event fallback for kind:30178 so the aggregate does +/// not leak the existence of unshared projections. +#[tokio::test] +#[ignore] +async fn test_team_catalog_count_excludes_foreign_unshared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author + .send_event(catalog_event( + &author_keys, + &uuid::Uuid::new_v4().to_string(), + false, + )) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + let ok = author + .send_event(catalog_event( + &author_keys, + &uuid::Uuid::new_v4().to_string(), + true, + )) + .await + .expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("count"); + let count_msg = serde_json::json!(["COUNT", sid, author_filter(&author_keys)]); + foreign.send_raw(&count_msg).await.expect("send COUNT"); + + let count = match foreign.recv_event(Duration::from_secs(5)).await { + Ok(RelayMessage::Count { count, .. }) => count, + Ok(RelayMessage::Closed { message, .. }) => panic!("COUNT closed unexpectedly: {message}"), + Ok(other) => panic!("unexpected relay message for COUNT: {other:?}"), + Err(e) => panic!("unexpected error for COUNT: {e}"), + }; + assert_eq!( + count, 1, + "foreign COUNT must see only the shared projection, got {count}" + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// Live fan-out honours the gate, and unsharing (a NIP-33 replacement that drops +/// the `shared` tag) retracts the projection from foreign readers. +#[tokio::test] +#[ignore] +async fn test_team_catalog_live_fanout_and_unshare_retracts() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let (t0, t1, t2) = (now.saturating_sub(2), now.saturating_sub(1), now); + + // Subscribe BEFORE publishing, scoped to this author so parallel tests + // publishing their own 30178s cannot trip the leak assertion. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fanout"); + foreign + .subscribe(&sid, vec![author_filter(&author_keys)]) + .await + .expect("subscribe"); + let _ = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("drain eose"); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + + // Unshared publish must NOT reach the foreign connection. + let ok = author + .send_event(catalog_event_at(&author_keys, &d_tag, false, t0)) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + match foreign.recv_event(Duration::from_millis(750)).await { + Err(buzz_test_client::TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(TEAM_CATALOG_KIND) => { + panic!("unshared projection leaked to foreign live subscription"); + } + Ok(_) => {} + Err(e) => panic!("unexpected error awaiting fan-out: {e}"), + } + + // Shared replacement MUST reach it. + let shared_event = catalog_event_at(&author_keys, &d_tag, true, t1); + let shared_id = shared_event.id; + let ok = author.send_event(shared_event).await.expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + let delivered = loop { + match foreign.recv_event(Duration::from_secs(5)).await { + Ok(RelayMessage::Event { event, .. }) if event.id == shared_id => break true, + Ok(_) => continue, + Err(buzz_test_client::TestClientError::Timeout) => break false, + Err(e) => panic!("unexpected error awaiting shared fan-out: {e}"), + } + }; + assert!( + delivered, + "shared projection must fan out to foreign readers" + ); + + // Unshare: replace at the same coordinate without the tag. Subsequent + // foreign REQs must return nothing. + let ok = author + .send_event(catalog_event_at(&author_keys, &d_tag, false, t2)) + .await + .expect("send unshare"); + assert!(ok.accepted, "unshare rejected: {}", ok.message); + + let sid_post = sub_id("post-unshare"); + foreign + .subscribe(&sid_post, vec![coordinate_filter(&author_keys, &d_tag)]) + .await + .expect("subscribe post"); + let after = foreign + .collect_until_eose(&sid_post, Duration::from_secs(5)) + .await + .expect("collect post"); + assert!( + after.is_empty(), + "unsharing must retract the projection from foreign readers, got {} event(s)", + after.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index a4003329bc..cab5fababc 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -75,11 +75,11 @@ pub(super) fn prepare_persona_publication( } fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool { - use buzz_core_pkg::kind::persona_event_is_shared; + use buzz_core_pkg::kind::event_is_shared; use nostr::JsonUtil; row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok()) - .is_some_and(|event| persona_event_is_shared(&event)) + .is_some_and(|event| event_is_shared(&event)) } /// Project each persona's catalog visibility from the active relay+owner diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index d9fe6acdb9..ee8e0d8b10 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -165,7 +165,7 @@ fn migrate_personas_in_dir_at( scoped_record.shared = existing .as_ref() .and_then(|row| nostr::Event::from_json(&row.raw_event).ok()) - .is_some_and(|event| buzz_core_pkg::kind::persona_event_is_shared(&event)); + .is_some_and(|event| buzz_core_pkg::kind::event_is_shared(&event)); let event = build_persona_event(&scoped_record) .map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))? .custom_created_at(monotonic_created_at( diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index ea61a811db..6afc18a501 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; -use buzz_core_pkg::kind::{persona_event_is_shared, KIND_PERSONA}; +use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; @@ -192,7 +192,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result:`. Unsharing is distinct from deletion — it is a newer valid head at the same coordinate published *without* the `shared` tag, which keeps the projection readable to its author while retracting it from foreign readers. + ## Relationships to other NIPs ### NIP-AE (Agent Engrams) @@ -216,6 +218,29 @@ surface per-event errors. Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an `auth` tag proving that `pubkey_o` authorized the agent's key. The persona event itself does not contain attestation; it is the *definition* from which attestation is issued at spawn time. +## Team catalog projection: kind:30178 + +Kind `30178` is the **shareable projection of a team**: owner-authored, parameterized replaceable, addressed by `(pubkey_o, 30178, d)` where `d` is the team's stable local id. Its `content` is a versioned JSON body carrying sanitized team fields plus ordered, *embedded* member definition projections. The content schema is defined by the client that publishes it; this section specifies only the envelope and the relay's contract. + +```jsonc +{ + "kind": 30178, + "pubkey": "", + "created_at": , + "tags": [ + ["d", ""], + ["shared", "true"] // optional; presence opts the projection into community reads + ], + "content": "" +} +``` + +**Why a separate kind rather than a `shared` tag on the team event (kind:30176).** A team's members are `kind:30175` definitions, which are author-only unless individually shared — so a foreign reader of a shared team could never hydrate its members. Kind `30178` embeds the member projections instead of referencing them: the share is atomic, it covers built-in members that have no `30175` head at all, it is immune to local-id/`d`-tag divergence, and an unshared `30175` stays private. Kind `30176`'s wire body is untouched, so device sync keeps its contract. + +**The `d` tag is a team id, not a persona slug.** It is either a UUID or a built-in identifier such as `builtin-team:welcome`. The colon is illegal under the persona slug grammar, and rewriting ids to fit would break NIP-33 addressing against the team's own `kind:30176` head — so the relay applies a laxer rule (see below) to `30178` than to `30175`. + +**Content carries only sanitized fields.** No environment variables, no `respond_to` allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. Sharing a team makes the team's and every member's instructions community-readable plaintext. + ## Relay behavior ### Ingest validation @@ -226,10 +251,20 @@ Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an - The relay MUST enforce that the `d` tag is non-empty (standard NIP-33 requirement for parameterized replaceable events). - The relay MUST enforce shared-tag shape: if a `shared` tag is present, it MUST consist of **exactly two elements** — `["shared", "true"]`. Extra elements (e.g. `["shared","true","extra"]`), wrong values (`["shared","false"]`), missing values (`["shared"]`), or duplicate `shared` tags are all rejected with `invalid:`. The two-element exact-shape constraint is required so that the relay's SQL visibility clause (`tags @> '[["shared","true"]]'`) never matches a stored malformed tag via JSONB containment supersets. +### Ingest validation: kind:30178 + +Kind `30178` is stored globally and its content is unvalidated, exactly as for `30175`. The envelope rules differ in one respect — the `d` grammar: + +- The relay MUST enforce the same `shared`-tag exact shape as `30175`, for the same reason: the read gate and the SQL containment clause must agree on every stored event. +- The relay MUST enforce **exactly one** `d` tag whose value is non-empty, at most 64 characters, and free of Unicode control characters and whitespace. Tags are counted by their first element, so a valueless `["d"]` counts toward the total and fails the value check on its own — otherwise `["d"]` alongside `["d",""]` would pass, and a consumer that reads `["d"]` as an empty-valued first `d` tag would address the event at `""` while this relay addresses it at ``. Without the non-empty check, generic NIP-33 storage maps a missing or empty `d` to the empty coordinate, collapsing every team into the single `(pubkey_o, 30178, "")` slot — last-write-wins data loss. The character bound keeps the value usable as a NIP-33 coordinate and as a log field. +- The relay MUST NOT apply the persona slug grammar to a `30178` `d` tag; team ids legitimately contain characters (notably `:`) that the slug grammar forbids. + ### Access control: author-only-unless-shared Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts and `respond_to_allowlist` from being visible to all community members as a side-effect of device sync. +The gate is kind-generic: the relay applies it to every kind in `SHARED_GATED_KINDS` (`buzz-core/src/kind.rs`), currently `30175` and the `30178` team-catalog projection described below. The rules and enforcement surfaces are identical for each member kind. + **Rules:** | Event state | Author reads | Foreign reads | @@ -239,13 +274,13 @@ Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts These rules are enforced at the following relay read surfaces (content and event existence are withheld on all of them): -- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). The visibility check is applied **before `ORDER BY … LIMIT`** at the SQL level (`persona_reader` field in `EventQuery`), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served. +- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). The visibility check is applied **before `ORDER BY … LIMIT`** at the SQL level (`shared_gated_reader` field in `EventQuery`), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served. - **NIP-01 `ids` lookup** — knowing an event id does NOT grant access to an unshared persona. The result gate returns nothing. - **Live fan-out** — unshared personas are delivered only to the author's connections. Shared personas fan out community-wide. -- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match `kind:30175`. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. -- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. The SQL-level `persona_reader` clause also applies before `LIMIT`, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content. -- **NIP-98 HTTP bridge `/count`** — `needs_persona_filtering` forces the per-event fallback path for any filter that can match `kind:30175`; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP. -- **FTS (NIP-50 search) and `/search`** — kind `30175` is not in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared persona. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass. +- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match a shared-gated kind. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. +- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. The SQL-level `shared_gated_reader` clause also applies before `LIMIT`, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content. +- **NIP-98 HTTP bridge `/count`** — `needs_shared_gate_filtering` forces the per-event fallback path for any filter that can match a shared-gated kind; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP. +- **FTS (NIP-50 search) and `/search`** — no shared-gated kind is in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared event. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass. **Device sync is unaffected.** The sync subscription (`{kinds:[30175], authors:[self]}`) reads the author's own events, which are always returned regardless of shared state. @@ -263,6 +298,7 @@ These rules are enforced at the following relay read surfaces (content and event - **Slug collision across pubkeys.** Two different owners can publish personas with the same slug. Clients MUST always scope queries by author pubkey, not just slug. - **Metadata exposure.** The `(pubkey, kind:30175, slug)` triple reveals persona existence. Event timestamps reveal edit history. - **No owner write authority over agents.** Persona events define *what* an agent should be; they do not grant runtime control over a running agent. The agent consumes the persona at spawn time. Updates to the persona event do not automatically propagate to running agents. +- **Sharing a team shares every member's instructions.** A `kind:30178` head carrying `["shared","true"]` exposes the team's own fields *and* the embedded projection of every member — including members whose own `kind:30175` heads are unshared and therefore still private. Clients MUST make this explicit at the point of sharing; the relay cannot infer it. ## Reference test vectors From 29dfe4821ed577489a1879fd2a9bfe2a621a52b3 Mon Sep 17 00:00:00 2001 From: Sumit Madan <33051892+sumit-m@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:30:22 +0530 Subject: [PATCH 79/99] fix(desktop): don't gate hover affordances on the hover media query (#3657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What problem this solves Tailwind v4 compiles every `hover:` variant inside `@media (hover: hover)`. Some Windows hosts answer that query `false` **even with a mouse attached**, and then every hover-revealed control in the app is permanently `visibility: hidden`. Measured in the app's own WebView2 devtools console, on a mouse-driven Windows 11 desktop: ```js matchMedia('(hover: hover)').matches // false matchMedia('(any-hover: hover)').matches // false matchMedia('(pointer: fine)').matches // false matchMedia('(any-pointer: fine)').matches // false navigator.maxTouchPoints // 10 ``` Windows itself, on the same machine at the same moment, reports a mouse present and an integrated digitizer: ``` GetSystemMetrics(SM_DIGITIZER) = 197 // INTEGRATED_TOUCH | INTEGRATED_PEN // | MULTI_INPUT | READY GetSystemMetrics(SM_MAXIMUMTOUCHES) = 10 SystemInformation.MousePresent = True ``` So this is not "the user has no mouse". Windows knows a mouse is attached, and Chromium still reports `any-pointer: fine: false` and `any-hover: false` — the `any-*` queries exist precisely to describe *any* available input device, and they are wrong here. The presence of an integrated touch digitizer collapses the reported capability to touch-only. The compiled rule that never applies: ```css .group-hover\/member\:visible { &:is(:where(.group\/member):hover *) { @media (hover: hover) { visibility: visible; } } } ``` The row genuinely matches `:hover` (verified: `row.matches(':hover') === true`), the button is in the DOM, the utility class is generated — and the declaration still never lands. ## Why this is more than one control Not a single menu. Confirmed newly-ungated in the production bundle after the change: | utility | media-gated before | after | |---|---|---| | `group-hover/member:visible` | yes | no | | `group-hover/inbox-item:opacity-100` | yes | no | | `group-hover/channel-row:opacity-100` | yes | no | | `group-hover/attachment:opacity-100` | yes | no | | `hover:bg-muted` | yes | no | On an affected host the channel-member action menu (remove member, change role, start/stop agent) has **no reachable affordance at all**: `visibility: hidden` also removes the button from tab order, so there is no keyboard path either. ## The fix One line, at the root, next to the existing variant override: ```css @custom-variant hover (&:hover); ``` This trusts the actual hover event rather than the capability query. Chromium only fires `:hover` when a real pointer is present, so behaviour on hosts that report the capability correctly is unchanged. Verified against a production `vite build`, not just the dev server — the override cascades to the *named* group variants (`group-hover/member`, etc.), which is the part that matters here. ## Prior art in this repo #2849 overrides Tailwind v4's `dark:` variant default at the *exact same insertion point* in this file, for the same class of reason (a v4 default that does not match how this app actually works). This change follows that precedent. **Note for whoever merges second: #2849 and this PR will conflict textually** — both append a `@custom-variant` immediately after `@config`. The resolution is to keep both lines; they are independent. ## Scope Desktop only. `web/src/shared/styles/globals.css` has the same Tailwind v4 default, but `web/src` contains **zero** `group-hover` usages, so there are no hover-revealed affordances to strand there. Adding the override to web would be speculative. One `hover` capability query is deliberately left in place — `.buzz-wave-hover-trigger` in `animations.css` gates a decorative wave-hand animation on `(hover: hover) and (pointer: fine)`. That is a cosmetic flourish rather than an affordance, so it stays inert on affected hosts instead of widening this diff. ## Reproducing The trigger is **an integrated touch digitizer anywhere on the machine**, not the display you are actually working on. This was found on a touch-capable laptop docked to an ordinary non-touch external monitor, driven entirely by a mouse — so "I'm on a desktop monitor" does not rule you out. Check with: ```js matchMedia('(hover: hover)').matches // false ⇒ affected ``` Not reproducible on macOS, or on a Windows machine with no digitizer at all — `hover: hover` is true there and every affordance works normally. If you are on such a host, emulate it in devtools by forcing `hover: none` / `pointer: coarse`, then open a channel's member list and hover a row: no action menu appears. ## Tradeoff worth naming On a genuine touch-only device, a bare `&:hover` can latch after a tap and stay applied until the next interaction, where the media-query default would have suppressed it. That is the real cost of this change. The judgement here is that a stuck hover style is a cosmetic annoyance, while an unreachable "remove member" button is a functional dead end — and that the affected hosts are overwhelmingly mouse-driven machines that merely *happen* to ship a digitizer, as the `MousePresent = True` reading above shows. If you would rather scope this to `@media not (hover: hover)` as an additive fallback instead of overriding the variant, I am happy to rework it. Signed-off-by: sumit-m <33051892+sumit-m@users.noreply.github.com> --- desktop/src/shared/styles/globals.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index fd60621933..704f6e542d 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; + @import "./globals/scrollbars.css"; @import "./globals/motion.css"; @import "./globals/animations.css"; @@ -17,3 +18,15 @@ @import "./globals/progress.css"; @config "../../../tailwind.config.js"; + +/* Tailwind v4 gates `hover:` behind `@media (hover: hover)`. Some Windows + hosts answer that query `false` even with a mouse attached — WebView2 here + reports `hover: none`, `any-pointer: fine: false`, `maxTouchPoints: 10` — + which leaves every hover-revealed control permanently `visibility: hidden`: + member row action menus, sidebar row actions, attachment controls. A bare + `&:hover` trusts the actual hover event instead of the capability query; + Chromium only fires :hover when a real pointer is present. + + Must stay below every `@import`: CSS requires `@import` to precede other + at-rules, so placing this above them silently drops the rest of the sheet. */ +@custom-variant hover (&:hover); From 74cd5712191bffd84ae688d59bb8b451c6eec1b0 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 16:04:14 -0600 Subject: [PATCH 80/99] fix(desktop): report authenticated relay recovery (#3812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - report the relay as connected immediately after socket open and successful AUTH - keep rate-limited subscription replay, the connect promise, and reconnect listeners unchanged - cover authenticated reconnect while replay is held behind the shared rate-limit gate ## Why After WARP recovery, the socket could reopen and authenticate successfully while subscription replay waited behind the existing rate-limit gate. `connect()` kept `ConnectionState` at `reconnecting` during that intentional delay, so the desktop displayed “Can’t reach the relay” despite authenticated traffic already flowing. This is separate from #3774: that fix keeps routine operations from bypassing scheduled reconnect backoff. This patch preserves those protections and only corrects the authenticated transport-state boundary. ## Failure semantics If replay fails after the early `connected` transition, the existing `replayLiveSubscriptions()` catch calls `resetConnection()`, closes the socket, returns state to `reconnecting`, and schedules recovery. Operation waiters and reconnect notifications still do not complete until replay succeeds. ## Validation At commit `c8a4308e1079f4f9e6a72f0f0bfba280fe822ec0` with a clean working tree: - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,847 passed - `pnpm --dir desktop check` — passed; two pre-existing informational template-literal notices - `pnpm --dir desktop exec playwright test tests/e2e/relay-reconnect.spec.ts` — 8 passed - regression test proven red before the production ordering change (`reconnecting` after 3 seconds) and green after it Signed-off-by: Wes Co-authored-by: Carl --- desktop/src/shared/api/relayClientSession.ts | 2 +- desktop/src/testing/e2eBridge.ts | 5 +++ desktop/tests/e2e/relay-reconnect.spec.ts | 35 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 8274034ed5..94438386eb 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -566,8 +566,8 @@ export class RelayClient { this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; }, BACKOFF_RESET_STABLE_MS); - await this.replayLiveSubscriptions(); this.connectionStateEmitter.set("connected"); + await this.replayLiveSubscriptions(); this.stallWatchdog.start(); this.emitReconnectIfNeeded(); } catch (error) { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 841e6ba83f..9bb3feabda 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11,6 +11,7 @@ import { } from "./e2eBridgeCustomHarnesses.ts"; import { relayClient } from "@/shared/api/relayClient"; +import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { ChannelTemplate, RelayEvent } from "@/shared/api/types"; import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache"; @@ -1115,6 +1116,7 @@ declare global { unavailable: boolean, ) => void; __BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[]; + __BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__?: (seconds: number) => void; __BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void; __BUZZ_E2E_SET_MESH__?: (mesh: { admitted?: boolean; @@ -9706,6 +9708,9 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [ ...relayWebsocketConnectAttemptStarts, ]; + window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__ = (seconds) => { + activateRateLimit(seconds); + }; window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => { relayWebsocketConnectAttemptStarts.length = 0; }; diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 67ce725da8..6606d5f04d 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -62,6 +62,19 @@ async function setMockWebsocketUnavailable( }, unavailable); } +async function activateRelayRateLimit( + page: import("@playwright/test").Page, + seconds: number, +) { + await page.evaluate((duration) => { + const activate = window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__; + if (!activate) { + throw new Error("E2E relay rate-limit seam is not installed."); + } + activate(duration); + }, seconds); +} + async function getMockWebsocketConnectAttempts( page: import("@playwright/test").Page, ) { @@ -195,6 +208,28 @@ test("routine traffic cannot bypass outage backoff and recovery stays automatic" ); }); +test("authenticated reconnect reports connected while replay is rate-limited", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await activateRelayRateLimit(page, 5); + await disconnectMockWebsockets(page); + + // Replay remains intentionally blocked behind admission control, but socket + // open + successful AUTH is already a healthy connection. The UI must not + // claim the relay is unreachable for the rest of the gate window. + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 3_000 }, + ) + .toBe("connected"); + await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0); +}); + test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"], From 36571f4adcfdcf3714a17bd968c58c78bcbdd9ef Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 30 Jul 2026 18:11:03 -0400 Subject: [PATCH 81/99] fix(desktop): allow linux-only media items as dead code off-linux (#3811) Local `desktop-tauri-clippy` fails on macOS with dead-code errors for `PROD_ORIGIN`, `DEV_ORIGIN`, and `is_trusted_media_origin`, which are only used inside `#[cfg(target_os = "linux")] enable_media_capture`. The items are intentionally platform-independent so unit tests run everywhere. Added `cfg_attr` allow attribute to suppress the warnings on non-Linux targets. Since [#3607](https://github.com/block/buzz/pull/3607), this affects all Rust developers on macOS. Signed-off-by: Will Pfleger Co-authored-by: d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78 --- desktop/src-tauri/src/linux_media.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs index c768e15422..240e2f8a77 100644 --- a/desktop/src-tauri/src/linux_media.rs +++ b/desktop/src-tauri/src/linux_media.rs @@ -22,17 +22,22 @@ //! which is the backend WebKitGTK media capture is reliable on. /// The origin Tauri serves the packaged app from on Linux. +/// Consumed only by linux-gated [`enable_media_capture`]; kept compiling on all +/// platforms so the unit tests run everywhere. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] const PROD_ORIGIN: &str = "tauri://localhost"; /// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort` /// 1420 in `vite.config.ts`). Only trusted in debug builds. #[cfg(debug_assertions)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] const DEV_ORIGIN: &str = "http://localhost:1420"; /// Whether `uri` (the webview's current document URI) is a trusted app origin /// allowed to use mic/camera. Matches the origin exactly or as a path prefix so /// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip /// through. Pure and platform-independent so it can be unit-tested everywhere. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn is_trusted_media_origin(uri: &str) -> bool { fn matches(uri: &str, origin: &str) -> bool { uri == origin From 23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 30 Jul 2026 18:42:18 -0400 Subject: [PATCH 82/99] fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz's NIP-11 document advertised `limitation.max_limit: 10_000`, but the effective websocket REQ page ceiling was `1_000` — a 10x lie. The websocket REQ path never sets `EventQuery::max_limit`, so `query_events` applied its own `unwrap_or(1000)` clamp to every historical query. Only the COUNT fallback (`apply_count_fallback_limit`) ever raises that clamp. A client that trusts the advertised value asks for 10,000 events, silently receives 1,000, and — with no error and no continuation signal — reads that short page as exhaustion. Up to 9,000 events are dropped without anyone noticing. `MAX_HISTORICAL_LIMIT = 2_000` in `handlers/req.rs` was dead weight for the same reason: nothing clamped to 2,000 could survive the DB's 1,000 clamp one layer down. ## Change `buzz_db::DEFAULT_MAX_PAGE_LIMIT` (`1_000`) is now the single source of truth. It is the `query_events` clamp default, the value both REQ clamp sites use, and the value advertised as NIP-11 `max_limit`. `MAX_HISTORICAL_LIMIT` is removed rather than re-pointed — an alias for a constant used four lines away adds a name without adding meaning. The NIP-50 search path carries a second, independent bound. It clamps its emission target to the shared ceiling like any other REQ, but how many FTS candidates it will scan was bounded separately, by a bare 10-page loop over 100-hit pages. That product only coincidentally equalled the ceiling, so raising the ceiling — or shrinking a page — would shrink the scan relative to what clients may now request, degrading search quality while nothing in the code registered the change. The page count is now ceiling-divided from `DEFAULT_MAX_PAGE_LIMIT` over a named `SEARCH_PAGE_SIZE`, so the scan budget tracks the advertised ceiling by construction. That budget is a resource policy, not a delivery promise. It bounds candidates *scanned*, not events *emitted*: post-filtering (NIP-01 match, channel access, reader visibility, dedup) discards an unpredictable share of every page, so a search result smaller than the requested limit remains possible. This is not a NIP-11 violation — `max_limit` is defined as a clamp the relay applies to a requested `limit`, not a guaranteed count in the response. Two guards hold the pair together: - `req_filter_limit_clamps_to_advertised_nip11_max_limit` reads `max_limit` back out of a built `RelayInfo` and asserts the REQ path clamps to exactly that number. - `search_scan_capacity_covers_advertised_nip11_max_limit` asserts the scan budget covers exactly one advertised ceiling's worth of candidates — no less, and with no spare page of slack, so the derivation can't be quietly replaced by a hand-tuned constant that happens to pass today. ## Behavior Websocket behavior is unchanged: 1,000 was already the real ceiling on every path, including NIP-50. The advertisement now tells the truth about it. Raising the effective limit is a capacity decision and is deliberately not made here. The generic HTTP bridge's page-2+ offsets do change, as a consequence of the corrected clamp. `extract_page_offset` sizes a page from `query.limit` *before* the DB clamp applies, so an absent limit previously produced an offset of 2,000 and a requested 1,500 produced 1,500 — while the page actually returned held at most 1,000 rows. Both now produce 1,000. This corrects paging that had been skipping rows the previous page never returned; `extract_page_offset_sizes_pages_from_clamped_limit` locks it down. ## Scope note The bridge's per-endpoint ceilings — `BRIDGE_WINDOW_MAX_LIMIT` (200) for channel windows and `BRIDGE_THREAD_MAX_LIMIT` (500) for thread reads — are endpoint contracts on a non-NIP-01 transport, not values NIP-11 speaks for, and are unchanged. Fixes #3757 --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- crates/buzz-db/src/event.rs | 15 ++- crates/buzz-db/src/lib.rs | 2 +- crates/buzz-relay/src/api/bridge.rs | 21 +++++ crates/buzz-relay/src/handlers/req.rs | 126 ++++++++++++++++++++++---- crates/buzz-relay/src/nip11.rs | 7 +- 5 files changed, 147 insertions(+), 24 deletions(-) diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index c0550e7e22..6c84950a2c 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -17,6 +17,13 @@ use buzz_core::{CommunityId, StoredEvent}; use crate::error::{DbError, Result}; +/// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is +/// unset — the effective ceiling on any client-requested `limit`. +/// +/// This is the value the relay advertises as NIP-11 `limitation.max_limit`, so +/// the advertised ceiling and the enforced one cannot drift. +pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000; + /// Optional filters for [`query_events`]. #[derive(Debug, Clone)] pub struct EventQuery { @@ -67,9 +74,9 @@ pub struct EventQuery { /// channel-less global events. Applied before SQL `LIMIT` so access-filtered /// historical pages have exact exhaustion semantics. pub channel_ids: Option>, - /// Override the default limit clamp (1000). Used by COUNT fallback path - /// which needs to fetch all matching events for post-filter counting. - /// When None, the default clamp of 1000 applies. + /// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by + /// the COUNT fallback path, which needs to fetch all matching events for + /// post-filter counting. When None, the default clamp applies. pub max_limit: Option, /// Shared-gated visibility reader: when set, append an SQL visibility /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so @@ -357,7 +364,7 @@ pub(crate) async fn query_events_on( return Ok(vec![]); } - let clamp = q.max_limit.unwrap_or(1000); + let clamp = q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT); let limit_val = q.limit.unwrap_or(100).min(clamp); let offset_val = q.offset.unwrap_or(0); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 5c60d1a702..50aac1cbaf 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -55,7 +55,7 @@ pub mod user; pub mod workflow; pub use error::{DbError, Result}; -pub use event::{EventQuery, ReactionEventInsertOutcome}; +pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; use chrono::{DateTime, Utc}; use sqlx::postgres::{PgConnection, PgPoolOptions}; diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 678199e734..a118ff453f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3042,6 +3042,27 @@ mod tests { assert_eq!(extract_page_offset(&raw, None), None); } + /// Offsets are sized from the *clamped* limit the DB will honor, not from + /// what the client asked for. `filter_to_query_params` clamps an absent or + /// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in + /// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`) + /// and that clamped value is what arrives here — so page N starts exactly + /// N-1 full pages in. Sizing from an unclamped limit would step past rows + /// the previous page never returned. + #[test] + fn extract_page_offset_sizes_pages_from_clamped_limit() { + let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT; + + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 2 }), Some(clamped)), + Some(clamped) + ); + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 3 }), Some(clamped)), + Some(clamped * 2) + ); + } + #[test] fn extract_depth_limit_valid() { let raw = serde_json::json!({ "depth_limit": 3 }); diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 35fbf0c892..2aed12cd7f 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -22,7 +22,6 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -const MAX_HISTORICAL_LIMIT: i64 = 2_000; const MAX_SUBSCRIPTIONS: usize = 1024; /// Maximum `query_events` calls in flight per multi-filter REQ / bridge query. @@ -416,10 +415,24 @@ pub async fn handle_req( ); } -/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE. -/// Search subscriptions are one-shot — no persistent subscription is registered. +/// FTS candidate hits fetched per page. Pages are always full regardless of +/// the requested limit — post-filtering discards an unpredictable share of +/// hits, so the scan fetches candidates in full pages rather than sizing +/// pages to the request. +const SEARCH_PAGE_SIZE: u32 = 100; + /// Maximum FTS pages to fetch per filter (prevents unbounded loops). -const MAX_SEARCH_PAGES: u32 = 10; +/// +/// Derived from the advertised page ceiling rather than fixed: the scan +/// budget is a resource policy — at most one advertised page ceiling's worth +/// of candidates per filter — and deriving it keeps the budget tracking the +/// ceiling if the ceiling ever moves. This bounds candidates *scanned*, not +/// events *emitted*: post-filtering (NIP-01 match, channel access, reader +/// visibility, dedup) can discard any number of candidates, so a result +/// smaller than the requested limit remains possible and is not a NIP-11 +/// violation — `max_limit` promises a clamp on the request, not a count in +/// the response. +const MAX_SEARCH_PAGES: u32 = (buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32).div_ceil(SEARCH_PAGE_SIZE); /// Resolve request-local channel access, repairing a stale cache-negative. /// @@ -501,6 +514,8 @@ pub(crate) fn build_search_channel_scope_filter( }) } +/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE. +/// Search subscriptions are one-shot — no persistent subscription is registered. #[allow(clippy::too_many_arguments)] async fn handle_search_req( sub_id: &str, @@ -535,8 +550,8 @@ async fn handle_search_req( let limit = filter .limit - .map(|l| (l as u32).min(MAX_HISTORICAL_LIMIT as u32)) - .unwrap_or(MAX_HISTORICAL_LIMIT as u32); + .map(|l| (l as u32).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32)) + .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32); if limit == 0 { continue; // NIP-01: limit 0 means "no results from this filter" @@ -583,13 +598,11 @@ async fn handle_search_req( let since = filter.since.map(|s| s.as_secs() as i64); let until = filter.until.map(|u| u.as_secs() as i64); - // Paginate: keep fetching pages until we've emitted `limit` results - // or exhausted the search result set. This ensures post-filtering - // doesn't silently reduce the result count below the requested limit. + // Paginate: keep fetching pages until we've emitted `limit` results or + // exhausted the search result set. Post-filtering discards an unpredictable + // share of each page, so continuing past short yields gives the scan a + // chance — not a guarantee — of filling the requested limit. let mut emitted: u32 = 0; - // Always fetch full pages (100) regardless of limit — post-filtering - // may discard many hits, so we need headroom to fill the requested limit. - let per_page: u32 = 100; for page in 1..=MAX_SEARCH_PAGES { if emitted >= limit { @@ -605,7 +618,7 @@ async fn handle_search_req( since, until, page, - per_page, + per_page: SEARCH_PAGE_SIZE, mode: buzz_search::SearchMode::FullText, }; @@ -617,9 +630,9 @@ async fn handle_search_req( } }; - // A short page is the last page: FTS returns up to `per_page` hits, - // so fewer than that means the result set is exhausted. - let exhausted = search_result.hits.len() < per_page as usize; + // A short page is the last page: FTS returns up to a full page of + // hits, so fewer than that means the result set is exhausted. + let exhausted = search_result.hits.len() < SEARCH_PAGE_SIZE as usize; let page_empty = search_result.hits.is_empty(); let hit_ids: Vec<[u8; 32]> = @@ -878,8 +891,8 @@ fn filter_to_query_params( .and_then(|u| chrono::DateTime::from_timestamp(u.as_secs() as i64, 0)); let limit = filter .limit - .map(|l| (l as i64).min(MAX_HISTORICAL_LIMIT)) - .unwrap_or(MAX_HISTORICAL_LIMIT); + .map(|l| (l as i64).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT)) + .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT); // Push author filter into SQL. Single-author uses the indexed `pubkey` column; // multi-author uses the `authors` IN-list pushdown added in the pure-nostr PR. @@ -1418,6 +1431,83 @@ mod tests { ) } + /// NIP-11 `limitation.max_limit` as this relay actually advertises it. + fn advertised_max_limit() -> i64 { + crate::nip11::RelayInfo::build( + None, + None, + false, + crate::config::DEFAULT_MAX_FRAME_BYTES, + None, + ) + .limitation + .expect("limitation") + .max_limit + .expect("max_limit") as i64 + } + + #[test] + fn req_filter_limit_clamps_to_advertised_nip11_max_limit() { + let advertised = advertised_max_limit(); + + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + + // A filter asking for more than the relay advertises is clamped down to + // exactly the advertised ceiling — the NIP-11 document is the promise, + // this is the enforcement. + let greedy = filter_to_query_params( + &Filter::new().limit(advertised as usize * 10), + None, + community, + ); + assert_eq!(greedy.limit, Some(advertised)); + + // A filter with no `limit` gets the same ceiling, not something larger. + let unbounded = filter_to_query_params(&Filter::new(), None, community); + assert_eq!(unbounded.limit, Some(advertised)); + + // Neither sets `max_limit`, so `query_events` applies its own default + // clamp. That default must equal the advertised value too, or the + // clamp above would be undone one layer down. + assert_eq!(greedy.max_limit, None); + assert_eq!(unbounded.max_limit, None); + assert_eq!(buzz_db::DEFAULT_MAX_PAGE_LIMIT, advertised); + + // Under-ceiling requests are honored verbatim. + let modest = filter_to_query_params(&Filter::new().limit(10), None, community); + assert_eq!(modest.limit, Some(10)); + } + + /// The NIP-50 search path clamps its emission target to the advertised + /// ceiling like every other REQ, but the number of candidates it will scan + /// is bounded a second time by the page budget. This pins the resource + /// policy: the budget covers exactly one advertised page ceiling's worth of + /// candidates — no less (a ceiling raise must not silently shrink the scan + /// relative to what clients may request) and no hand-tuned spare (the budget + /// must stay derived, not drift back into a magic number). It deliberately + /// does NOT claim search fills the emitted limit — post-filtering can + /// discard any number of candidates. + #[test] + fn search_scan_capacity_covers_advertised_nip11_max_limit() { + let advertised = advertised_max_limit(); + let capacity = i64::from(MAX_SEARCH_PAGES) * i64::from(SEARCH_PAGE_SIZE); + + assert!( + capacity >= advertised, + "NIP-50 scans at most {capacity} candidates ({MAX_SEARCH_PAGES} pages of \ + {SEARCH_PAGE_SIZE}) but NIP-11 advertises {advertised} — the scan budget \ + no longer covers the advertised ceiling" + ); + + // The budget is derived, not hand-tuned: one page under the derived + // count must be insufficient, or the ceiling could rise without the + // page count following it. + assert!( + capacity - i64::from(SEARCH_PAGE_SIZE) < advertised, + "scan budget has a spare page of slack — derive it from the ceiling" + ); + } + #[test] fn count_fallback_fetches_one_extra_candidate() { let mut query = diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index a8e397dd21..2575ddd7ba 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -89,6 +89,11 @@ pub struct RelayLimitation { /// Canonical `RelayLimitation` advertised by this relay. /// +/// `max_limit` is [`buzz_db::DEFAULT_MAX_PAGE_LIMIT`], the same constant the +/// REQ path clamps filter limits to, so the advertised ceiling and the +/// enforced one cannot drift (see +/// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`). +/// /// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers /// unconditionally reject connections that are not in /// `AuthState::Authenticated`. This is independent of the REST API token @@ -103,7 +108,7 @@ fn relay_limitation(max_message_length: usize) -> RelayLimitation { max_message_length: Some(max_message_length as u64), max_subscriptions: Some(1024), max_filters: Some(10), - max_limit: Some(10_000), + max_limit: Some(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32), max_subid_length: Some(256), min_pow_difficulty: None, auth_required: true, From ede26863345a518ec46edd6d7692e0281883491b Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Thu, 30 Jul 2026 15:47:35 -0700 Subject: [PATCH 83/99] fix(desktop): align data deletion labels (#2230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The Profile settings action still says “Sign Out,” while its confirmation action says “Delete My Data.” Both buttons trigger the same destructive local-data wipe and should name it consistently. ## What - Label both destructive actions “Delete my data” - Assert the matching section and confirmation labels in the existing Playwright coverage ## Risk Assessment Low — copy and test assertions only; sign-out behavior is unchanged. ## References - Follow-up to #2208 - #2216 also touches this copy and should preserve “Delete my data” when rebased - `just desktop-check` - `just desktop-test` (3,275 tests) - Desktop E2E build and sign-out Playwright spec (2 tests) Generated with Codex Signed-off-by: Bradley Axen --- desktop/src/features/settings/ui/SignOutSection.tsx | 4 ++-- desktop/tests/e2e/signout-screenshots.spec.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx index 8d4dc1c481..746220459b 100644 --- a/desktop/src/features/settings/ui/SignOutSection.tsx +++ b/desktop/src/features/settings/ui/SignOutSection.tsx @@ -151,7 +151,7 @@ export function SignOutSection() { {isPending ? ( ) : null} - {isPending ? "Signing out…" : "Sign Out"} + {isPending ? "Signing out…" : "Delete my data"} ) : null} - {isPending ? "Signing out…" : "Delete My Data"} + {isPending ? "Signing out…" : "Delete my data"} diff --git a/desktop/tests/e2e/signout-screenshots.spec.ts b/desktop/tests/e2e/signout-screenshots.spec.ts index 32fc3ed09d..5cf35c70ab 100644 --- a/desktop/tests/e2e/signout-screenshots.spec.ts +++ b/desktop/tests/e2e/signout-screenshots.spec.ts @@ -23,7 +23,7 @@ test.describe("signout screenshots", () => { }); }); - test("signout-section — Sign Out card in Settings › Profile", async ({ + test("signout-section — data deletion card in Settings › Profile", async ({ page, }) => { await installMockBridge(page); @@ -32,6 +32,9 @@ test.describe("signout screenshots", () => { const section = page.getByTestId("settings-signout"); await section.scrollIntoViewIfNeeded(); + await expect( + section.getByRole("button", { name: "Delete my data" }), + ).toBeVisible(); // Settle animations before capture. await page.evaluate(() => @@ -59,7 +62,7 @@ test.describe("signout screenshots", () => { await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog.getByText("Sign out and wipe all data?")).toBeVisible(); await expect( - dialog.getByRole("button", { name: "Delete My Data" }), + dialog.getByRole("button", { name: "Delete my data" }), ).toBeVisible(); // Settle animations before capture. From 9e8fcfda099652926b921bca7fcc9bfecab0e140 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 30 Jul 2026 18:53:04 -0400 Subject: [PATCH 84/99] fix(desktop): channel topic and membership metadata cleanup (#3642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of #2216, scoped to the system/status lines in the chat timeline. ## Why Two problems on the same surface. **Clearing a channel topic renders as empty quotes.** The relay reports a clear as a `topic_changed` event carrying an empty string — there's no separate "cleared" event type. So the timeline printed: > Alice > changed the topic to “” which reads as if the topic were *set to* two quote marks. Same for purpose. **The membership caption reads like a headline, not a metadata line.** `title` and `action` render on separate lines — the member's name sits in the header row with the avatar and timestamp, and the caption sits beneath it. So the caption was "was added by Alice Chen" standing alone under a name, while its siblings on that same line are "joined the channel" and "left the channel". ## What - Blank, missing, or whitespace-only topic/purpose now reads **"cleared the channel topic"** / **"cleared the channel purpose"**. - Membership captions drop "was": **"added by Alice Chen"**, matching "joined the channel" and "left the channel". - The wording moves to `lib/systemEventCopy.ts` as a pure function, so it's assertable in a unit test instead of only reachable through the DOM. That also removes two JSX fragments from `SystemMessageRow.tsx`, taking it 911 → 900 lines. ## Two E2E assertions this exposed Both were measuring something other than what they claimed, and the copy change tipped them over. Neither is a product bug, but both would have failed the next person too. 1. **`mentions.spec.ts:1245`** asserted a button was un-underlined while the mouse was still parked from a previous `hover()`. Any reflow — new rows, scroll-to-bottom, a different text wrap — can slide that button under the stationary pointer, so the assertion measured *where the mouse happened to be* rather than the resting style. Dropping four characters changed the text wrap, changed the row height, changed the scroll offset, and the pointer landed on it. Now parks the pointer off-target first. 2. **`mentions.spec.ts:1253`** used a bare `role=tooltip` lookup. Once the first tooltip animates out while the second opens, two elements match and strict mode trips. Now scopes to the open tooltip via `:not([data-state="closed"])`. ## Deliberately out of scope - **Timestamps.** The day divider, per-message clock times, the Inbox thread pane, and the inbox list have three divergent date implementations and none fully match the writing standard's Today/Yesterday/weekday/date progression. That's its own slice of #2216. - **Whose avatar shows.** An addition puts the *added* member in the header; a removal puts the *remover* there. Possibly intentional, but it's a design question, not copy. - **`the channel` vs `this channel`.** joined/left/removed say "the channel"; created/archived/unarchived say "this channel". Worth normalizing, but it touches lines this PR otherwise leaves alone. ## Validation - `pnpm check`, `pnpm typecheck` — clean - Unit: **3781/3781**, including 6 new tests in `systemEventCopy.test.mjs` covering set/blank/undefined/null/whitespace for both fields, plus a guard that no variant can emit empty quotes - Smoke E2E `mentions` + `messaging`: **85/85** - The previously fragile test run with `--repeat-each=5`: **5/5** Signed-off-by: Clay Delk Co-authored-by: Claude Opus 5 (1M context) --- .../messages/lib/systemEventCopy.test.mjs | 107 ++++++++++++++++++ .../features/messages/lib/systemEventCopy.ts | 59 ++++++++++ .../features/messages/ui/SystemMessageRow.tsx | 56 +++++++-- desktop/tests/e2e/mentions.spec.ts | 26 +++-- .../channel_detail_page/system_rows.dart | 7 +- .../features/channels/timeline_message.dart | 24 +++- .../channels/channel_detail_page_test.dart | 6 +- .../channels/timeline_message_test.dart | 47 ++++++++ 8 files changed, 311 insertions(+), 21 deletions(-) create mode 100644 desktop/src/features/messages/lib/systemEventCopy.test.mjs create mode 100644 desktop/src/features/messages/lib/systemEventCopy.ts diff --git a/desktop/src/features/messages/lib/systemEventCopy.test.mjs b/desktop/src/features/messages/lib/systemEventCopy.test.mjs new file mode 100644 index 0000000000..eeed9d543c --- /dev/null +++ b/desktop/src/features/messages/lib/systemEventCopy.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + describeChannelTextFieldChange, + toInlineName, +} from "./systemEventCopy.ts"; + +test("a set topic is quoted verbatim", () => { + assert.equal( + describeChannelTextFieldChange("topic", "Release planning"), + "changed the topic to “Release planning”", + ); +}); + +test("a set purpose names the purpose, not the topic", () => { + assert.equal( + describeChannelTextFieldChange("purpose", "Where we ship from"), + "changed the purpose to “Where we ship from”", + ); +}); + +// The relay reports a clear as a change carrying an empty string, so without +// this branch the timeline reads: changed the topic to “”. +test("an empty value reads as cleared, not as a change to empty quotes", () => { + for (const blank of ["", undefined, null]) { + assert.equal( + describeChannelTextFieldChange("topic", blank), + "cleared the topic", + ); + assert.equal( + describeChannelTextFieldChange("purpose", blank), + "cleared the purpose", + ); + } +}); + +test("a whitespace-only value reads as cleared", () => { + assert.equal( + describeChannelTextFieldChange("topic", " \n\t "), + "cleared the topic", + ); +}); + +test("surrounding whitespace is trimmed out of the quotes", () => { + assert.equal( + describeChannelTextFieldChange("topic", " Release planning "), + "changed the topic to “Release planning”", + ); +}); + +test("no caption announces empty quotes", () => { + for (const value of ["", " ", null, undefined, "Real topic"]) { + for (const field of ["topic", "purpose"]) { + assert.doesNotMatch( + describeChannelTextFieldChange(field, value), + /“”|""/, + `${field} with ${JSON.stringify(value)} must not render empty quotes`, + ); + } + } +}); + +test("the reader's own name is lowercase mid-sentence", () => { + // "added by You" next to an agent's "managed by you" was the inconsistency. + assert.equal(toInlineName("You", true), "you"); +}); + +test("cleared and changed captions use the same noun", () => { + // Not "cleared the channel topic" against "changed the topic to …". + assert.match(describeChannelTextFieldChange("topic", ""), /\bthe topic\b/); + assert.match( + describeChannelTextFieldChange("topic", "Ship it"), + /\bthe topic\b/, + ); + for (const value of ["", "Ship it"]) { + assert.doesNotMatch( + describeChannelTextFieldChange("topic", value), + /channel topic/, + ); + } +}); + +test("every other name keeps its own capitalization", () => { + for (const name of [ + "Alice Chen", + "you-know-who", + "Someone", + "npub1abc…def", + ]) { + assert.equal(toInlineName(name, false), name); + } +}); + +test("someone else whose display name is literally You is left alone", () => { + // The decisive case: the label is user-controlled, identity is not. Matching + // on the string would rewrite this person's name as if they were the reader. + assert.equal(toInlineName("You", false), "You"); + assert.equal(toInlineName("Youssef", false), "Youssef"); + assert.equal(toInlineName("You Know Who", false), "You Know Who"); +}); + +test("the reader is lowercased whatever their profile name says", () => { + // Self resolution never consults the profile, but the rule keys on identity, + // so it does not matter what the label happens to be. + assert.equal(toInlineName("Alice Chen", true), "you"); +}); diff --git a/desktop/src/features/messages/lib/systemEventCopy.ts b/desktop/src/features/messages/lib/systemEventCopy.ts new file mode 100644 index 0000000000..bae6abb09b --- /dev/null +++ b/desktop/src/features/messages/lib/systemEventCopy.ts @@ -0,0 +1,59 @@ +/** + * Copy for channel system events (the "joined", "added by", "changed the + * topic" captions in the message timeline). + * + * These live outside `SystemMessageRow` so the wording is a pure function of + * the payload and can be asserted directly in tests. Only cases whose caption + * is plain text belong here — cases that interpolate a profile link build their + * JSX in the component. + */ + +/** Curly quotes, so the caption matches the typography used elsewhere in chat. */ +const OPEN_QUOTE = "“"; +const CLOSE_QUOTE = "”"; + +export type ChannelTextField = "topic" | "purpose"; + +/** + * Caption for a channel topic or purpose change. + * + * Bare "the topic" rather than "the channel topic": this row only ever renders + * in a channel timeline, under that channel's own header, so naming the channel + * again is redundant — and it keeps the cleared and changed captions on the same + * noun instead of one saying "channel topic" and the other "topic". + * + * A blank value means the field was cleared: the relay reports a clear as a + * `topic_changed` / `purpose_changed` event carrying an empty string, not as a + * separate event type. Without this branch the timeline renders `changed the + * topic to ""`, which reads like the topic was set to two quote marks. + * Whitespace-only values are treated as cleared for the same reason. + */ +export function describeChannelTextFieldChange( + field: ChannelTextField, + value: string | null | undefined, +): string { + const trimmed = value?.trim(); + if (!trimmed) { + return `cleared the ${field}`; + } + return `changed the ${field} to ${OPEN_QUOTE}${trimmed}${CLOSE_QUOTE}`; +} + +/** + * Adjusts a resolved display name for use inside a sentence rather than in the + * name slot at the top of a row — "added by you", "removed you from the channel". + * + * `resolveUserLabel` returns "You" for the current user, which is right standing + * alone and wrong mid-phrase. Agent ownership already draws the same distinction + * from the other side: `formatOwnerLabel` returns lowercase "you" because it is + * only ever read as "managed by you". + * + * `isSelf` is the caller's pubkey comparison, not an inspection of `label`. + * Matching on the string would also rewrite a different person whose display + * name happens to be "You" — the label is user-controlled, identity is not. + * Every name that isn't the reader's own is a proper noun and is returned + * untouched. + */ +export function toInlineName(label: string, isSelf: boolean): string { + return isSelf ? "you" : label; +} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 4410964dd9..c4637d2823 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -28,6 +28,10 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + describeChannelTextFieldChange, + toInlineName, +} from "../lib/systemEventCopy"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; @@ -180,6 +184,29 @@ function resolveDisplayLabel( return resolveLabel(pubkey, currentPubkey, profiles); } +function isSelfPubkey( + pubkey: string | undefined, + currentPubkey: string | undefined, +): boolean { + return Boolean( + pubkey && + currentPubkey && + normalizePubkey(pubkey) === normalizePubkey(currentPubkey), + ); +} + +/** Same label as `resolveDisplayLabel`, adjusted for mid-sentence use. */ +function resolveInlineDisplayLabel( + pubkey: string | undefined, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): string { + return toInlineName( + resolveLabel(pubkey, currentPubkey, profiles), + isSelfPubkey(pubkey, currentPubkey), + ); +} + function isKnownAgentPubkey( pubkey: string | undefined, profiles: UserProfileLookup | undefined, @@ -386,7 +413,7 @@ function MembershipPersonName({ pubkey={pubkey} underlineOnHover > - {resolveDisplayLabel(pubkey, currentPubkey, profiles)} + {resolveInlineDisplayLabel(pubkey, currentPubkey, profiles)} ); } @@ -497,12 +524,17 @@ function describeSystemEvent( currentPubkey, profiles, ); + const inlineTargetLabel = resolveInlineDisplayLabel( + payload.target, + currentPubkey, + profiles, + ); const actorName = ( {actorLabel} ); const targetName = ( - {targetLabel} + {inlineTargetLabel} ); const membershipTitle = ( @@ -522,9 +554,13 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - was added by{" "} + added by{" "} - {resolveDisplayLabel(payload.actor, currentPubkey, profiles)} + {resolveInlineDisplayLabel( + payload.actor, + currentPubkey, + profiles, + )} , along with{" "} - was added by{" "} + added by{" "} - {resolveDisplayLabel(payload.actor, currentPubkey, profiles)} + {resolveInlineDisplayLabel( + payload.actor, + currentPubkey, + profiles, + )} ), @@ -587,12 +627,12 @@ function describeSystemEvent( case "topic_changed": return { title: actorName, - action: <>changed the topic to “{payload.topic}”, + action: describeChannelTextFieldChange("topic", payload.topic), }; case "purpose_changed": return { title: actorName, - action: <>changed the purpose to “{payload.purpose}”, + action: describeChannelTextFieldChange("purpose", payload.purpose), }; case "channel_created": return { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 694b5abef5..512eb3d800 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1114,7 +1114,7 @@ test("system add rows use plain names while remove rows retain agent mention sty const addedRow = page .getByTestId("system-message-row") .filter({ hasText: "portal" }) - .filter({ hasText: "was added by" }); + .filter({ hasText: "added by" }); const removedRow = page .getByTestId("system-message-row") .filter({ hasText: "removed portal from the channel" }); @@ -1178,7 +1178,7 @@ test("groups member additions and joins with hidden names in the standard toolti const groupedRow = page .getByTestId("system-message-row") - .filter({ hasText: "was added by Alice Chen" }); + .filter({ hasText: "added by Alice Chen" }); for (const visibleName of [ "Erica Chapman", "Peter Griffin", @@ -1188,9 +1188,9 @@ test("groups member additions and joins with hidden names in the standard toolti await expect(groupedRow).toContainText(visibleName); } await expect( - groupedRow.locator("p").filter({ hasText: "was added by" }), + groupedRow.locator("p").filter({ hasText: "added by" }), ).toContainText( - "was added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", + "added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", ); await expect(groupedRow.locator("[data-mention]")).toHaveCount(0); @@ -1200,6 +1200,11 @@ test("groups member additions and joins with hidden names in the standard toolti await expect(visibleName).toHaveCSS("text-decoration-line", "underline"); const othersTrigger = groupedRow.getByRole("button", { name: "2 others" }); + // Park the pointer off-target first: the previous hover leaves the mouse at a + // fixed viewport point, and any later reflow (new rows, scroll-to-bottom, a + // different text wrap) can slide this button under it. Without this the + // assertion measures where the mouse happens to be, not the resting style. + await page.mouse.move(0, 0); await expect(othersTrigger).toHaveCSS("text-decoration-line", "none"); await othersTrigger.hover(); await expect(othersTrigger).toHaveCSS("text-decoration-line", "underline"); @@ -1242,10 +1247,17 @@ test("groups member additions and joins with hidden names in the standard toolti const joinedOthersTrigger = joinedRow.getByRole("button", { name: "2 others", }); + await page.mouse.move(0, 0); await expect(joinedOthersTrigger).toHaveCSS("text-decoration-line", "none"); await joinedOthersTrigger.hover(); - await expect(page.getByRole("tooltip")).toContainText("Olivia Park"); - await expect(page.getByRole("tooltip")).toContainText("Sam Rivera"); + // Scope to the *open* tooltip: the first row's tooltip stays mounted with + // data-state="closed" while it animates out, so a bare role=tooltip lookup + // matches two elements and trips strict mode. + const joinedTooltip = page.locator( + '[role="tooltip"]:not([data-state="closed"])', + ); + await expect(joinedTooltip).toContainText("Olivia Park"); + await expect(joinedTooltip).toContainText("Sam Rivera"); }); test("system agent profile only exposes message action", async ({ page }) => { @@ -1277,7 +1289,7 @@ test("system agent profile only exposes message action", async ({ page }) => { const joinedRow = page .getByTestId("system-message-row") .filter({ hasText: "mira" }) - .filter({ hasText: "was added by" }); + .filter({ hasText: "added by" }); const agentName = joinedRow.getByText("mira", { exact: true }); await expect(agentName).toHaveText("mira"); await expect(agentName).not.toHaveAttribute("data-mention"); diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 72554ee1a1..0372690e1e 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -279,7 +279,12 @@ class _MembershipSystemMessageContent extends StatelessWidget { TextSpan( text: event.isSelfJoin ? 'joined the channel' - : 'was added by ${resolveLabel(event.actorPubkey)}', + // No "was": the name renders on the line above via + // MessageAuthorMeta, so this reads as a status line rather than a + // sentence continuing across the metadata row. Matches desktop's + // SystemMessageRow. `SystemEvent.describe` keeps "was added by" + // because it builds subject and predicate into one string. + : 'added by ${resolveLabel(event.actorPubkey)}', ), if (additionalTargets.isNotEmpty) TextSpan(text: event.isSelfJoin ? ' along with ' : ', along with '), diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 253c949703..c8fd96491b 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -101,9 +101,10 @@ class SystemEvent { final target = resolveLabel(targetPubkey); return '$actor removed $target from the channel'; }(), - SystemEventType.topicChanged => '$actor changed the topic to "$topic"', + SystemEventType.topicChanged => + '$actor ${_describeTextFieldChange('topic', topic)}', SystemEventType.purposeChanged => - '$actor changed the purpose to "$purpose"', + '$actor ${_describeTextFieldChange('purpose', purpose)}', SystemEventType.channelCreated => '$actor created this channel', SystemEventType.channelArchived => '$actor archived this channel', SystemEventType.channelUnarchived => '$actor unarchived this channel', @@ -113,6 +114,25 @@ class SystemEvent { } } +/// Caption fragment for a channel topic or purpose change, e.g. +/// `changed the topic to "Release planning"` or `cleared the topic`. +/// +/// A blank value means the field was cleared: the relay reports a clear as a +/// `topic_changed` / `purpose_changed` event carrying an empty string, not as a +/// separate event type. Without this branch the timeline renders +/// `changed the topic to ""`, which reads as if the topic were set to two quote +/// marks. Whitespace-only values are treated as cleared for the same reason. +/// +/// Mirrors `describeChannelTextFieldChange` in +/// `desktop/src/features/messages/lib/systemEventCopy.ts`. +String _describeTextFieldChange(String field, String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return 'cleared the $field'; + } + return 'changed the $field to "$trimmed"'; +} + @immutable class TimelineReaction { final String emoji; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 1c66093899..899394de23 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1348,7 +1348,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Bob'), findsOneWidget); - final addedAction = findRichText('was added by Alice'); + final addedAction = findRichText('added by Alice'); expect(addedAction, findsOneWidget); expect(find.text('Alice added Bob to the channel'), findsNothing); expect( @@ -1366,7 +1366,7 @@ void main() { expect(timestampRect.left, greaterThan(nameRect.right)); final addedText = tester.widget(addedAction); expect( - effectiveFontSizeForText(addedText.text, 'was added by Alice'), + effectiveFontSizeForText(addedText.text, 'added by Alice'), systemMessageBodyTextStyle.fontSize, ); }); @@ -1435,7 +1435,7 @@ void main() { expect(find.text('Bob'), findsOneWidget); expect( - findRichText('was added by Alice, along with Carol, Dave, Erin, and '), + findRichText('added by Alice, along with Carol, Dave, Erin, and '), findsOneWidget, ); expect(find.byKey(const Key('membership-overflow')), findsOneWidget); diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 87f39d1c65..c29a12aef4 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -287,6 +287,53 @@ void main() { ); }); + // The relay reports a clear as a change carrying an empty string, so + // without the cleared branch this reads: changed the topic to "". + test('a blank topic or purpose reads as cleared', () { + for (final blank in [null, '', ' \n\t ']) { + expect( + SystemEvent( + type: SystemEventType.topicChanged, + actorPubkey: 'pk1', + topic: blank, + ).describe(resolve), + 'Alice cleared the topic', + ); + expect( + SystemEvent( + type: SystemEventType.purposeChanged, + actorPubkey: 'pk1', + purpose: blank, + ).describe(resolve), + 'Alice cleared the purpose', + ); + } + }); + + test('no caption announces empty quotes', () { + for (final value in [null, '', ' ', 'Real topic']) { + expect( + SystemEvent( + type: SystemEventType.topicChanged, + actorPubkey: 'pk1', + topic: value, + ).describe(resolve), + isNot(contains('""')), + ); + } + }); + + test('surrounding whitespace is trimmed out of the quotes', () { + expect( + SystemEvent( + type: SystemEventType.topicChanged, + actorPubkey: 'pk1', + topic: ' Release v2 ', + ).describe(resolve), + 'Alice changed the topic to "Release v2"', + ); + }); + test('channel_created', () { final event = SystemEvent( type: SystemEventType.channelCreated, From f3e5e812677f6f14bffe16a7aa02642d56faca4b Mon Sep 17 00:00:00 2001 From: Alex Kemper Date: Thu, 30 Jul 2026 18:54:41 -0400 Subject: [PATCH 85/99] fix(catalog): update Amp tagline (#3806) ## Summary Update Amp's runtime catalog description to use its current tagline: > The coding agent and development environment that runs anywhere and everywhere. ### Related issue N/A. This follows the Amp description update in https://github.com/block/buzz/pull/3758. ### Testing * `pnpm -C desktop check` * `pnpm -C desktop typecheck` * `pnpm -C desktop test` (3,835 passed) No screenshot is included because this changes only the catalog description text. It does not change layout or interaction behavior. Signed-off-by: AJKemps Co-authored-by: AJKemps Co-authored-by: Alex Kemper --- desktop/src/features/settings/ui/harnessCatalogCopy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/settings/ui/harnessCatalogCopy.ts b/desktop/src/features/settings/ui/harnessCatalogCopy.ts index 70439091b2..9a71ff70f9 100644 --- a/desktop/src/features/settings/ui/harnessCatalogCopy.ts +++ b/desktop/src/features/settings/ui/harnessCatalogCopy.ts @@ -36,7 +36,7 @@ const HARNESS_DESCRIPTIONS: Record = { // https://moonshotai.github.io/kimi-cli/en/ kimi: "A terminal coding agent for software development and command-line tasks.", // Sources: https://ampcode.com, https://ampcode.com/manual - amp: "A coding agent for your terminal and editor.", + amp: "The coding agent and development environment that runs anywhere and everywhere.", // Sources: https://github.com/NousResearch/hermes-agent, // https://hermes-agent.nousresearch.com/docs/ hermes: "A general-purpose AI agent from Nous Research.", From 468647a51f858b29d27eaf9fd07bf90294f99d39 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:58:03 -0400 Subject: [PATCH 86/99] feat(desktop): locally stored NIP-49 encrypted key backup (#2937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a locally stored **NIP-49 encrypted key backup** (`ncryptsec`) to the desktop app, per the plan reviewed in buzz-development (Rev 3, approved 9/10 by Wren; implementation also reviewed and approved 9/10). **Two-artifact design — canonical bytes originate entirely in Rust:** - `create_ncryptsec_backup` runs under the `identity_mutation` lock: encrypt → decrypt-verify against the live pubkey → atomic `0o600` write to `{app_data_dir}/identity.ncryptsec` → reread/byte-compare → return the exact persisted bytes. The frontend never re-derives or re-encrypts. - `save_ncryptsec_copy` writes a portable copy via the save dialog (parse-gated, secret-file semantics) and never mutates canonical state. - `generate_backup_passphrase`: 6 words from the EFF short wordlist via `OsRng` (custom passphrases min 12 chars). - Import accepts `ncryptsec1` with optional password; the raw-`nsec` path is untouched. Different-pubkey import and sign-out wipe the app-managed backup (post-commit, best-effort — a failed import can never destroy the still-live identity's backup; regression-tested). **Never-relay guarantee (egress guard + tripwires):** - `egress_guard.rs` fail-closed at all 8 `/events` submission boundaries (relay submit funnel, 3× `relay.rs`, huddle STT, both engram submitters, native WS choke point), rejecting `ncryptsec1`/`NCRYPTSEC1` in text and binary frames. Scope is deliberately ncryptsec-only: pairing intentionally carries raw nsec inside its encrypted session. - Site-granular `/events` inventory tripwire: per-file (`/events` count, guard-call count) pairs; unlisted files expect zero. Mutation-style tests prove a ninth site in an existing file, a removed guard, and a new unlisted file all fail the scan. - ncryptsec source-allowlist scans in **both** trees (Rust + TS). **Frontend:** onboarding `BackupStep` is encrypted-by-default — the default path never invokes `get_nsec` (e2e asserts the command log). Raw-nsec export stays behind an explicit click with prior semantics. Shared `EncryptedBackupCreator` powers onboarding + a new settings row; the import form auto-switches to encrypted mode on `ncryptsec1` paste (case-insensitive HRP). **Open product call for @tlongwell-block:** onboarding default is *encrypted* in this PR; flipping to raw-default is a small change either way (documented in the plan). Review history: plan Rev 3 and the implementation were both iterated with Wren to 9/10 (two blockers from round 1 — import ordering, inventory granularity — plus an uppercase-bech32 hardening gap, all fixed in `dde37183e`). Thread: buzz-development. ### Related issue Follow-up to the direction explored in #385 (NIP-PB, closed) — this ships local NIP-49 (the standard) instead of a new NIP. No open duplicate found. ### Testing All at exactly `dde37183e` (same shell, HEAD verified): - `cargo test` — 1680 passed / 0 failed / 14 ignored (includes a deliberate ~70s log_n-18 NIP-49 round trip, spec vector, wrong-password, NFKC, uppercase-vector decrypt, injection test per egress boundary, inventory mutation tests, import-ordering regression tests) - `cargo clippy --all-targets -- -D warnings` — clean; `cargo fmt --check` — clean - `pnpm typecheck` — clean; JS unit suite 3529/3529; biome (repo-pinned 2.4.16) clean - Playwright `onboarding-backup` / `onboarding` / `onboarding-agent-defaults` / `profile-nsec-reveal` — 86 passed, 1 known avatar-reservation flake (passed on rerun; untouched by this diff). `passThroughBackupStep` now exercises the encrypted default, so every downstream onboarding spec covers the new path. - Note: browser e2e fakes the crypto via the mock bridge (fixed spec-vector blob); decryption correctness is proven in the Rust tests. ## Latest onboarding integration The current head adds an additive `IdentityInfo.storage` field (`ephemeral`, `system-keyring`, `local-file`, or `environment`) so onboarding can accurately explain where the active identity is protected. It surfaces storage metadata only—never key material—and leaves the existing lost/keyring-locked recovery behavior intact. --------- Signed-off-by: Tyler Longwell Signed-off-by: Taylor Ho Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell Co-authored-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- .../texture-card/generate-card-texture.mjs | 159 ++-- desktop/src-tauri/src/app_state.rs | 97 +- desktop/src-tauri/src/app_state_tests.rs | 12 +- desktop/src-tauri/src/commands/identity.rs | 117 ++- desktop/src-tauri/src/identity_storage.rs | 62 ++ desktop/src-tauri/src/key_backup.rs | 47 + desktop/src-tauri/src/key_backup_tests.rs | 79 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/models.rs | 2 + desktop/src-tauri/src/reset.rs | 20 + .../onboarding/lib/encryptedBackup.test.mjs | 118 +++ .../onboarding/lib/encryptedBackup.ts | 135 +++ .../onboarding/lib/keyImportInput.test.mjs | 73 ++ .../features/onboarding/lib/keyImportInput.ts | 127 +++ .../onboarding/ui/BackupPasswordTimeline.tsx | 130 +++ .../src/features/onboarding/ui/BackupStep.tsx | 495 +++++++--- .../features/onboarding/ui/BackupTestFlow.tsx | 745 +++++++++++++++ .../onboarding/ui/DownloadKeyStep.tsx | 139 +++ .../onboarding/ui/EncryptedBackupCreator.tsx | 885 ++++++++++++++++++ .../onboarding/ui/KeyringLockedScreen.tsx | 4 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 158 +++- .../onboarding/ui/NostrKeyImportForm.tsx | 561 ++++++----- .../onboarding/ui/OnboardingChrome.tsx | 15 +- .../features/onboarding/ui/OnboardingFlow.tsx | 4 +- .../ui/OnboardingSlideTransition.tsx | 1 + .../src/features/onboarding/ui/SetupStep.tsx | 41 +- .../ui/onboardingFlowSteps.test.mjs | 24 +- .../features/settings/ui/SignOutSection.tsx | 38 +- desktop/src/shared/api/identityTypes.ts | 28 + desktop/src/shared/api/tauriIdentity.ts | 11 +- desktop/src/shared/api/types.ts | 20 +- .../shared/lib/ncryptsecSourceScan.test.mjs | 74 ++ .../src/shared/styles/globals/components.css | 85 +- desktop/src/shared/ui/alert-dialog.tsx | 69 +- .../shared/ui/assets/card-texture-compact.png | Bin 0 -> 232377 bytes .../ui/assets/card-texture-dark-compact.png | Bin 0 -> 328178 bytes .../shared/ui/assets/card-texture-dark.png | Bin 0 -> 1678571 bytes desktop/src/shared/ui/card-texture.css | 47 +- desktop/src/shared/ui/card.tsx | 46 +- desktop/src/shared/ui/popover.tsx | 73 +- desktop/src/testing/e2eBridge.ts | 65 +- desktop/tests/e2e/harness-management.spec.ts | 9 +- desktop/tests/e2e/onboarding-backup.spec.ts | 333 ++++++- .../onboarding-docked-cta-screenshots.spec.ts | 99 +- desktop/tests/e2e/onboarding.spec.ts | 125 +++ .../tests/e2e/signout-confirmation.spec.ts | 34 +- desktop/tests/helpers/fileDrag.ts | 52 + desktop/tests/helpers/onboarding.ts | 3 +- 48 files changed, 4728 insertions(+), 734 deletions(-) create mode 100644 desktop/src-tauri/src/identity_storage.rs create mode 100644 desktop/src/features/onboarding/lib/encryptedBackup.test.mjs create mode 100644 desktop/src/features/onboarding/lib/encryptedBackup.ts create mode 100644 desktop/src/features/onboarding/lib/keyImportInput.test.mjs create mode 100644 desktop/src/features/onboarding/lib/keyImportInput.ts create mode 100644 desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx create mode 100644 desktop/src/features/onboarding/ui/BackupTestFlow.tsx create mode 100644 desktop/src/features/onboarding/ui/DownloadKeyStep.tsx create mode 100644 desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx create mode 100644 desktop/src/shared/api/identityTypes.ts create mode 100644 desktop/src/shared/lib/ncryptsecSourceScan.test.mjs create mode 100644 desktop/src/shared/ui/assets/card-texture-compact.png create mode 100644 desktop/src/shared/ui/assets/card-texture-dark-compact.png create mode 100644 desktop/src/shared/ui/assets/card-texture-dark.png create mode 100644 desktop/tests/helpers/fileDrag.ts diff --git a/desktop/scripts/texture-card/generate-card-texture.mjs b/desktop/scripts/texture-card/generate-card-texture.mjs index 75cc24e744..57ebc61a9b 100644 --- a/desktop/scripts/texture-card/generate-card-texture.mjs +++ b/desktop/scripts/texture-card/generate-card-texture.mjs @@ -12,83 +12,116 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const OUTPUT = path.resolve( - HERE, - "../../src/shared/ui/assets/card-texture.png", -); - -// CSS-pixel source geometry. Screenshotting at DPR 2 produces a crisp asset. -const CARD_SIZE = 640; -const OUTSET = 96; -const CAPTURE_SIZE = CARD_SIZE + OUTSET * 2; +const OUTPUT_DIRECTORY = path.resolve(HERE, "../../src/shared/ui/assets"); const DPR = 2; // Approved texture parameters, archived from the former runtime SVG filter. -const BLUR = 66; -const DILATE = Math.round(BLUR * 0.85); const THRESHOLD_BIAS = 0.302; const SLOPE = 8; const FREQUENCY = 0.999; const OCTAVES = 3; const SEED = 5315; -await mkdir(path.dirname(OUTPUT), { recursive: true }); +const TEXTURES = [ + { + filename: "card-texture.png", + color: "white", + cardSize: 640, + outset: 96, + blur: 66, + innerBand: 112, + }, + { + filename: "card-texture-dark.png", + color: "#171b21", + cardSize: 640, + outset: 96, + blur: 66, + innerBand: 112, + }, + { + filename: "card-texture-compact.png", + color: "white", + cardSize: 320, + outset: 24, + blur: 24, + innerBand: 44, + }, + { + filename: "card-texture-dark-compact.png", + color: "#171b21", + cardSize: 320, + outset: 24, + blur: 24, + innerBand: 44, + }, +]; + +await mkdir(OUTPUT_DIRECTORY, { recursive: true }); const browser = await chromium.launch(); try { - const page = await browser.newPage({ - deviceScaleFactor: DPR, - viewport: { height: CAPTURE_SIZE, width: CAPTURE_SIZE }, - }); + for (const texture of TEXTURES) { + const captureSize = texture.cardSize + texture.outset * 2; + const dilate = Math.round(texture.blur * 0.85); + const output = path.join(OUTPUT_DIRECTORY, texture.filename); + const page = await browser.newPage({ + deviceScaleFactor: DPR, + viewport: { height: captureSize, width: captureSize }, + }); - await page.setContent(` - -

- - -
`); + await page.setContent(` + +
+ + +
`); - await page.locator("#stage").screenshot({ - omitBackground: true, - path: OUTPUT, - }); + await page.locator("#stage").screenshot({ + omitBackground: true, + path: output, + }); + await page.close(); + + console.log(`Generated ${output}`); + console.log(`Asset: ${captureSize * DPR}×${captureSize * DPR}px @${DPR}x`); + console.log( + `Runtime slice: ${(texture.outset + texture.innerBand) * DPR}px; outset: ${texture.outset}px`, + ); + } } finally { await browser.close(); } - -console.log(`Generated ${OUTPUT}`); -console.log(`Asset: ${CAPTURE_SIZE * DPR}×${CAPTURE_SIZE * DPR}px @${DPR}x`); -console.log(`Runtime slice: ${(OUTSET + 112) * DPR}px; outset: ${OUTSET}px`); diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 94d162e620..abce86202a 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, io::Write, sync::{ - atomic::{AtomicBool, AtomicU16}, + atomic::{AtomicBool, AtomicU16, AtomicU8}, Arc, Mutex, }, }; @@ -13,10 +13,15 @@ use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; +pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity}; use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; + pub struct AppState { pub keys: Mutex, + /// Durable backend holding `keys`. Updated after the key write and before + /// recovery flags are cleared so `get_identity` reports a consistent state. + pub(crate) identity_storage: AtomicU8, pub http_client: reqwest::Client, /// A no-redirect client for authenticated relay media fetches (download, /// clipboard copy, snapshot, editor). Every caller pre-validates the URL @@ -178,19 +183,20 @@ pub fn build_media_fetch_client() -> reqwest::Result { pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. - let keys = match identity_from_env() { + let (keys, identity_storage) = match identity_from_env() { Some(keys) => { eprintln!( "buzz-desktop: configured identity pubkey {}", keys.public_key().to_hex() ); - keys + (keys, IdentityStorage::Environment) } - None => Keys::generate(), + None => (Keys::generate(), IdentityStorage::Ephemeral), }; AppState { keys: Mutex::new(keys), + identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) .pool_idle_timeout(std::time::Duration::from_secs(10)) @@ -366,9 +372,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let resolved = load_or_create_identity(&data_dir)?; - // Write keys before setting the recovery flags (Release) so any thread - // that reads a flag as false with Acquire is guaranteed to see the keys. - *state.keys.lock().map_err(|e| e.to_string())? = resolved.keys; + // Write keys and storage before setting the recovery flags (Release) so + // any thread that reads a flag as false with Acquire sees consistent data. + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = resolved.keys; + state.set_identity_storage(resolved.storage); + } state.identity_lost.store( resolved.recovery == RecoveryState::Lost, std::sync::atomic::Ordering::Release, @@ -394,26 +404,6 @@ const IDENTITY_KEY_NAME: &str = "identity"; /// keyring is merely unreachable (the key IS in the keyring, must NOT generate). const MIGRATION_MARKER_NAME: &str = "identity.migrated"; -/// Recovery state produced by identity resolution. `None` means the app has -/// a real, usable identity. `Lost` means the keyring was reachable-but-empty -/// despite a prior successful migration — the key vanished externally. `KeyringLocked` -/// means the keyring is unreachable this boot but was used in the past -/// (marker present, no file) — the key still exists but is temporarily -/// inaccessible. Both non-`None` variants boot with an ephemeral key; the -/// frontend shows a different recovery screen for each. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RecoveryState { - None, - Lost, - KeyringLocked, -} - -/// The output of identity resolution. -struct ResolvedIdentity { - keys: Keys, - recovery: RecoveryState, -} - /// The keyring operations the identity resolution flow needs. Abstracted so the /// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be /// unit-tested against a fake without touching the live OS keyring. @@ -465,6 +455,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result Result<(), String> { +) -> Result { match persist_identity_to_keyring(store, keys, legacy_path, data_dir) { - Ok(()) => Ok(()), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(e) => { eprintln!( "buzz-desktop: keyring write failed during import ({e}), \ falling back to identity.key" ); - save_key_file(legacy_path, keys) + save_key_file(legacy_path, keys)?; + Ok(IdentityStorage::LocalFile) } } } @@ -892,7 +897,7 @@ pub(crate) fn persist_imported_identity( keys: &Keys, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result<(), String> { +) -> Result { persist_imported_identity_impl(store, keys, legacy_path, data_dir) } @@ -920,15 +925,6 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("commit migration marker: {e}")) } -/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes -/// the migration marker only after a keyring success — on the file-fallback arm -/// the key is on disk and a marker would wrongly trip the next Unreachable boot -/// into failing closed. -enum PersistBackend { - Keyring, - File, -} - /// Generate a fresh identity, persist it through the store, return it. /// /// On a keyring-backed persist no file is written, so a later @@ -940,9 +936,10 @@ fn generate_and_persist( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result { +) -> Result<(Keys, IdentityStorage), String> { let keys = Keys::generate(); - if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? { + let storage = store_key_preferring_keyring(store, &keys, legacy_path)?; + if storage == IdentityStorage::SystemKeyring { let marker_path = migration_marker_path(data_dir); if let Err(e) = write_migration_marker(&marker_path) { eprintln!( @@ -956,7 +953,7 @@ fn generate_and_persist( "buzz-desktop: generated and saved identity pubkey {}", keys.public_key().to_hex() ); - Ok(keys) + Ok((keys, storage)) } /// Persist `keys` through the store, silently falling back to the `0o600` file @@ -968,17 +965,17 @@ fn store_key_preferring_keyring( store: &impl IdentityKeyStore, keys: &Keys, legacy_path: &std::path::Path, -) -> Result { +) -> Result { let nsec = keys .secret_key() .to_bech32() .map_err(|e| format!("encode nsec: {e}"))?; match store.store(IDENTITY_KEY_NAME, &nsec) { - Ok(()) => Ok(PersistBackend::Keyring), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(keyring_err) => { eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback"); save_key_file(legacy_path, keys)?; - Ok(PersistBackend::File) + Ok(IdentityStorage::LocalFile) } } } diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index 485dfaea15..751bcf22e5 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -484,7 +484,7 @@ fn fresh_keyring_generate_writes_marker() { let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); // The key was stored in the keyring (not the file), and the marker marks it. - assert!(!legacy_path.exists()); + assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring); assert!(migration_marker_path(dir.path()).exists()); assert_eq!( store @@ -541,7 +541,10 @@ fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() { let from_file = load_key_file(&legacy_path).unwrap(); assert_key_eq(&resolved.keys, &from_file); // No marker: the file is the authoritative store, not the keyring. - assert!(!migration_marker_path(dir.path()).exists()); + assert!( + !migration_marker_path(dir.path()).exists() + && resolved.storage == IdentityStorage::LocalFile + ); } // ── New tests for the three defects fixed in this PR ───────────────────── @@ -786,10 +789,7 @@ fn persist_imported_identity_falls_back_to_file_on_keyring_failure() { let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path()); // The policy core handles the keyring failure — Ok, not Err. - assert!( - result.is_ok(), - "must not propagate keyring failure when file fallback succeeds" - ); + assert_eq!(result.unwrap(), IdentityStorage::LocalFile); // Key is recoverable from the file on next boot. let from_file = load_key_file(&legacy_path).unwrap(); diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 142e3bac88..33ecf3cfca 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -43,6 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: state.identity_storage().as_str().to_string(), lost, locked, reset_failed, @@ -334,11 +335,17 @@ pub async fn save_ncryptsec_copy( #[tauri::command] pub async fn import_identity( nsec: String, + password: Option, app_handle: tauri::AppHandle, ) -> Result { tokio::task::spawn_blocking(move || { - let trimmed = nsec.trim(); - let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; + // NIP-49 backups require a passphrase and decrypt entirely in Rust. + // Raw nsec/hex input follows the existing parser path unchanged. + let password = password.map(zeroize::Zeroizing::new); + let keys = crate::key_backup::recover_keys_from_input( + &nsec, + password.as_ref().map(|value| value.as_str()), + )?; // Serialize against persist_current_identity: hold this guard for the // full function body so a concurrent stale persist can't overwrite @@ -353,30 +360,14 @@ pub async fn import_identity( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); - // Persist into the OS keyring first (store → read-back verify → marker → - // delete file). Falls back to the 0o600 file when the keyring is - // unavailable; returns Err only when both backends fail. - let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - - // Update in-memory keys BEFORE clearing recovery flags. The Release - // stores below pair with Acquire loads in get_identity: a reader - // observing false is guaranteed to see the updated keys. - let pubkey = keys.public_key(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; - - // Clear both recovery flags — an import is valid in either lost or - // keyring-locked state and resolves both. In the locked case the - // keyring is unreachable, so persist_imported_identity already fell - // back to identity.key; on the next Unreachable boot the file is - // loaded directly and when the keyring returns the adoption path - // picks it up. - state - .identity_lost - .store(false, std::sync::atomic::Ordering::Release); - state - .keyring_locked - .store(false, std::sync::atomic::Ordering::Release); + let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| { + // Persist into the OS keyring first (store → read-back verify → + // marker → delete file). Falls back to the 0o600 file when the + // keyring is unavailable; returns Err only when both backends fail. + let store = + crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + })?; let pubkey_hex = pubkey.to_hex(); let display_name = truncated_display_name(&pubkey)?; @@ -386,6 +377,7 @@ pub async fn import_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, @@ -395,6 +387,69 @@ pub async fn import_identity( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Commit an imported identity: durably persist, swap in-memory keys, clear +/// recovery flags, then remove the previous identity's stale app-managed +/// backup. Caller must hold `state.identity_mutation`. +/// +/// Ordering is the contract: +/// +/// 1. `persist` runs FIRST. If it fails (`Err` from both keyring and file +/// fallback), nothing has changed — the previous identity stays live in +/// memory AND its valid canonical `identity.ncryptsec` stays on disk. +/// 2. Only after durable persistence do we swap `state.keys` and clear the +/// recovery flags. +/// 3. Stale-backup cleanup runs LAST and is deliberately best-effort: at that +/// point the import is durably committed, so reporting a cleanup failure +/// as a command `Err` would claim a half-applied import that actually +/// succeeded. The leftover blob is still passphrase-encrypted and is +/// replaced by the next backup creation; we log and move on. +fn commit_imported_identity( + state: &AppState, + data_dir: &std::path::Path, + keys: nostr::Keys, + persist: impl FnOnce(&nostr::Keys) -> Result, +) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> { + // Capture the previous pubkey up front for post-commit cleanup. + let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); + + let storage = persist(&keys)?; + + // Update in-memory keys BEFORE clearing recovery flags. The Release + // stores below pair with Acquire loads in get_identity: a reader + // observing false is guaranteed to see the updated keys. + let pubkey = keys.public_key(); + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = keys; + state.set_identity_storage(storage); + } + + // Clear both recovery flags — an import is valid in either lost or + // keyring-locked state and resolves both. In the locked case the + // keyring is unreachable, so the persist step already fell back to + // identity.key; on the next Unreachable boot the file is loaded + // directly and when the keyring returns the adoption path picks it up. + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + state + .keyring_locked + .store(false, std::sync::atomic::Ordering::Release); + + // Importing a different identity invalidates the app-managed backup: it + // encrypts the previous key and must not linger mislabeled. Best-effort + // per the ordering contract above. + if let Err(e) = crate::key_backup::cleanup_stale_backup(&previous_pubkey, &pubkey, data_dir) { + eprintln!( + "buzz-desktop: import committed, but stale key backup cleanup failed: {e}; \ + the leftover identity.ncryptsec encrypts the PREVIOUS key and will be \ + replaced by the next backup creation" + ); + } + + Ok((pubkey, storage)) +} + /// Make the current ephemeral identity durable by persisting it to the OS /// keyring (or falling back to identity.key). This is called when the user /// chooses to start a new identity instead of re-importing their previous one @@ -438,11 +493,12 @@ pub async fn persist_current_identity( let key_path = data_dir.join("identity.key"); let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; + let storage = + crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - // Keys are already the live identity — only clear identity_lost. - // Release pairs with Acquire in get_identity so readers see - // consistent state. + // Keys are already the live identity. Record where the durable write + // landed before clearing identity_lost. + state.set_identity_storage(storage); state .identity_lost .store(false, std::sync::atomic::Ordering::Release); @@ -454,6 +510,7 @@ pub async fn persist_current_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, diff --git a/desktop/src-tauri/src/identity_storage.rs b/desktop/src-tauri/src/identity_storage.rs new file mode 100644 index 0000000000..b39c1a0331 --- /dev/null +++ b/desktop/src-tauri/src/identity_storage.rs @@ -0,0 +1,62 @@ +use nostr::Keys; + +use crate::app_state::AppState; + +/// Durable location of the active human identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum IdentityStorage { + Ephemeral = 0, + SystemKeyring = 1, + LocalFile = 2, + Environment = 3, +} + +impl IdentityStorage { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Ephemeral => "ephemeral", + Self::SystemKeyring => "system-keyring", + Self::LocalFile => "local-file", + Self::Environment => "environment", + } + } + + fn from_u8(value: u8) -> Self { + match value { + 1 => Self::SystemKeyring, + 2 => Self::LocalFile, + 3 => Self::Environment, + _ => Self::Ephemeral, + } + } +} + +impl AppState { + pub(crate) fn identity_storage(&self) -> IdentityStorage { + IdentityStorage::from_u8( + self.identity_storage + .load(std::sync::atomic::Ordering::Acquire), + ) + } + + pub(crate) fn set_identity_storage(&self, storage: IdentityStorage) { + self.identity_storage + .store(storage as u8, std::sync::atomic::Ordering::Release); + } +} + +/// Recovery state produced by identity resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecoveryState { + None, + Lost, + KeyringLocked, +} + +/// Identity and persistence metadata produced by startup resolution. +pub(crate) struct ResolvedIdentity { + pub(crate) keys: Keys, + pub(crate) recovery: RecoveryState, + pub(crate) storage: IdentityStorage, +} diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index 6396911aef..f97bf95a67 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -13,6 +13,10 @@ use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; use nostr::{FromBech32, Keys, ToBech32}; +/// Bech32 prefix of NIP-49 encrypted secret keys. Import routing is +/// case-insensitive because bech32 permits all-uppercase encodings. +pub const NCRYPTSEC_HRP: &str = "ncryptsec1"; + /// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB). /// The blob self-describes its cost, so this can be raised later without /// breaking existing backups. @@ -108,6 +112,27 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { Ok(Keys::new(secret_key)) } +/// Recover identity keys from either an encrypted NIP-49 backup or the raw +/// nsec/hex formats accepted before encrypted imports were added. +pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result { + let trimmed = input.trim(); + let is_ncryptsec = trimmed + .get(..NCRYPTSEC_HRP.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(NCRYPTSEC_HRP)); + + if is_ncryptsec { + let password = password.ok_or_else(|| "key backup requires a password".to_string())?; + decrypt_ncryptsec(trimmed, password) + } else { + Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}")) + } +} + +/// Path of the canonical app-managed backup file. +pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf { + data_dir.join(BACKUP_FILE_NAME) +} + /// Atomically write `ncryptsec` to `path` with owner-only permissions, then /// reread and byte-compare. Same crash-safety pattern as /// `app_state::save_key_file`. @@ -140,6 +165,28 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), Ok(()) } +/// Delete the app-managed backup if present. Missing files are already clean. +pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> { + let path = backup_file_path(data_dir); + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("delete stale backup file: {e}")), + } +} + +/// Remove the app-managed backup only when an import changes identities. +pub fn cleanup_stale_backup( + previous: &nostr::PublicKey, + new: &nostr::PublicKey, + data_dir: &std::path::Path, +) -> Result<(), String> { + if previous != new { + delete_backup_file(data_dir)?; + } + Ok(()) +} + /// Generate a passphrase of `word_count` EFF short-wordlist words joined by /// `separator`, using OS entropy. /// diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index e5892ad99e..b9713201e1 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -79,12 +79,59 @@ fn verify_backup_blob_catches_pubkey_mismatch() { assert!(err.contains("does not match identity"), "{err}"); } +// ── Import key recovery ─────────────────────────────────────────────────────── + +#[test] +fn recover_keys_ncryptsec_happy_path() { + let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); +} + +#[test] +fn recover_keys_ncryptsec_requires_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err(); + assert_eq!(err, "key backup requires a password"); +} + +#[test] +fn recover_keys_ncryptsec_wrong_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err(); + assert_eq!(err, "wrong backup password or damaged key backup"); +} + +#[test] +fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() { + let upper = SPEC_NCRYPTSEC.to_ascii_uppercase(); + assert_eq!( + recover_keys_from_input(&upper, None).unwrap_err(), + "key backup requires a password" + ); + let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); + + let mut mixed = SPEC_NCRYPTSEC.to_string(); + mixed.replace_range(0..1, "N"); + let err = recover_keys_from_input(&mixed, Some("nostr")).unwrap_err(); + assert!(err.contains("invalid ncryptsec"), "{err}"); +} + +#[test] +fn recover_keys_raw_nsec_path_unchanged() { + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + let recovered = recover_keys_from_input(&nsec, None).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + assert!(recover_keys_from_input("garbage", None).is_err()); +} + // ── File lifecycle ──────────────────────────────────────────────────────────── #[test] fn write_backup_file_persists_0600_and_verifies() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(BACKUP_FILE_NAME); + let path = backup_file_path(dir.path()); write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); let on_disk = std::fs::read_to_string(&path).unwrap(); @@ -101,7 +148,7 @@ fn write_backup_file_persists_0600_and_verifies() { #[test] fn write_backup_file_overwrites_atomically() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(BACKUP_FILE_NAME); + let path = backup_file_path(dir.path()); write_backup_file(&path, "ncryptsec1old").unwrap(); write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); @@ -113,6 +160,34 @@ fn write_backup_file_overwrites_atomically() { assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); } +#[test] +fn delete_backup_file_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + delete_backup_file(dir.path()).unwrap(); + let path = backup_file_path(dir.path()); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + delete_backup_file(dir.path()).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn cleanup_stale_backup_removes_only_on_identity_change() { + let dir = tempfile::tempdir().unwrap(); + let path = backup_file_path(dir.path()); + let a = Keys::generate().public_key(); + let b = Keys::generate().public_key(); + + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + cleanup_stale_backup(&a, &a, dir.path()).unwrap(); + assert!(path.exists(), "same identity must keep the backup"); + + cleanup_stale_backup(&a, &b, dir.path()).unwrap(); + assert!( + !path.exists(), + "identity change must remove the stale backup" + ); +} + #[test] fn generated_passphrase_respects_word_count_and_separator() { let words: std::collections::HashSet<&str> = diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7dcc5994ae..ee2a98f5c1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod egress_guard; mod event_sync; mod events; mod huddle; +mod identity_storage; mod key_backup; mod linux_media; mod managed_agents; diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc20..3f04d3d7a1 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct IdentityInfo { pub pubkey: String, pub display_name: String, + /// Durable location of the active identity key. + pub storage: String, /// True when the app booted with an ephemeral key because the OS keyring /// was empty despite a prior successful migration (key was externally /// deleted). The frontend routes to the nsec re-import step when true. diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index d2e35e6839..18ddd80eb8 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -463,6 +463,26 @@ mod tests { assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once"); } + // ── NIP-49: the boot wipe destroys the app-managed key backup ───────────── + + #[test] + fn test_wipe_removes_app_managed_key_backup() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let backup = crate::key_backup::backup_file_path(&app_data); + std::fs::write(&backup, b"encrypted-backup-bytes").unwrap(); + + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false)); + + assert!(outcome.completed); + assert!( + !backup.exists(), + "sign-out wipe must destroy the app-managed key backup" + ); + } + // ── Test 3: keychain failure keeps sentinel ──────────────────────────────── #[test] diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs new file mode 100644 index 0000000000..b570153185 --- /dev/null +++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; +const reduce = (events, from = initialEncryptedBackupState) => + events.reduce(encryptedBackupReducer, from); +test("password validation mirrors Rust character counting", () => { + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + assert.equal( + effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])), + emoji, + ); +}); +test("valid password requests encryption without copying it into events", () => { + const ready = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + ]); + assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four"); + const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready); + assert.equal(isEncrypting(started), true); + assert.equal(started.requestId, 1); + assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); +}); +test("background encryption remains silent until download is clicked", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.passphrase, "one-two-three-four"); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.ncryptsec, null); + assert.equal(state.savedPassword, false); + assert.equal(state.requestId, null); +}); +test("stale async completions cannot replace current request", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + { type: "encrypt-started", requestId: 2 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" }, + ]); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); +}); +test("failure clears submitted password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.createError, "keychain unavailable"); + assert.equal(state.downloadPending, false); + assert.equal(downloadDisabled(state), true); +}); +test("queued download commits and clears password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("Back preserves blob for immediate re-download without password", () => { + const made = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + { type: "back-to-password" }, + ]); + assert.equal(made.ncryptsec, "ncryptsec1abc"); + assert.equal(made.passphrase, ""); + assert.equal(downloadDisabled(made), false); +}); +test("starting over discards blob and invalidates late requests", () => { + const made = { + ...initialEncryptedBackupState, + ncryptsec: "ncryptsec1abc", + encrypted: "ncryptsec1abc", + savedPassword: true, + nextRequestId: 3, + }; + const fresh = reduce([{ type: "start-new-backup" }], made); + assert.equal(fresh.ncryptsec, null); + assert.equal(fresh.nextRequestId, 4); + assert.equal( + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, + ); +}); diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts new file mode 100644 index 0000000000..2f7d4a0bb0 --- /dev/null +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -0,0 +1,135 @@ +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; + createError: string | null; + downloadPending: boolean; + ncryptsec: string | null; + savedPassword: boolean; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, + createError: null, + downloadPending: false, + ncryptsec: null, + savedPassword: false, +}; + +export type EncryptedBackupEvent = + | { type: "set-passphrase"; value: string } + | { type: "encrypt-started"; requestId: number } + | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } + | { type: "encrypt-failed"; requestId: number; message: string } + | { type: "download-clicked" } + | { type: "back-to-password" } + | { type: "start-new-backup" }; + +export function encryptedBackupReducer( + state: EncryptedBackupState, + event: EncryptedBackupEvent, +): EncryptedBackupState { + switch (event.type) { + case "set-passphrase": + return { + ...state, + passphrase: event.value, + encrypted: null, + createError: null, + }; + case "encrypt-started": + return { + ...state, + requestId: event.requestId, + nextRequestId: Math.max(state.nextRequestId, event.requestId + 1), + createError: null, + }; + case "encrypt-succeeded": + if (event.requestId !== state.requestId) return state; + if (state.downloadPending) { + return { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: event.ncryptsec, + downloadPending: false, + savedPassword: true, + }; + } + return { + ...state, + requestId: null, + encrypted: event.ncryptsec, + }; + case "encrypt-failed": + if (event.requestId !== state.requestId) return state; + return { + ...state, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + }; + case "download-clicked": + if ( + state.ncryptsec || + state.downloadPending || + (!state.encrypted && !effectivePassphrase(state)) + ) + return state; + return state.encrypted + ? { + ...state, + ncryptsec: state.encrypted, + passphrase: "", + savedPassword: true, + } + : { ...state, downloadPending: true }; + case "back-to-password": + return { ...state, createError: null }; + case "start-new-backup": + return { + ...initialEncryptedBackupState, + nextRequestId: state.nextRequestId + 1, + }; + } +} + +export function passphraseIssue(passphrase: string): string | null { + if (passphrase.length === 0) return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; +} +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + return [...state.passphrase].length < MIN_PASSPHRASE_LEN + ? null + : state.passphrase; +} +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if (state.savedPassword || state.encrypted || state.requestId !== null) + return null; + return effectivePassphrase(state); +} +export function isEncrypting(state: EncryptedBackupState): boolean { + return state.requestId !== null; +} +export function downloadDisabled(state: EncryptedBackupState): boolean { + if (state.savedPassword && state.ncryptsec) return false; + return ( + state.downloadPending || + (!state.encrypted && effectivePassphrase(state) === null) + ); +} diff --git a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs new file mode 100644 index 0000000000..bc0bb4b4d7 --- /dev/null +++ b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs @@ -0,0 +1,73 @@ +/** + * Pure-logic tests for key-import input classification (nsec vs NIP-49 + * ncryptsec) and submit gating. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey } from "nostr-tools/pure"; +import { + classifyKeyImportInput, + isPlausibleNcryptsec, + keyImportSubmitEnabled, + NCRYPTSEC_ENCODED_LENGTH, +} from "./keyImportInput.ts"; + +// NIP-49 spec vector — structurally valid encrypted backup. +const NCRYPTSEC = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +const VALID_NSEC = nsecEncode(generateSecretKey()); + +test("classify_by_hrp_with_whitespace_tolerance", () => { + assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec"); + assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec"); + assert.equal(classifyKeyImportInput("npub1whatever"), "unknown"); + assert.equal(classifyKeyImportInput(""), "unknown"); + // nsec must not be shadowed by the longer HRP check. + assert.equal(classifyKeyImportInput("nsec1"), "nsec"); +}); + +test("uppercase_bech32_encoding_classifies_and_gates_like_lowercase", () => { + // Bech32 permits an all-uppercase encoding; it must route to the + // encrypted path (matching Rust) and be submit-plausible. + const upper = NCRYPTSEC.toUpperCase(); + assert.equal(classifyKeyImportInput(upper), "ncryptsec"); + assert.equal(isPlausibleNcryptsec(upper), true); + assert.equal(keyImportSubmitEnabled(upper, ""), false); + assert.equal(keyImportSubmitEnabled(upper, "hunter2hunter2"), true); + // Mixed case: routed encrypted (Rust reports the accurate error) but + // never plausible/submittable — mixed-case bech32 cannot decode. + const mixed = `N${NCRYPTSEC.slice(1)}`; + assert.equal(classifyKeyImportInput(mixed), "ncryptsec"); + assert.equal(isPlausibleNcryptsec(mixed), false); + assert.equal(keyImportSubmitEnabled(mixed, "hunter2hunter2"), false); +}); + +test("plausible_ncryptsec_requires_complete_checksummed_nip49_payload", () => { + assert.equal(NCRYPTSEC.length, NCRYPTSEC_ENCODED_LENGTH); + assert.equal(isPlausibleNcryptsec(NCRYPTSEC), true); + assert.equal(isPlausibleNcryptsec(` ${NCRYPTSEC}\n`), true); + assert.equal(isPlausibleNcryptsec(NCRYPTSEC.slice(0, -1)), false); + assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC}q`), false); + // Same length and charset, but a changed checksum must not advance the UI. + assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC.slice(0, -1)}q`), false); + // '1' and 'b' / 'i' / 'o' are not in the Bech32 data charset. + assert.equal(isPlausibleNcryptsec("ncryptsec1bio"), false); + assert.equal(isPlausibleNcryptsec("ncryptsec1"), false); + assert.equal(isPlausibleNcryptsec("ncryptsec1 with spaces"), false); +}); + +test("submit_gating_nsec_path_unchanged", () => { + assert.equal(keyImportSubmitEnabled(VALID_NSEC, ""), true); + assert.equal(keyImportSubmitEnabled("nsec1garbage", ""), false); + assert.equal(keyImportSubmitEnabled("", ""), false); +}); + +test("submit_gating_ncryptsec_requires_passphrase", () => { + assert.equal(keyImportSubmitEnabled(NCRYPTSEC, ""), false); + assert.equal(keyImportSubmitEnabled(NCRYPTSEC, "hunter2hunter2"), true); + // Structurally implausible blob never submits, passphrase or not. + assert.equal(keyImportSubmitEnabled("ncryptsec1bio", "hunter2"), false); +}); diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts new file mode 100644 index 0000000000..0f6fc609ed --- /dev/null +++ b/desktop/src/features/onboarding/lib/keyImportInput.ts @@ -0,0 +1,127 @@ +/** + * Pure classification + submit gating for the key-import form, unit-testable + * without a DOM. + * + * `ncryptsec1…` is a NIP-49 encrypted backup: no npub preview is possible + * (the pubkey is inside the encrypted payload) and a passphrase is required. + * Password validation happens in Rust at decrypt time; this module performs + * the password-independent Bech32 and NIP-49 structure checks needed to decide + * when the form can safely switch modes. + */ + +import { nsecToNpub } from "@/shared/lib/nostrUtils"; + +export type KeyImportKind = "nsec" | "ncryptsec" | "unknown"; + +const NCRYPTSEC_HRP = "ncryptsec"; +const NIP49_VERSION = 2; +const NIP49_PAYLOAD_BYTES = 91; +/** Current NIP-49 payloads encode to 162 characters including the checksum. */ +export const NCRYPTSEC_ENCODED_LENGTH = 162; +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_GENERATORS = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3, +] as const; + +function bech32Polymod(values: readonly number[]): number { + let checksum = 1; + for (const value of values) { + const high = checksum >>> 25; + checksum = ((checksum & 0x1ffffff) << 5) ^ value; + for (let index = 0; index < BECH32_GENERATORS.length; index += 1) { + if ((high >>> index) & 1) checksum ^= BECH32_GENERATORS[index]; + } + } + return checksum >>> 0; +} + +function expandBech32Hrp(hrp: string): number[] { + return [ + ...Array.from(hrp, (character) => character.charCodeAt(0) >>> 5), + 0, + ...Array.from(hrp, (character) => character.charCodeAt(0) & 31), + ]; +} + +function convertFiveBitWordsToBytes(words: readonly number[]): number[] | null { + let accumulator = 0; + let bitCount = 0; + const bytes: number[] = []; + + for (const word of words) { + accumulator = (accumulator << 5) | word; + bitCount += 5; + while (bitCount >= 8) { + bitCount -= 8; + bytes.push((accumulator >>> bitCount) & 0xff); + } + } + + // Bech32 conversion without padding permits fewer than five zero remainder + // bits. Any larger or non-zero remainder is not a canonical byte encoding. + if (bitCount >= 5 || ((accumulator << (8 - bitCount)) & 0xff) !== 0) { + return null; + } + return bytes; +} + +export function classifyKeyImportInput(input: string): KeyImportKind { + const trimmed = input.trim(); + // Case-insensitive on the HRP to match the Rust classifier: an uppercase + // valid backup routes to the encrypted path (and decodes there); mixed + // case routes there too and fails in Rust with the accurate error. + if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec"; + if (trimmed.startsWith("nsec1")) return "nsec"; + return "unknown"; +} + +/** + * Password-independent NIP-49 validation used for the automatic UI transition. + * A candidate must have canonical casing and length, a valid Bech32 checksum, + * and the current 91-byte/version-2 NIP-49 payload shape. + */ +export function isPlausibleNcryptsec(input: string): boolean { + const trimmed = input.trim(); + if (trimmed.length !== NCRYPTSEC_ENCODED_LENGTH) return false; + if (trimmed !== trimmed.toLowerCase() && trimmed !== trimmed.toUpperCase()) { + return false; + } + + const normalized = trimmed.toLowerCase(); + const separatorIndex = normalized.lastIndexOf("1"); + if ( + separatorIndex !== NCRYPTSEC_HRP.length || + normalized.slice(0, separatorIndex) !== NCRYPTSEC_HRP + ) { + return false; + } + + const encoded = normalized.slice(separatorIndex + 1); + const words = Array.from(encoded, (character) => + BECH32_CHARSET.indexOf(character), + ); + if (words.some((word) => word < 0) || words.length <= 6) return false; + if (bech32Polymod([...expandBech32Hrp(NCRYPTSEC_HRP), ...words]) !== 1) { + return false; + } + + const payload = convertFiveBitWordsToBytes(words.slice(0, -6)); + return ( + payload?.length === NIP49_PAYLOAD_BYTES && payload[0] === NIP49_VERSION + ); +} + +/** + * Whether the import form's submit should be enabled. + * nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase. + */ +export function keyImportSubmitEnabled( + input: string, + passphrase: string, +): boolean { + const kind = classifyKeyImportInput(input); + if (kind === "ncryptsec") { + return isPlausibleNcryptsec(input) && passphrase.length > 0; + } + return nsecToNpub(input) !== null; +} diff --git a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx new file mode 100644 index 0000000000..610c104d95 --- /dev/null +++ b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx @@ -0,0 +1,130 @@ +import { FileKey2, LockKeyhole, LockOpen } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; + +import { cn } from "@/shared/lib/cn"; + +const BACKUP_KEY_DOTS = [ + "key-dot-1", + "key-dot-2", + "key-dot-3", + "key-dot-4", + "key-dot-5", + "key-dot-6", + "key-dot-7", + "key-dot-8", + "key-dot-9", +] as const; + +const TIMELINE_CONNECTOR_DOTS = [ + "connector-dot-1", + "connector-dot-2", + "connector-dot-3", + "connector-dot-4", +] as const; + +const TIMELINE_DOT_INITIAL = { opacity: 0.35, scale: 0.85 }; +const TIMELINE_DOT_PULSE = { + opacity: [0.35, 1, 0.35], + scale: [0.85, 1.25, 0.85], +}; +const TIMELINE_DOT_TRANSITION = { + duration: 0.7, + ease: "easeInOut" as const, + repeat: Number.POSITIVE_INFINITY, + repeatDelay: 1.2, +}; +const TIMELINE_TOP_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map( + (_, index) => ({ + ...TIMELINE_DOT_TRANSITION, + delay: index * 0.16, + }), +); +const TIMELINE_BOTTOM_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map( + (_, index) => ({ + ...TIMELINE_DOT_TRANSITION, + delay: (index + TIMELINE_CONNECTOR_DOTS.length) * 0.16 + 0.24, + }), +); + +/** + * Decorative timeline shared by backup creation and encrypted-backup restore. + * Backup creation reads key → password → lock; restore reads encrypted file → + * password → unlocked account. The password field is layered over the center. + */ +export function BackupPasswordTimeline({ + className, + mode = "backup", +}: { + className?: string; + mode?: "backup" | "restore"; +}) { + const reduceMotion = useReducedMotion() ?? false; + + return ( +
+ {mode === "restore" ? ( +
+ +
+ ) : ( +
+ {BACKUP_KEY_DOTS.map((dot) => ( + + ))} +
+ )} +
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ {mode === "restore" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index ed2184baaa..99d9c6324d 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,183 +1,438 @@ -import { AlertTriangle, Info, RefreshCw } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, Info, ShieldCheck } from "lucide-react"; +import { useReducedMotion } from "motion/react"; import * as React from "react"; import { getNsec } from "@/shared/api/tauriIdentity"; +import type { IdentityStorage } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; +import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import { Card } from "@/shared/ui/card"; import { Spinner } from "@/shared/ui/spinner"; -import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { + ONBOARDING_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; import { OnboardingFooter } from "./OnboardingFooter"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; /** - * Pure helper so the disabled logic can be unit-tested without a DOM. - * - * Disabled while loading (key not fetched yet) or after a failed load (only - * the explicit "Skip for now" ghost advances past an error). + * How long the "Creating your identity key" loader holds the stage before the + * finished state fades in. Purely perceptual — the key already exists; the + * pause sells the creation moment. */ -export function backupNextDisabled({ - isLoading, - loadError, -}: { - isLoading: boolean; - loadError: string | null; -}): boolean { - return isLoading || loadError !== null; +const INTRO_HOLD_MS = 1400; + +/** + * The creation moment should only be sold once per app session. Module-level + * so remounts (e.g. navigating Back and returning to this step) skip the fake + * hold and show the finished state instantly. + */ +let introPlayed = false; + +const REVEAL_ANIMATION_CLASS = + "animate-in fade-in duration-700 motion-reduce:animate-none"; + +const BACKUP_OPTION_CLASS = + "flex min-h-48 w-full flex-col items-start justify-start px-6 py-5 text-left text-foreground"; + +/** Viewing the key never blocks onboarding — Next is always actionable. */ +export function backupNextDisabled(): boolean { + return false; } type BackupStepProps = { direction: OnboardingTransitionDirection; + identityStorage?: IdentityStorage; onBack: () => void; onNext: () => void; + onOpenPasswordBackup: () => void; + onShowOptions: () => void; + optionsExpanded: boolean; + returningFromSecurity: boolean; }; /** - * Onboarding backup step — shows the user their freshly created key so they - * can save it somewhere safe. Only shown on the fresh-key path. + * Onboarding identity-key step — shows the freshly created key, then opens a + * dark backup-options state. Copy fetches the raw key only after an explicit + * click; password backup opens the separate security flow. Neither method + * blocks Next. */ -export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { +export function BackupStep({ + direction, + identityStorage, + onBack, + onNext, + onOpenPasswordBackup, + onShowOptions, + optionsExpanded, + returningFromSecurity, +}: BackupStepProps) { + const reduceMotion = useReducedMotion() ?? false; + const [created, setCreated] = React.useState(introPlayed || reduceMotion); + const [copyState, setCopyState] = React.useState< + "idle" | "copying" | "copied" + >("idle"); + const [copyError, setCopyError] = React.useState(null); const [nsec, setNsec] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(true); - const [loadError, setLoadError] = React.useState(null); + const [isRevealed, setIsRevealed] = React.useState(false); const cancelledRef = React.useRef(false); + const copiedTimerRef = React.useRef(null); - const loadNsec = React.useCallback(async () => { - setIsLoading(true); - setLoadError(null); - try { - const value = await getNsec(); - if (!cancelledRef.current) setNsec(value); - } catch (err) { - if (!cancelledRef.current) - setLoadError( - err instanceof Error - ? err.message - : "Failed to retrieve private key.", - ); - } finally { - if (!cancelledRef.current) setIsLoading(false); + React.useEffect(() => { + if (introPlayed) return; + if (reduceMotion) { + introPlayed = true; + setCreated(true); + return; } - }, []); + const timer = window.setTimeout(() => { + introPlayed = true; + setCreated(true); + }, INTRO_HOLD_MS); + return () => window.clearTimeout(timer); + }, [reduceMotion]); React.useEffect(() => { cancelledRef.current = false; - void loadNsec(); return () => { // Back-during-fetch: cancel any in-flight setState calls and clear the // nsec from memory on unmount (backup step is only on the fresh-key path). cancelledRef.current = true; setNsec(null); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); }; - }, [loadNsec]); + }, []); + + const copyKeyToClipboard = React.useCallback(async () => { + setCopyState("copying"); + setCopyError(null); + try { + const value = nsec ?? (await getNsec()); + await writeTextToClipboard(value); + if (cancelledRef.current) return; + setCopyState("copied"); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = window.setTimeout(() => { + if (!cancelledRef.current) setCopyState("idle"); + }, 2000); + } catch (err) { + if (cancelledRef.current) return; + setCopyState("idle"); + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [nsec]); + + const toggleReveal = React.useCallback(async () => { + if (isRevealed) { + setIsRevealed(false); + return; + } + setCopyError(null); + try { + // The raw key enters the DOM only after this explicit reveal action. + const value = nsec ?? (await getNsec()); + if (cancelledRef.current) return; + setNsec(value); + setIsRevealed(true); + } catch (err) { + if (cancelledRef.current) return; + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [isRevealed, nsec]); + + // Fixed-length decorative mask (nsec keys are 63 chars) so no key material + // is fetched just to render the blurred row. Bullets are joined with a + // zero-width space: WebKit won't line-break a run of U+2022 without an + // explicit break opportunity, so the masked row would overflow otherwise. + const maskedKey = React.useMemo( + () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"), + [nsec], + ); + const storageDescription = + identityStorage === "system-keyring" + ? "Buzz keeps your identity key in your system keychain. Your computer may ask for your password when Buzz needs to read the key." + : identityStorage === "local-file" + ? "Your system keychain wasn’t available, so Buzz keeps your identity key in a private file on this device." + : "Buzz keeps your identity key protected on this device. Make a separate backup in case you lose access."; + const storageTitle = + identityStorage === "system-keyring" + ? "Protected by your system keychain" + : identityStorage === "local-file" + ? "Stored in private device storage" + : "Protected in private device storage"; + const introStorageDescription = + identityStorage === "system-keyring" + ? "Buzz keeps your identity key in your system keychain." + : identityStorage === "local-file" + ? "Buzz keeps your identity key in a private file on this device because the system keychain wasn’t available." + : "Your identity key is protected on this device."; + + if (optionsExpanded) { + return ( + +
+

+ Backup options +

+

+ Your identity key works like a password for your Buzz account. Keep + a copy somewhere safe. You can create a backup file and lock it with + a password you can remember. +

+
+ +
+
+
+ {storageTitle} + + {storageDescription} + +
+ +
+ + Saved in your password manager + + + Copy your identity key, then save it in a password manager like + 1Password. + + +
+ +
+ + Locked in a backup file + + + Create a backup file and choose a password you can remember. + You’ll need both to restore your account. + + +
+
+ + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can continue + and find it later in Settings > Profile > Identity. +

+ ) : null} +
+
+ ); + } return (
-

- Your unique identity key has been created + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {created + ? "Your unique identity key has been created" + : "Creating your identity key"}

-

- This key is stored in your system keychain, but save it some place - safe in case you ever need to restore your account. -

-
- -
- {isLoading ? ( -
- - Loading your private key… -
- ) : loadError ? ( -
-
- - - Could not retrieve your private key: {loadError}. You can - continue and find it later in Settings > Profile > - Identity. - -
- -
- ) : nsec ? ( - -
- -
-
- ) : ( -

- No key available to back up. -

- )} - - {nsec ? ( -

- - - Never share your private key. Anyone with this key can impersonate - you and access everything in your account. - + review backup options + {" "} + for ways to restore your account.

) : null}
- - + + + ) : ( +
+
+ +
+
+

+ {isRevealed && nsec ? nsec : maskedKey} +

+
+ +
+
+ + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null} - {loadError ? ( +

+ + + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. + +

+
+
+ )} + + {created ? ( + - ) : null} - - + +
+ ) : null}
); } diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx new file mode 100644 index 0000000000..9370d5c061 --- /dev/null +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -0,0 +1,745 @@ +import { Check, CircleHelp, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; +import { createPortal } from "react-dom"; + +import { + getNsec, + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; + +type BackupTestStage = "drop" | "password" | "success"; + +/** + * Durable progress through the test flow. Owned by the host so navigating + * away (e.g. onboarding Back) and returning doesn't force the user to + * re-drop the file. The password attempt is deliberately NOT part of this + * state — it lives only in short-lived component state and is cleared the + * moment it's submitted or the component unmounts. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** Contents of the accepted file, pending or past verification. */ + ncryptsec: string | null; + /** The Rust-verified public identity once decryption succeeded. */ + result: BackupVerification | null; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + ncryptsec: null, + result: null, +}; + +type BackupTestFlowProps = { + /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ + variant?: "spotlight" | "boxed"; + /** + * When supplied, only this exact just-created file is accepted — the + * onboarding ceremony proves the user saved *that* backup. Without it the + * flow is a general-purpose tester for any key backup file. + */ + expectedNcryptsec?: string; + /** Re-open the native save dialog for another copy of the backup file. */ + onSaveCopy?: () => void; + isSaving?: boolean; + saveError?: string | null; + /** Optional onboarding footer target for the verification CTA. */ + verifyButtonPortal?: HTMLElement | null; + /** Host-owned progress so it survives this component unmounting. */ + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; + /** Fired once when the user completes the test successfully. */ + onVerified?: () => void; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; +const VERIFICATION_CONNECTOR_DOTS = [ + "verification-dot-1", + "verification-dot-2", + "verification-dot-3", + "verification-dot-4", +] as const; +const VERIFICATION_DOT_ANIMATION = { + opacity: [0.35, 1, 0.35], + scale: [0.85, 1.25, 0.85], +}; +const VERIFICATION_DOT_TRANSITION = { + duration: 0.7, + ease: "easeInOut" as const, + repeat: Number.POSITIVE_INFINITY, + repeatDelay: 1.2, +}; +const PRIVATE_KEY_MASK = Array.from({ length: 63 }, () => "•").join("\u200b"); + +type BurstParticle = { + id: number; + x: number; + y: number; + emoji: string; + delay: number; + scale: number; + rotate: number; +}; + +/** + * One-shot radial emoji burst behind the success badge. Purely decorative — + * skipped entirely under reduced motion. + */ +function SuccessBurst() { + const particles = React.useMemo( + () => + Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => { + const angle = + (i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5; + const distance = 70 + Math.random() * 80; + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + emoji: BURST_EMOJIS[i % BURST_EMOJIS.length], + delay: Math.random() * 0.18, + scale: 0.8 + Math.random() * 0.7, + rotate: -120 + Math.random() * 240, + }; + }), + [], + ); + + return ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +function VerificationConnector({ + delayOffset, + reduceMotion, +}: { + delayOffset: number; + reduceMotion: boolean; +}) { + return ( +
+ {VERIFICATION_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ ); +} + +/** + * "Test your backup" flow: the user drops a backup file onto a large + * dropzone, then enters its password. Verification is a real NIP-49 decrypt + * in Rust — the submitted password is cleared immediately after the result + * and only the derived public identity ever comes back. + */ +export function BackupTestFlow({ + variant = "spotlight", + expectedNcryptsec, + onSaveCopy, + isSaving = false, + saveError, + verifyButtonPortal, + progress, + onProgressChange, + onVerified, +}: BackupTestFlowProps) { + const reduceMotion = useReducedMotion() ?? false; + const { stage, fileName, ncryptsec, result } = progress; + // True while a file drag is anywhere over the window — the drop overlay + // takes over the host surface only for the duration of the drag. + const [isWindowDragging, setIsWindowDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); + + React.useEffect(() => { + // dragenter/dragleave fire per nested element, so track depth to know + // when the drag has actually left the window. + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsWindowDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsWindowDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsWindowDragging(false); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + // The password attempt is component-local, never host state: it is cleared + // when verification is submitted and when this component unmounts. + const [attempt, setAttempt] = React.useState(""); + const [error, setError] = React.useState(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const [successNsec, setSuccessNsec] = React.useState(null); + const [isSuccessNsecRevealed, setIsSuccessNsecRevealed] = + React.useState(false); + const [isLoadingSuccessNsec, setIsLoadingSuccessNsec] = React.useState(false); + const [successNsecError, setSuccessNsecError] = React.useState( + null, + ); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Opaque correlation id so a stale in-flight verification can't commit + // after "Use a different file" or unmount. + const requestRef = React.useRef(0); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestRef.current += 1; + setAttempt(""); + }; + }, []); + + React.useEffect(() => { + if (stage === "password") passwordInputRef.current?.focus(); + }, [stage]); + + const handleFile = React.useCallback( + async (file: File) => { + let text: string; + try { + text = (await file.text()).trim(); + } catch { + if (mountedRef.current) setError("Could not read that file."); + return; + } + if (!mountedRef.current) return; + if (!text.toLowerCase().startsWith("ncryptsec1")) { + setError( + expectedNcryptsec + ? "That doesn't look like your key backup. Choose the file you just downloaded." + : "That doesn't look like a key backup file.", + ); + return; + } + if (expectedNcryptsec && text !== expectedNcryptsec.trim()) { + setError("That's a key backup, but not the one you just downloaded."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [expectedNcryptsec, onProgressChange], + ); + + const handleVerify = React.useCallback(async () => { + if (!ncryptsec || !attempt || isVerifying) return; + const password = attempt; + const requestId = ++requestRef.current; + setIsVerifying(true); + setError(null); + setIsRevealed(false); + // Clear the attempt the moment it's handed to Rust — success or failure, + // the typed password never lingers in the field. + setAttempt(""); + try { + const verified = await verifyNcryptsecBackup(ncryptsec, password); + if (!mountedRef.current || requestId !== requestRef.current) return; + onProgressChange((prev) => ({ + ...prev, + stage: "success", + result: verified, + })); + onVerified?.(); + } catch (err) { + if (mountedRef.current && requestId === requestRef.current) + setError( + err instanceof Error ? err.message : "Could not verify this backup.", + ); + } finally { + if (mountedRef.current && requestId === requestRef.current) + setIsVerifying(false); + } + }, [attempt, isVerifying, ncryptsec, onProgressChange, onVerified]); + + const toggleSuccessNsec = React.useCallback(async () => { + if (isSuccessNsecRevealed) { + setIsSuccessNsecRevealed(false); + return; + } + if (successNsec) { + setIsSuccessNsecRevealed(true); + return; + } + setIsLoadingSuccessNsec(true); + setSuccessNsecError(null); + try { + const value = await getNsec(); + if (!mountedRef.current) return; + setSuccessNsec(value); + setIsSuccessNsecRevealed(true); + } catch (err) { + if (!mountedRef.current) return; + setSuccessNsecError( + err instanceof Error ? err.message : "Could not retrieve your key.", + ); + } finally { + if (mountedRef.current) setIsLoadingSuccessNsec(false); + } + }, [isSuccessNsecRevealed, successNsec]); + + const isSpotlight = variant === "spotlight"; + + if (stage === "success" && result) { + // The onboarding ceremony pins the exact file, so a success there is by + // construction the current identity — celebrate and move on. The general + // tester reports which identity the backup unlocks. + const isCeremony = Boolean(expectedNcryptsec); + return ( +
+ {reduceMotion ? null : } + + + + {isCeremony ? ( +
+

+ Your backup works! +

+

+ File and password verified. Keep them both somewhere safe — + that's all you need to restore your identity. +

+
+

+ {isSuccessNsecRevealed && successNsec + ? successNsec + : PRIVATE_KEY_MASK} +

+ +
+ {successNsecError ? ( +

+ {successNsecError} +

+ ) : null} +
+ ) : ( + <> +

+ This backup works +

+

+ {result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+
+ +
+ + )} +
+ {isCeremony ? null : ( + + )} +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + + { + const file = event.target.files?.[0]; + // Allow re-selecting the same file after an error. + event.target.value = ""; + if (file) void handleFile(file); + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> + + {isWindowDragging ? ( + /* + * Composer-style takeover: fills the nearest positioned host + * surface (the onboarding card / the settings backup row) and is + * itself the drop target, so anywhere on that surface accepts + * the file. + */ + // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {onSaveCopy ? ( +
+ +
+ ) : null} + {saveError ? ( +

{saveError}

+ ) : null} +
+ ) : ( + + {(() => { + const fileRow = ( +
+ + + + {fileName} + +
+ ); + const passwordField = ( +
+ setAttempt(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleVerify(); + } + }} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); + if (!isSpotlight) { + return ( + <> + {fileRow} +

+ Enter the password to prove you can unlock this backup. +

+ {passwordField} + + ); + } + return ( +
+
+ ); + })()} + {(() => { + const verifyButton = ( + + ); + if (verifyButtonPortal === undefined) { + return ( +
{verifyButton}
+ ); + } + return verifyButtonPortal + ? createPortal(verifyButton, verifyButtonPortal) + : null; + })()} +
+ )} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx new file mode 100644 index 0000000000..3d69150049 --- /dev/null +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -0,0 +1,139 @@ +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; +import { OnboardingFooter } from "./OnboardingFooter"; +import { + type OnboardingTransitionDirection, + OnboardingSlideTransition, +} from "./OnboardingSlideTransition"; +import { + type EncryptedBackupSession, + EncryptedBackupCreator, +} from "./EncryptedBackupCreator"; + +type DownloadKeyStepProps = { + direction: OnboardingTransitionDirection; + /** Backup state owned by the parent flow across the creation and test views. */ + session: EncryptedBackupSession; + onBack: () => void; +}; + +/** + * Password-backup security subview within the identity-key onboarding step. + * The raw key never enters this component: Rust builds the NIP-49 payload + * locally and the native save dialog produces the user-owned file. + */ +export function DownloadKeyStep({ + direction, + session, + onBack, +}: DownloadKeyStepProps) { + const reduceMotion = useReducedMotion() ?? false; + // Once the encrypted payload is saved, the creator advances to its guided + // backup test while this surface keeps its own navigation. + const hasCreated = session.created; + const hasVerifiedBackup = session.verified; + const hasSelectedBackup = session.test.stage === "password"; + const [primaryActionSlot, setPrimaryActionSlot] = + React.useState(null); + + return ( + + + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {hasVerifiedBackup + ? "Your backup is verified" + : hasSelectedBackup + ? "That’s your backup file" + : hasCreated + ? "Optionally, test your backup" + : "Backup your key with a password"} +

+

+ {hasVerifiedBackup + ? "Your file and password can restore your identity." + : hasSelectedBackup + ? "Now enter your password to prove you can unlock it." + : hasCreated + ? "Learn how your backup works. Drop the file you just saved and unlock it with your password." + : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."} +

+
+ +
+
+ +
+ +
+
+
+
+ + +
+ + + + ); +} diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx new file mode 100644 index 0000000000..bb76166bd7 --- /dev/null +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -0,0 +1,885 @@ +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; +import * as React from "react"; +import { createPortal } from "react-dom"; + +import { + createNcryptsecBackup, + generateBackupPassphrase, + saveNcryptsecCopy, +} from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + downloadDisabled, + passphraseIssue, + pendingEncryptPassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, + MIN_PASSPHRASE_LEN, + type EncryptedBackupEvent, + type EncryptedBackupState, +} from "../lib/encryptedBackup"; +import { + type BackupTestProgress, + BackupTestFlow, + initialBackupTestProgress, +} from "./BackupTestFlow"; +import { BackupPasswordTimeline } from "./BackupPasswordTimeline"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Pause after the last keystroke before the background KDF starts, so typing + * past the minimum length doesn't launch an encryption per character. + */ +const ENCRYPT_DEBOUNCE_MS = 400; + +const PENDING_TICKER_MESSAGES = [ + "Downloading once finished", + "Encrypting your password", + "Just a bit longer...", +] as const; + +/** How long each ticker message holds before sliding to the next. */ +const PENDING_TICKER_INTERVAL_MS = 2500; + +/** Matches the `duration-300` slide transition on the ticker column. */ +const PENDING_TICKER_SLIDE_MS = 300; + +/** + * Vertical ticker for the queued-download button label — cycles through the + * pending messages by sliding a stacked column inside a one-line viewport. + * The column ends with a clone of the first message, so the wrap-around + * slides up from the bottom like every other step; once the clone settles, + * the column snaps (transition disabled) back to the real first row. All + * lines render at all times, so the button keeps the width of the longest + * message instead of resizing on each swap. + */ +function PendingDownloadTicker() { + // Index into the rendered column (messages + trailing clone of the first). + const [position, setPosition] = React.useState(0); + const [snap, setSnap] = React.useState(false); + + React.useEffect(() => { + const timer = window.setInterval( + () => setPosition((current) => current + 1), + PENDING_TICKER_INTERVAL_MS, + ); + return () => window.clearInterval(timer); + }, []); + + // The clone is visually identical to the first message: once its slide-in + // finishes, jump back to the real first row without animating. + React.useEffect(() => { + if (position !== PENDING_TICKER_MESSAGES.length) return; + const timer = window.setTimeout(() => { + setSnap(true); + setPosition(0); + }, PENDING_TICKER_SLIDE_MS); + return () => window.clearTimeout(timer); + }, [position]); + + // Re-enable the transition one frame after the snap has painted. + React.useEffect(() => { + if (!snap) return; + const raf = window.requestAnimationFrame(() => setSnap(false)); + return () => window.cancelAnimationFrame(raf); + }, [snap]); + + // The clone row duplicates the first message's text, so it carries its own + // stable key. + const column = [ + ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })), + { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] }, + ]; + + return ( + + + {column.map((row) => ( + + {row.message} + + ))} + + + ); +} + +/** + * Everything about an in-progress backup that must survive this component + * unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the + * backup test passed, where the file was saved, the save-once guard, and the + * test-flow progress. Hosts that need the state to outlive the creator (the + * onboarding flow, where Back unmounts the step) call + * `useEncryptedBackupSession` at a longer-lived level and pass it down; + * otherwise the creator owns a private session internally. + */ +export type EncryptedBackupSession = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + /** + * True once the encrypted payload has been committed AND saved to disk. + * Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching + * the blob itself — keeping them outside the ncryptsec confinement scan. + */ + created: boolean; + /** True once the user has passed the backup test. */ + verified: boolean; + setVerified: React.Dispatch>; + savedPath: string | null; + setSavedPath: React.Dispatch>; + /** The committed blob a save was already kicked off for (save-once guard). */ + savedForRef: React.MutableRefObject; + test: BackupTestProgress; + setTest: React.Dispatch>; +}; + +/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */ +export function useEncryptedBackupSession(): EncryptedBackupSession { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [verified, setVerified] = React.useState(false); + const [savedPath, setSavedPath] = React.useState(null); + const savedForRef = React.useRef(null); + const [test, setTest] = React.useState( + initialBackupTestProgress, + ); + return React.useMemo( + () => ({ + state, + dispatch, + created: state.ncryptsec !== null && savedPath !== null, + verified, + setVerified, + savedPath, + setSavedPath, + savedForRef, + test, + setTest, + }), + [state, verified, savedPath, test], + ); +} + +/** + * Return to a secure saved-password placeholder. The encrypted blob survives + * for instant re-download, while no password or test attempt is retained. + */ +export function backupSessionToPasswordEntry( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "back-to-password" }); + session.setVerified(false); + session.setSavedPath(null); + session.setTest(initialBackupTestProgress); +} + +/** Discard all backup-creation and verification progress. */ +export function resetEncryptedBackupSession( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "start-new-backup" }); + session.setVerified(false); + session.setSavedPath(null); + session.savedForRef.current = null; + session.setTest(initialBackupTestProgress); +} + +type EncryptedBackupCreatorProps = { + /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ + variant?: "spotlight" | "boxed"; + /** + * When set, the "Download" button is portaled into this element instead of + * rendering inline. + */ + createButtonPortal?: HTMLElement | null; + /** Optional onboarding footer target for the guided-test verification CTA. */ + verifyButtonPortal?: HTMLElement | null; + /** Extra classes for the "Download" button. */ + createButtonClassName?: string; + /** + * Host-owned session so the backup state survives this component + * unmounting (onboarding Back navigation). Omitted = private session. + */ + session?: EncryptedBackupSession; + /** Fired once the encrypted payload has been created (before saving). */ + onCreated?: () => void; + /** Fired only after the encrypted key file has been saved successfully. */ + onSaved?: (path: string) => void; + /** Whether creation continues into onboarding's guided test ceremony. */ + guidedTest?: boolean; + /** Fired once when the user completes the backup test successfully. */ + onVerified?: () => void; +}; + +/** + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, + securityTheme = false, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; + securityTheme?: boolean; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow shared by onboarding and + * Settings. The raw private key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly. Background encryption is silent; clicking + * mid-encryption reveals the queued-download ticker until the KDF finishes. + */ +export function EncryptedBackupCreator({ + variant = "spotlight", + createButtonPortal, + verifyButtonPortal, + createButtonClassName, + session: sessionProp, + onCreated, + onSaved, + guidedTest = true, + onVerified, +}: EncryptedBackupCreatorProps) { + // Hosts without a longer-lived session get a private one (settings card). + const fallbackSession = useEncryptedBackupSession(); + const session = sessionProp ?? fallbackSession; + const { state, dispatch, savedPath, setSavedPath, savedForRef } = session; + const [isRevealed, setIsRevealed] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const [isSaving, setIsSaving] = React.useState(false); + const [confirmNewPassword, setConfirmNewPassword] = React.useState(false); + const mountedRef = React.useRef(true); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // A queued download locks the form — mask the password too so it isn't + // left readable on screen while the user waits for the save dialog. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + // Correlate KDF completion by an opaque request id. The password exists only + // in this short-lived effect closure and is cleared from reducer state once + // Rust returns; stale completions cannot commit. + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let cancelled = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelled) return; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }), + ) + .catch((err: unknown) => + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }), + ); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [dispatch, pendingPassphrase, skipDebounce, state.nextRequestId]); + + // Download commit: fires once per committed blob, whether the commit was + // instant (encryption already done) or resolved a queued download. The flow + // only advances to the test view once the file is actually on disk — a + // canceled save dialog or a save failure rolls the commit back to the + // password form so "Download backup" can be clicked again. + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || savedForRef.current === ncryptsec) return; + savedForRef.current = ncryptsec; + onCreated?.(); + setIsSaving(true); + setSaveError(null); + const rollBack = () => { + savedForRef.current = null; + dispatch({ type: "back-to-password" }); + }; + void saveNcryptsecCopy(ncryptsec) + .then((path) => { + if (path) { + setSavedPath(path); + onSaved?.(path); + } else { + // User canceled the native save dialog — nothing was downloaded. + rollBack(); + } + }) + .catch((err: unknown) => { + rollBack(); + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + }) + .finally(() => { + if (mountedRef.current) setIsSaving(false); + }); + }, [ + dispatch, + onCreated, + onSaved, + savedForRef, + setSavedPath, + state.ncryptsec, + ]); + + const handleSaveCopy = React.useCallback(async () => { + if (!state.ncryptsec || isSaving) return; + setIsSaving(true); + setSaveError(null); + try { + const path = await saveNcryptsecCopy(state.ncryptsec); + if (mountedRef.current && path) { + setSavedPath(path); + onSaved?.(path); + } + } catch (err) { + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, [isSaving, onSaved, setSavedPath, state.ncryptsec]); + + const { setVerified, test, setTest } = session; + const handleVerified = React.useCallback(() => { + setVerified(true); + onVerified?.(); + }, [onVerified, setVerified]); + + const issue = passphraseIssue(state.passphrase); + const showBackupTimeline = + variant === "spotlight" && + !state.savedPassword && + !state.createError && + !saveError; + + // The test view requires a successful save, not just a committed blob — + // while the native save dialog is open the password form stays put. + if (state.ncryptsec && savedPath && guidedTest) { + return ( +
+ void handleSaveCopy()} + onVerified={handleVerified} + progress={test} + saveError={saveError} + variant={variant} + verifyButtonPortal={verifyButtonPortal} + /> +
+ ); + } + // Without the guided test (settings), a completed save keeps the form + // visible in its saved-password state: masked input, instant re-download, + // and the change-password confirmation guarding any edit. + + return ( +
+
+ {showBackupTimeline ? : null} +
+ { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onPaste={(event) => { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onChange={(event) => + dispatch({ type: "set-passphrase", value: event.target.value }) + } + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) + return; + event.preventDefault(); + if (downloadDisabled(state) || isSaving) return; + if (state.savedPassword && state.ncryptsec) { + void handleSaveCopy(); + return; + } + dispatch({ type: "download-clicked" }); + }} + placeholder={ + state.savedPassword + ? "" + : `Password (min ${MIN_PASSPHRASE_LEN} characters)` + } + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + {state.savedPassword ? ( +
+ •••••••••••••••••••••••••••••••• +
+ ) : null} + {state.savedPassword ? ( + + Backup password saved; hidden for security. + + ) : null} + + setConfirmNewPassword(true) + : undefined + } + onGenerated={(value) => { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + securityTheme={variant === "spotlight"} + /> + {issue ? ( +

+ {issue} +

+ ) : null} +
+
+ + {state.savedPassword && state.ncryptsec && savedPath ? ( +
+

+ Backup saved to {savedPath} +

+

+ Your password isn't kept — download another copy anytime, or start + over to choose a new password. +

+
+ ) : null} + + {state.createError ? ( +

+ {state.createError} +

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

+ {saveError} +

+ ) : null} + + {(() => { + // A queued download gets an explicit progress treatment. Background + // encryption stays silent until the user asks to download. + const createButton = ( +
+ {state.downloadPending || isSaving ? ( + + ) : null} + +
+ ); + // `undefined` = inline (settings); `null` = slot not mounted yet + // (skip a frame rather than flashing the button inline). + if (createButtonPortal === undefined) + return
{createButton}
; + return createButtonPortal + ? createPortal(createButton, createButtonPortal) + : null; + })()} + + + + Create a new backup password? + + Starting over lets you pick a new password and download a fresh + backup file. Backups you saved earlier will still work — just use + the password you created them with. + + + + + Keep current backup + + { + dispatch({ type: "start-new-backup" }); + setSavedPath(null); + savedForRef.current = null; + setTest(initialBackupTestProgress); + setIsRevealed(false); + }} + > + Start with a new password + + + + +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx index 0376fc9709..a6a02f38c0 100644 --- a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx +++ b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx @@ -22,8 +22,8 @@ export function KeyringLockedScreen() { }, []); const handleImport = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); // Update the identity query cache so useIdentityQuery observers see // locked: false. The bootedLocked latch in hooks.ts will then route // to RelaunchRequiredScreen via bootedLocked && !identityLocked. diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index ca87c76636..cee17c68f8 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -1,20 +1,33 @@ import * as React from "react"; import type { QueryClient } from "@tanstack/react-query"; +import { ArrowUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import { getIdentity, importIdentity, persistCurrentIdentity, } from "@/shared/api/tauriIdentity"; +import type { IdentityStorage } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; +import { DownloadKeyStep } from "./DownloadKeyStep"; +import { + backupSessionToPasswordEntry, + resetEncryptedBackupSession, + useEncryptedBackupSession, +} from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; import { LandingBees } from "./LandingBees"; -import { NostrKeyImportForm } from "./NostrKeyImportForm"; +import { + NostrKeyImportForm, + type NostrKeyImportStage, +} from "./NostrKeyImportForm"; import { ONBOARDING_LANDING_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, OnboardingChrome, } from "./OnboardingChrome"; import { OnboardingFooterProvider } from "./OnboardingFooter"; @@ -28,6 +41,8 @@ export type MachineOnboardingPage = | "setup" | "config"; +type BackupSubview = "created" | "options" | "password"; + /** A pending navigation the parent should execute after RouterProvider mounts. */ export type PostOnboardingNavigation = { to: string; @@ -61,10 +76,27 @@ export function MachineOnboardingFlow({ const [error, setError] = React.useState(null); const [isPending, setIsPending] = React.useState(false); const [identityWasImported, setIdentityWasImported] = React.useState(false); + const [keyImportStage, setKeyImportStage] = + React.useState("key-entry"); const [selectedPubkey, setSelectedPubkey] = React.useState( null, ); + const [identityStorage, setIdentityStorage] = React.useState< + IdentityStorage | undefined + >(); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); + const [backupSubview, setBackupSubview] = + React.useState("created"); + const [backupDirection, setBackupDirection] = React.useState< + "forward" | "backward" + >("forward"); + const [returningFromSecurity, setReturningFromSecurity] = + React.useState(false); + // Owned here so switching between the yellow onboarding view and the dark + // security subview keeps the created backup, password, and test progress. + const backupSession = useEncryptedBackupSession(); + const reduceMotion = useReducedMotion() ?? false; + const isSecuritySubview = page === "backup" && backupSubview !== "created"; const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { setReadyRuntimeIds(Array.from(new Set(runtimeIds))); @@ -79,6 +111,10 @@ export function MachineOnboardingFlow({ const identity = await getIdentity(); queryClient.setQueryData(["identity"], identity); setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); setPage("backup"); } catch (cause) { setError( @@ -101,6 +137,10 @@ export function MachineOnboardingFlow({ const identity = await persistCurrentIdentity(); queryClient.setQueryData(["identity"], identity); setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); setPage("backup"); } catch (cause) { setError( @@ -112,8 +152,8 @@ export function MachineOnboardingFlow({ }, [queryClient]); const importExistingIdentity = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); continueWithIdentity(identity.pubkey); queryClient.setQueryData(["identity"], identity); setIdentityWasImported(true); @@ -126,6 +166,8 @@ export function MachineOnboardingFlow({ return (
{page === "identity" ? : null} - {page !== "identity" ? ( + {isSecuritySubview ? ( +
+ +
+ ) : page !== "identity" ? ( @@ -178,9 +237,12 @@ export function MachineOnboardingFlow({ : "Create a new identity key"} -
- - ) : ( - { - setNsecInput(event.target.value); - setImportError(null); - }} - placeholder="nsec1..." - ref={inputRef} - spellCheck={false} - type="password" - value={nsecInput} - /> - )} -
+ + + + ) : ( + { + setNsecInput(event.target.value); + setImportError(null); + }} + placeholder="nsec1..." + ref={inputRef} + spellCheck={false} + type="password" + value={nsecInput} + /> + )} + + ) : null} - {variant === "spotlight" ? null : ( - <> - { - void handleFiles(event.currentTarget.files); - event.currentTarget.value = ""; - }} - ref={fileInputRef} - tabIndex={-1} - type="file" - /> + {/* Hidden file input shared by both variants: the default drop zone and + the spotlight "Choose a backup file" button both open it. Accepts the + .ncryptsec backups our own save flow emits alongside raw .key files. */} + { + void handleFiles(event.currentTarget.files); + event.currentTarget.value = ""; + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> - + ) : null} + + {isPasswordStage ? ( +
+ + +
+ { + setPassphrase(event.target.value); + setImportError(null); + }} + placeholder="Backup password" + ref={passphraseInputRef} + spellCheck={false} + type={isRevealed ? "text" : "password"} + value={passphrase} /> - setIsRevealed((current) => !current)} + size="icon" + type="button" + variant="ghost" > - Drop a key here - - - - )} + {isRevealed ? ( +
+
+ ) : null} -
- {previewNpub ? ( - variant === "spotlight" ? ( - // Spotlight uses the backup step's quiet caption language: - // centered, unboxed, with the npub in the shared olive key ink. -
-

-

-

- {previewNpub} -

-
- ) : ( -
- -
-

- This will use this Nostr identity: + {!isPasswordStage || errorMessage ? ( +

+ {!isPasswordStage && previewNpub ? ( + variant === "spotlight" ? ( + // Spotlight uses the backup step's quiet caption language: + // centered, unboxed, with the npub in the shared olive key ink. +
+

+

-

+

{previewNpub}

-
- ) - ) : null} + ) : ( +
+ +
+

+ This will use this Nostr identity: +

+

+ {previewNpub} +

+
+
+ ) + ) : null} - {showInvalidHint && !errorMessage ? ( -

- Waiting for a valid nsec1 key -

- ) : null} + {showInvalidHint && !errorMessage ? ( +

+ {isEncryptedInput + ? "Waiting for a complete ncryptsec backup" + : "Waiting for a valid nsec1 key"} +

+ ) : null} - {errorMessage ? ( -

{errorMessage}

- ) : null} -
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ) : null} diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx index 936313bce0..7a52ae4999 100644 --- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx @@ -2,8 +2,8 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; /** * Positions in the first-launch flow: landing, identity/key, harness setup, - * default config, community choice, community profile, meet the team. Used as - * the default pagination length when a flow doesn't pass an explicit total. + * default config, community choice, community profile, meet the team. Password + * backup is an optional subview of identity/key, not another position. */ export const TOTAL_ONBOARDING_PAGES = 7; @@ -17,6 +17,9 @@ const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6"; */ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`; +/** Inverted primary action used only on dark backup-security surfaces. */ +export const ONBOARDING_SECURITY_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} bg-white text-black/80 hover:bg-white/90 hover:text-black`; + /** * Primary-CTA styling for the landing screen only: the shared pill with the * chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved @@ -24,6 +27,10 @@ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(- */ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`; +/** Shared quiet pill for secondary actions throughout onboarding. */ +export const ONBOARDING_SECONDARY_CTA_CLASS = + "h-9 rounded-full bg-foreground/10 px-6 text-foreground hover:bg-foreground/15 hover:text-foreground"; + /** * Icon-control styling for onboarding surfaces that sit on the textured card: * olive backup ink (`--buzz-onboarding-backup-ink`) with a plain @@ -34,6 +41,10 @@ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(- export const ONBOARDING_INK_ICON_CLASS = "text-[color:var(--buzz-onboarding-backup-ink)] hover:bg-transparent hover:text-foreground"; +/** Icon controls on the dark noisy backup surfaces stay visually unboxed. */ +export const ONBOARDING_SECURITY_ICON_CLASS = + "text-muted-foreground hover:bg-transparent hover:text-foreground"; + /** * Shared onboarding chrome shown on every page after the landing screen: a * static Buzz mark pinned to the top-left, and a centered pagination track that diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 01a226e3de..a3653f750f 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -388,8 +388,8 @@ export function OnboardingFlow({ // key's relay profile reseeds the steps, and a key that already finished // onboarding on this machine skips straight into the app. const importExistingKey = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); relayClient.disconnect(); queryClient.setQueryData(["identity"], identity); queryClient.removeQueries({ queryKey: profileQueryKey }); diff --git a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx index 82d9a0213c..ba5d8b2c87 100644 --- a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx @@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward"; export type OnboardingTransitionEffect = | "fade" | "line-slide" + | "mask-reveal-down" | "mask-reveal-up" | "none"; diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 431c9b2f51..911ddaf362 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -698,25 +698,28 @@ function SetupStepContent({ /> - - - + {/* Relative row keeps the primary CTA truly centered while Skip + hangs off its right edge without shifting the center. */} +
+ + +
+ + + + + + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index ba45bc62fb..967e50f5d2 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -35,17 +35,8 @@ import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { emojiDisplayName } from "@/shared/lib/emojiName"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; +import { DeleteMessageConfirmDialog } from "./DeleteMessageConfirmDialog"; import { DropdownMenu, DropdownMenuContent, @@ -277,35 +268,11 @@ function MoreActionsMenu({ {onDelete ? ( - onDelete(message)} onOpenChange={setIsDeleteDialogOpen} open={isDeleteDialogOpen} - > - - - Delete message? - - This will permanently delete this message and cannot be undone. - - - - - - - - - - - - + /> ) : null} {canReport ? ( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index df3a734cee..69e4ec67b5 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -513,10 +513,9 @@ function MessageComposerImpl({ if (editTargetRef.current && onEditSaveRef.current) { if (isSendingRef.current || isUploadingRef.current) return; const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; - // Empty text + zero attachments is a no-op (don't let edit become an - // effective deletion). - if (!trimmed && !hasMedia) return; + // No empty-edit guard here: clearing an edit to empty (no text, no + // attachments) flows through to onEditSave as empty content, which + // deletes the message instead of publishing it (see handleEditSave). // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` // because edit semantics use `[]` as the explicit "wipe all diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts new file mode 100644 index 0000000000..772506571b --- /dev/null +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// The mock identity's own pre-seeded message in #general (authored by +// DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge.ts). Editing/deleting one's own +// message is exactly Sam's workflow: "delete a message by clearing its edit." +const OWN_MESSAGE_ID = "mock-general-welcome"; +const ORIGINAL_CONTENT = "Welcome to #general"; + +// Open the more-actions menu for a message row and wait for the menu to mount. +async function openMoreActionsMenu( + page: import("@playwright/test").Page, + messageId: string, +) { + const row = page.locator(`[data-message-id="${messageId}"]`); + await row.hover(); + await page.getByTestId(`more-actions-${messageId}`).click(); + await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ + timeout: 5_000, + }); +} + +// Enter edit mode for a message, clear it to empty, and submit — the gesture +// that triggers the empty-edit delete confirmation. +async function submitEmptyEdit( + page: import("@playwright/test").Page, + messageId: string, +) { + await openMoreActionsMenu(page, messageId); + await page.getByTestId(`edit-message-${messageId}`).click(); + await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 }); + // Edit mode sets the editor content via Tiptap's async transaction pipeline; + // wait for it to populate before we clear it. + const input = page.getByTestId("message-input"); + await expect(input).not.toBeEmpty({ timeout: 5_000 }); + await input.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("Backspace"); + await expect(input).toBeEmpty(); + await page.keyboard.press("Enter"); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +}); + +test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({ + page, +}) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await submitEmptyEdit(page, OWN_MESSAGE_ID); + + // The same "Delete message?" confirmation the Delete menu action shows — an + // empty edit is routed through it, not silently deleted. + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog).toContainText("Delete message?"); + // Edit mode stays active while the dialog is open — it exits only on confirm. + await expect(page.getByTestId("edit-target")).toBeVisible(); + + // Confirm → the message row is removed and edit mode has exited. + await dialog.getByRole("button", { name: "Delete" }).click(); + await expect(dialog).toBeHidden({ timeout: 5_000 }); + await expect(page.getByTestId("edit-target")).toBeHidden(); + await expect(row).toBeHidden({ timeout: 5_000 }); +}); + +test("cancelling the empty-edit delete keeps the message", async ({ page }) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await submitEmptyEdit(page, OWN_MESSAGE_ID); + + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Cancel → nothing is deleted, the original message survives, and the user is + // left in edit mode (the editing session is preserved, not discarded). + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toBeHidden({ timeout: 5_000 }); + await expect(page.getByTestId("edit-target")).toBeVisible(); + await expect(row).toBeVisible(); + await expect(page.getByTestId("message-timeline")).toContainText( + ORIGINAL_CONTENT, + ); +}); + +test("a non-empty edit still edits and never deletes", async ({ page }) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await openMoreActionsMenu(page, OWN_MESSAGE_ID); + await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click(); + await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 }); + const input = page.getByTestId("message-input"); + await expect(input).not.toBeEmpty({ timeout: 5_000 }); + const editedContent = `Edited, not deleted ${Date.now()}`; + + await input.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type(editedContent); + await page.keyboard.press("Enter"); + + // No delete confirmation, edit mode exits, the row survives with new text. + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 5_000 }); + await expect(row).toBeVisible(); + await expect(page.getByTestId("message-timeline")).toContainText( + editedContent, + ); + await expect(page.getByTestId("message-timeline")).not.toContainText( + ORIGINAL_CONTENT, + ); +}); From d48b0e0eec4d2958f90a3cafa9d974450abe8501 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Fri, 31 Jul 2026 07:00:15 -0400 Subject: [PATCH 89/99] feat(desktop): upgrade Pocket TTS model (#3266) ## Context Buzz Desktop currently installs an older Pocket TTS model bundle. The current bundle changes the tokenizer, learned BOS input, recurrent-state contract, and prompt behavior, so updating download URLs alone is not compatible. ## Summary This PR upgrades Buzz Desktop to the current pinned Pocket TTS model. It preserves existing product behavior and the hard 50-token model-input limit while adding the required runtime support, verified acquisition, and crash-safe cache migration. ## Changes - Pins an immutable Pocket TTS revision, artifact names, exact byte sizes, SHA-256 checksums, Mary reference voice, and license. - Loads the bundle-matched SentencePiece tokenizer, learned BOS embedding, and bundle-declared recurrent states. - Uses one pinned Pocket TTS configuration; no precision or model-version selector is added. - Preserves the resident engine's exact `<= 50` token contract without changing Desktop segmentation policy. - Bumps the Pocket cache manifest to v4, verifies size and checksum before adoption, atomically swaps the cache, and recovers the last verified cache after interrupted installs, including an incomplete final directory. - Keeps acquisition, cache migration, worker adoption, and tests within the existing Desktop implementation. - Removes the obsolete model-quality harness, which was coupled to the superseded production prompt and model layout. ## Related issue None. ## Testing Manual listening completed on the exact Desktop build. The updated model improved speech quality and resolved the phrase-start and sample-onset artifacts. Reproducible integrity and model checks are below. ## Screenshots N/A. This changes model installation and speech synthesis, not a visual surface. ## Reviewer-reproducible examples ### Before and after model identity ```sh git show 35305bfc8fd456ca9a17caa1ddbfaabd87d46981:desktop/src-tauri/src/huddle/models.rs \ | grep -E 'sherpa-onnx-pocket-tts|TTS_MODEL_VERSION' git show 211d17c58567448fe7ac95c4fa0ad2b88378849a:desktop/src-tauri/src/huddle/pocket_models.rs \ | grep -E 'MODEL_REPOSITORY|MODEL_REVISION|MODEL_PRECISION|MAX_TOKENS' ``` The target branch identifies the January bundle. The PR branch identifies the immutable April revision, INT8 precision, and 50-token maximum. ### Deterministic runtime validation Use the pinned artifacts listed in `pocket_models.rs` and run the model-dependent Pocket tests with the model directory supplied by the test environment. The checked-in long-sentence fixture must preserve its expected 48 and 44 token split and produce non-silent PCM. ### Manual listening validation John listened to an untrimmed Pocket TTS onset-stress clip generated from the exact user-provided passage, with every sentence synthesized separately and identical 100 ms digital-silence boundaries. The clip used no leading period, onset trimming, gain adjustment, or loudness normalization. The updated model produced better-quality speech and resolved the start-of-sample artifacts. --------- Signed-off-by: John Tennant Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: John Tennant Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- desktop/src-tauri/Cargo.lock | 461 ++++++++- desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/examples/pocket_bench.rs | 116 --- .../src-tauri/examples/pocket_clip_probe.rs | 122 --- .../src-tauri/examples/pocket_onset_probe.rs | 149 --- .../src-tauri/examples/pocket_quality_ab.rs | 519 ---------- desktop/src-tauri/src/huddle/models.rs | 241 +++-- desktop/src-tauri/src/huddle/models_tests.rs | 148 +++ desktop/src-tauri/src/huddle/pocket.rs | 672 ++----------- desktop/src-tauri/src/huddle/pocket_april.rs | 940 ++++++++++++++++++ desktop/src-tauri/src/huddle/pocket_models.rs | 130 +++ desktop/src-tauri/src/huddle/tts.rs | 200 ++-- desktop/src-tauri/src/huddle/tts_tests.rs | 34 +- .../src/huddle/tts_tests/token_split.rs | 24 + 14 files changed, 2047 insertions(+), 1714 deletions(-) delete mode 100644 desktop/src-tauri/examples/pocket_bench.rs delete mode 100644 desktop/src-tauri/examples/pocket_clip_probe.rs delete mode 100644 desktop/src-tauri/examples/pocket_onset_probe.rs delete mode 100644 desktop/src-tauri/examples/pocket_quality_ab.rs create mode 100644 desktop/src-tauri/src/huddle/models_tests.rs create mode 100644 desktop/src-tauri/src/huddle/pocket_april.rs create mode 100644 desktop/src-tauri/src/huddle/pocket_models.rs create mode 100644 desktop/src-tauri/src/huddle/tts_tests/token_split.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 325eb9aa67..aca89d7ed7 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -84,6 +84,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -707,6 +708,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -731,6 +738,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bip39" version = "2.2.2" @@ -1065,8 +1078,11 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "opus", + "ort", + "ort-sys", "plist", "png 0.18.1", + "rand 0.10.2", "regex", "reqwest 0.13.4", "rodio", @@ -1074,6 +1090,7 @@ dependencies = [ "rusqlite", "rustls", "security-framework 3.7.0", + "sentencepiece-model", "serde", "serde_json", "serde_yaml", @@ -1093,6 +1110,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tempfile", + "tokenizers", "tokio", "tokio-tungstenite 0.29.0", "tokio-util", @@ -1562,6 +1580,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -1813,6 +1832,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -2137,6 +2166,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -2634,6 +2672,12 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "euclid" version = "0.22.14" @@ -2692,6 +2736,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -4763,6 +4818,39 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.118", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -4872,6 +4960,22 @@ dependencies = [ "libc", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "markup5ever" version = "0.38.0" @@ -4898,6 +5002,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -4997,7 +5111,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "rustls", "serde", @@ -5135,7 +5249,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "regex-lite", "reqwest 0.12.28", @@ -5224,8 +5338,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "prost", - "prost-build", + "prost 0.14.4", + "prost-build 0.14.4", "protoc-bin-vendored", "rmcp", "schemars 1.2.1", @@ -5261,7 +5375,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.4", "serde_json", "sha2 0.10.9", ] @@ -5376,6 +5490,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if 1.0.4", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "mime" version = "0.3.17" @@ -5521,6 +5657,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "more-asserts" version = "0.3.1" @@ -5648,6 +5806,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -6149,7 +6322,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -6629,7 +6802,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -6644,7 +6817,7 @@ dependencies = [ "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "serde", "serde_json", "tonic", @@ -6710,6 +6883,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "os_pipe" version = "1.2.3" @@ -6937,6 +7128,16 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -7201,6 +7402,15 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.1" @@ -7411,6 +7621,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.4" @@ -7418,7 +7638,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.4", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.118", + "tempfile", ] [[package]] @@ -7431,15 +7671,28 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.4", + "prost-types 0.14.4", "regex", "syn 2.0.118", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "prost-derive" version = "0.14.4" @@ -7453,13 +7706,35 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost", + "prost 0.14.4", ] [[package]] @@ -7526,6 +7801,33 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost 0.13.5", + "prost-reflect", + "prost-types 0.13.5", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types 0.13.5", + "thiserror 1.0.69", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -7774,7 +8076,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7837,7 +8139,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7846,6 +8148,43 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "realfft" version = "3.5.0" @@ -8658,6 +8997,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost 0.13.5", + "prost-build 0.13.5", + "protox", +] + [[package]] name = "serde" version = "1.0.228" @@ -9090,8 +9441,8 @@ name = "skippy-protocol" version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ - "prost", - "prost-build", + "prost 0.14.4", + "prost-build 0.14.4", "protoc-bin-vendored", "serde", ] @@ -9271,6 +9622,18 @@ dependencies = [ "der", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + [[package]] name = "sse-stream" version = "0.2.4" @@ -9652,7 +10015,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -10227,7 +10590,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -10390,6 +10753,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.1", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.14.0", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -10723,7 +11119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.4", "tonic", ] @@ -10904,7 +11300,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", "nom 8.0.0", - "petgraph", + "petgraph 0.8.3", ] [[package]] @@ -11075,6 +11471,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -11089,9 +11494,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -11104,6 +11515,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6f3c03c5a5..248eac107e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -82,6 +82,9 @@ bytes = "1" futures-util = "0.3" opus = "0.3" neteq = { version = "0.8", default-features = false } +ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] } +ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] } +rand = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -125,6 +128,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", zip = "8" flate2 = "1" sherpa-onnx = "1.12" +sentencepiece-model = "0.1" regex = "1" rusqlite = { version = "0.37", features = ["bundled"] } axum = "0.8" @@ -135,6 +139,7 @@ audioadapter-buffers = "3.0" tempfile = "3" strip-ansi-escapes = "0.2" tracing = "0.1" +tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] } [dev-dependencies] # `test-util` enables tokio's paused-clock (`start_paused`) so the relay diff --git a/desktop/src-tauri/examples/pocket_bench.rs b/desktop/src-tauri/examples/pocket_bench.rs deleted file mode 100644 index b4f5635a95..0000000000 --- a/desktop/src-tauri/examples/pocket_bench.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Cold-vs-warm latency bench for Pocket TTS. -//! -//! This duplicates the small config-building snippet from `huddle::pocket` so it -//! doesn't depend on changing module visibility for a one-off dev tool. -//! Keep in sync with `huddle::pocket::load_text_to_speech`. -//! -//! Run with the model files in a directory (defaults to /tmp/pocket-tts-bench): -//! cargo run --release --example pocket_bench -//! cargo run --release --example pocket_bench /path/to/pocket-tts - -use std::path::PathBuf; -use std::time::Instant; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -const SAMPLE_RATE: u32 = 24_000; -const TEST_TEXT: &str = - "Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx."; - -fn main() { - let model_dir = std::env::args() - .nth(1) - .unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string()); - println!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let t0 = Instant::now(); - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - let load_ms = t0.elapsed().as_secs_f32() * 1000.0; - println!("Engine load: {load_ms:.1} ms"); - - let t0 = Instant::now(); - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let samples = wave.samples().to_vec(); - let sr = wave.sample_rate(); - let voice_ms = t0.elapsed().as_secs_f32() * 1000.0; - println!("Voice load: {voice_ms:.1} ms"); - - let gen = || GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(samples.clone()), - reference_sample_rate: sr, - ..Default::default() - }; - - let t0 = Instant::now(); - let cold = engine - .generate_with_config(TEST_TEXT, &gen(), None:: bool>) - .expect("cold synth"); - let cold_ms = t0.elapsed().as_secs_f32() * 1000.0; - let cold_audio_ms = (cold.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0; - let cold_rtf_x = cold_audio_ms / cold_ms; - println!( - "Cold synth: {cold_ms:.1} ms → {cold_audio_ms:.1} ms audio → {cold_rtf_x:.2}× realtime" - ); - - let t0 = Instant::now(); - let warm = engine - .generate_with_config(TEST_TEXT, &gen(), None:: bool>) - .expect("warm synth"); - let warm_ms = t0.elapsed().as_secs_f32() * 1000.0; - let warm_audio_ms = (warm.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0; - let warm_rtf_x = warm_audio_ms / warm_ms; - println!( - "Warm synth: {warm_ms:.1} ms → {warm_audio_ms:.1} ms audio → {warm_rtf_x:.2}× realtime" - ); - - let out_path = "/tmp/pocket_bench_out.wav"; - let ok = sherpa_onnx::write(out_path, warm.samples(), SAMPLE_RATE as i32); - println!( - "Wrote {} ({} samples, ok={ok})", - out_path, - warm.samples().len() - ); - - let delta_ms = cold_ms - warm_ms; - let delta_pct = (delta_ms / warm_ms) * 100.0; - println!(); - println!("Cold/warm delta: {delta_ms:+.1} ms ({delta_pct:+.1}%)"); - println!( - "Decision: warmup {}.", - if delta_ms > 200.0 { - "RECOMMENDED — significant cold-call penalty" - } else if delta_ms > 50.0 { - "OPTIONAL — small cold-call penalty" - } else { - "UNNECESSARY — cold and warm essentially equal" - } - ); -} diff --git a/desktop/src-tauri/examples/pocket_clip_probe.rs b/desktop/src-tauri/examples/pocket_clip_probe.rs deleted file mode 100644 index ad8657599f..0000000000 --- a/desktop/src-tauri/examples/pocket_clip_probe.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Clipping probe for any fixed playback gain applied after Pocket TTS synth. -//! -//! Synthesises a spread of sentences (short/long, calm/energetic) and reports -//! the raw peak of each, the post-gain peak, and the fraction of samples that -//! would hit a ±1.0 clamp — i.e. how much a fixed gain would flat-top the -//! waveform ("blown out" distortion). -//! -//! History: the production pipeline briefly shipped a fixed 9.3× gain -//! calibrated on a single bench utterance that peaked at 0.076. This probe -//! showed real output peaks at 0.4–0.97, so that gain clipped 13–34% of all -//! samples (the 2026-06-12 "blown out" report). Production now applies no -//! gain — run this probe before reintroducing one. -//! -//! Run with model files in ~/.buzz/models/pocket-tts (override with arg 1): -//! cargo run --release --example pocket_clip_probe - -use std::path::PathBuf; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -/// Candidate gain under test (the regressed production value). -const GAIN: f32 = 9.3; - -const PROMPTS: &[&str] = &[ - "Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx.", - "Yep, I can hear you.", - "Absolutely! That sounds fantastic, let's do it right now!", - "The quick brown fox jumps over the lazy dog near the riverbank.", - "I found three problems in the code: a race condition, a memory leak, and an off-by-one error in the loop bounds.", - "No.", - "Warning! The build failed because seventeen tests crashed unexpectedly!", - "Sure, I can walk you through the whole pipeline step by step whenever you're ready.", -]; - -fn main() { - let model_dir = std::env::args().nth(1).unwrap_or_else(|| { - dirs::home_dir() - .expect("home dir") - .join(".buzz/models/pocket-tts") - .to_string_lossy() - .into_owned() - }); - eprintln!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let voice_samples = wave.samples().to_vec(); - let voice_sr = wave.sample_rate(); - - let gen = || GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - - let _ = engine.generate_with_config("warmup.", &gen(), None:: bool>); - - println!( - "{:<46} | {:>8} | {:>9} | {:>9} | {:>10}", - "prompt", "raw peak", "raw RMS", "post-gain", "% clipped" - ); - println!("{}", "-".repeat(95)); - - let mut worst_clip = 0.0f32; - for prompt in PROMPTS { - let out = engine - .generate_with_config(prompt, &gen(), None:: bool>) - .expect("synth"); - let samples = out.samples(); - - let peak = samples.iter().fold(0.0f32, |m, s| m.max(s.abs())); - let rms = (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt(); - let post = peak * GAIN; - let clipped = samples.iter().filter(|s| s.abs() * GAIN > 1.0).count(); - let clip_pct = 100.0 * clipped as f32 / samples.len() as f32; - worst_clip = worst_clip.max(clip_pct); - - let label: String = prompt.chars().take(44).collect(); - println!("{label:<46} | {peak:>8.4} | {rms:>9.4} | {post:>9.3} | {clip_pct:>9.3}%"); - } - - println!(); - println!( - "Verdict: worst-case clipped fraction {worst_clip:.3}% — {}", - if worst_clip > 0.1 { - "AUDIBLE DISTORTION LIKELY (gain too hot)" - } else if worst_clip > 0.0 { - "marginal — occasional transient clipping" - } else { - "no clipping at this gain" - } - ); -} diff --git a/desktop/src-tauri/examples/pocket_onset_probe.rs b/desktop/src-tauri/examples/pocket_onset_probe.rs deleted file mode 100644 index 05b4d0193c..0000000000 --- a/desktop/src-tauri/examples/pocket_onset_probe.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Onset-attenuation probe for Pocket TTS. -//! -//! Synthesises a handful of short sentences and dumps per-sentence onset -//! statistics (samples[0], 1ms/5ms/20ms peak + RMS) so we can decide whether -//! the production `apply_fades` 8 ms fade-in is masking real audio. -//! -//! Also writes the raw (un-faded, un-normalised) audio of each sentence to -//! /tmp so they can be inspected in Audacity / aplay without rodio in the -//! loop. -//! -//! Run with model files in /tmp/pocket-tts-bench (override with arg 1): -//! cargo run --release --example pocket_onset_probe -//! cargo run --release --example pocket_onset_probe /path/to/pocket-tts - -use std::path::PathBuf; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -const SAMPLE_RATE: u32 = 24_000; - -/// Test prompts chosen to span different onsets: -/// - palatal glide 'Y' (soft onset) -/// - voiceless fricative 'H' (very soft onset) -/// - labio-velar glide 'W' (medium onset) -/// - voiceless stop 'T' (hard onset) -const PROMPTS: &[&str] = &[ - "Yep, I can hear you.", - "Hello there friend.", - "What can I help with?", - "Try this experiment now.", -]; - -fn main() { - let model_dir = std::env::args() - .nth(1) - .unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string()); - eprintln!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let voice_samples = wave.samples().to_vec(); - let voice_sr = wave.sample_rate(); - - // Warmup so we're not measuring cold-call jitter. - { - let cfg = GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - let _ = engine.generate_with_config("warmup.", &cfg, None:: bool>); - } - - println!( - "{:<28} | {:>10} | {:>10} {:>10} | {:>10} {:>10} | {:>10} {:>10}", - "prompt", - "samples[0]", - "peak@1ms", - "rms@1ms", - "peak@5ms", - "rms@5ms", - "peak@20ms", - "rms@20ms" - ); - println!("{}", "-".repeat(120)); - - for prompt in PROMPTS { - // Mirror the production prompt-prep (capitalise + terminal punctuation). - // These prompts already have it, so this is just to match what - // sherpa-onnx sees in production. - let cfg = GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - let out = engine - .generate_with_config(prompt, &cfg, None:: bool>) - .expect("synth"); - let samples = out.samples(); - - let n_1ms = (SAMPLE_RATE as f32 * 0.001) as usize; - let n_5ms = (SAMPLE_RATE as f32 * 0.005) as usize; - let n_20ms = (SAMPLE_RATE as f32 * 0.020) as usize; - - let stats = |range: &[f32]| -> (f32, f32) { - if range.is_empty() { - return (0.0, 0.0); - } - let peak = range.iter().fold(0.0_f32, |a, &x| a.max(x.abs())); - let sumsq: f32 = range.iter().map(|x| x * x).sum(); - let rms = (sumsq / range.len() as f32).sqrt(); - (peak, rms) - }; - - let first = samples.first().copied().unwrap_or(0.0); - let (p1, r1) = stats(&samples[..n_1ms.min(samples.len())]); - let (p5, r5) = stats(&samples[..n_5ms.min(samples.len())]); - let (p20, r20) = stats(&samples[..n_20ms.min(samples.len())]); - - println!( - "{:<28} | {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6}", - prompt, first, p1, r1, p5, r5, p20, r20 - ); - - let safe: String = prompt - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect(); - let out_path = format!("/tmp/pocket_onset_{}.wav", &safe[..safe.len().min(24)]); - let _ = sherpa_onnx::write(&out_path, samples, SAMPLE_RATE as i32); - eprintln!( - " → wrote {out_path} ({} samples = {:.3} s)", - samples.len(), - samples.len() as f32 / SAMPLE_RATE as f32 - ); - } -} diff --git a/desktop/src-tauri/examples/pocket_quality_ab.rs b/desktop/src-tauri/examples/pocket_quality_ab.rs deleted file mode 100644 index 0c31f1c910..0000000000 --- a/desktop/src-tauri/examples/pocket_quality_ab.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! Reproducible blind Pocket TTS quality corpus generator. -//! -//! Renders Buzz's production prompt preparation and post-processing across: -//! INT8/FP32 × per-sentence/grouped generation. The generated filenames are -//! deterministically blinded; keep `key.json` away from listeners until their -//! scoring sheet is complete. -//! -//! Usage: -//! cargo run --release --example pocket_quality_ab -- \ -//! [--idle-minutes N --only ITEM] -//! -//! The optional idle run intentionally creates one engine per condition, warms -//! all four, sleeps once, and then makes each clip the first generation after -//! dormancy. It requires `--only` because only the first synthesis after an -//! uninterrupted idle is a valid post-idle observation. Run each 5/15-minute -//! item as a separate process. - -// Importing the production module also brings in runtime-only helpers that this -// standalone corpus generator deliberately does not call. -#![allow(dead_code)] - -#[path = "../src/huddle/pocket.rs"] -mod production_pocket; -#[path = "../src/huddle/preprocessing.rs"] -mod production_preprocessing; - -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use serde::Serialize; -use sha2::{Digest, Sha256}; -use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave}; - -use production_pocket::{prepare_pocket_prompt, SAMPLE_RATE}; -use production_preprocessing::{preprocess_for_tts, split_sentences}; - -const NUM_STEPS: i32 = 1; -const SILENCE_SCALE: f32 = 1.0; -const INTER_SENTENCE_SILENCE_SAMPLES: usize = SAMPLE_RATE as usize / 10; -const LEAD_IN_SAMPLES: usize = SAMPLE_RATE as usize / 50; -const FADE_OUT_SAMPLES: usize = SAMPLE_RATE as usize * 8 / 1000; -const TARGET_RMS_DBFS: f32 = -23.0; -const BLINDING_SEED: &str = "pocket-quality-2026-07-21-v1"; - -const CORPUS: &[CorpusItem] = &[ - CorpusItem { id: "short_one_word", kind: "short", text: "Yep." }, - CorpusItem { id: "short_four_words", kind: "short", text: "Sounds good to me." }, - CorpusItem { - id: "multi_relay_review", - kind: "multi-sentence", - text: "I looked at the relay code this morning. The lease logic is solid. There's one race in the worker claim path, though. I'll write it up and send you a patch.", - }, - CorpusItem { - id: "multi_community_size", - kind: "multi-sentence", - text: "Great question. The answer is it depends on the community size. For small ones, keep it simple.", - }, - CorpusItem { - id: "mixed_agent_message", - kind: "mixed", - text: "That's 42 open PRs right now — mostly small. I'll triage them after lunch.", - }, -]; - -#[derive(Clone, Copy)] -struct CorpusItem { - id: &'static str, - kind: &'static str, - text: &'static str, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -enum Precision { - Int8, - Fp32, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -enum Chunking { - PerSentence, - Grouped, -} - -#[derive(Clone, Copy, Debug)] -struct Condition { - precision: Precision, - chunking: Chunking, -} - -const CONDITIONS: [Condition; 4] = [ - Condition { - precision: Precision::Int8, - chunking: Chunking::PerSentence, - }, - Condition { - precision: Precision::Int8, - chunking: Chunking::Grouped, - }, - Condition { - precision: Precision::Fp32, - chunking: Chunking::PerSentence, - }, - Condition { - precision: Precision::Fp32, - chunking: Chunking::Grouped, - }, -]; - -#[derive(Serialize)] -struct KeyFile { - warning: &'static str, - blinding_seed: &'static str, - target_rms_dbfs: f32, - items: Vec, -} - -#[derive(Serialize)] -struct KeyItem { - id: String, - kind: String, - text: String, - clips: Vec, -} - -#[derive(Serialize)] -struct KeyClip { - file: String, - precision: Precision, - chunking: Chunking, - cold_start: bool, - idle_minutes: Option, - synthesis_ms: u128, - audio_seconds: f32, -} - -struct Voice { - samples: Vec, - sample_rate: i32, -} - -struct Engine { - inner: OfflineTts, - voice: Voice, -} - -fn main() -> Result<(), String> { - let mut args = std::env::args().skip(1); - let int8_dir = required_path(args.next(), "INT8 model directory")?; - let fp32_dir = required_path(args.next(), "FP32 model directory")?; - let output_dir = required_path(args.next(), "output directory")?; - let mut idle_minutes = None; - let mut only_item = None; - while let Some(arg) = args.next() { - match arg.as_str() { - "--idle-minutes" => { - idle_minutes = Some( - args.next() - .ok_or("--idle-minutes requires a value")? - .parse::() - .map_err(|e| format!("invalid idle minutes: {e}"))?, - ); - } - "--only" => only_item = Some(args.next().ok_or("--only requires an item ID")?), - _ => return Err(format!("unknown argument: {arg}")), - } - } - - if idle_minutes.is_some() && only_item.is_none() { - return Err("--idle-minutes requires --only so every clip is first-after-idle".into()); - } - if let Some(ref requested) = only_item { - if !CORPUS.iter().any(|item| item.id == requested) { - return Err(format!("unknown corpus item for --only: {requested}")); - } - } - - validate_model_dir(&int8_dir, Precision::Int8)?; - validate_model_dir(&fp32_dir, Precision::Fp32)?; - fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?; - - let mut engines = Vec::with_capacity(CONDITIONS.len()); - for condition in CONDITIONS { - let dir = match condition.precision { - Precision::Int8 => &int8_dir, - Precision::Fp32 => &fp32_dir, - }; - let engine = load_engine(dir, condition.precision)?; - // Production warms once before serving a real utterance. Cold cases use - // separate fresh engines below and deliberately skip this call. - synth_chunks(&engine, &["warmup".to_string()])?; - engines.push(engine); - } - - if let Some(minutes) = idle_minutes { - eprintln!("All four warmed engines idle for {minutes} minute(s)…"); - std::thread::sleep(Duration::from_secs(minutes * 60)); - } - - let mut key_items = Vec::new(); - for item in CORPUS { - if only_item - .as_deref() - .is_some_and(|requested| requested != item.id) - { - continue; - } - let preprocessed = preprocess_for_tts(item.text); - let per_sentence: Vec = split_sentences(&preprocessed) - .into_iter() - .filter(|s| !s.trim().is_empty()) - .collect(); - // These corpus texts are deliberately below the upstream ~50-token - // grouping target, so grouped mode is one exact generate() call. - let grouped = vec![per_sentence.join(" ")]; - let item_dir = output_dir.join(item.id); - fs::create_dir_all(&item_dir).map_err(|e| e.to_string())?; - let clip_order = blinded_order(item.id); - let mut clips = Vec::new(); - - let mut rendered = Vec::new(); - for (condition_index, engine) in engines.iter().enumerate() { - let condition = CONDITIONS[condition_index]; - let chunks = match condition.chunking { - Chunking::PerSentence => &per_sentence, - Chunking::Grouped => &grouped, - }; - let started = Instant::now(); - let audio = synth_chunks(engine, chunks)?; - rendered.push(( - condition_index, - condition, - audio, - started.elapsed().as_millis(), - )); - } - loudness_match_item(&mut rendered); - for (condition_index, condition, audio, synth_ms) in rendered { - let clip_number = clip_order[condition_index] + 1; - let file_name = format!("clip{clip_number}.wav"); - write_wav(&item_dir.join(&file_name), &audio)?; - clips.push(KeyClip { - file: format!("{}/{file_name}", item.id), - precision: condition.precision, - chunking: condition.chunking, - cold_start: false, - idle_minutes, - synthesis_ms: synth_ms, - audio_seconds: audio.len() as f32 / SAMPLE_RATE as f32, - }); - } - clips.sort_by(|a, b| a.file.cmp(&b.file)); - key_items.push(KeyItem { - id: item.id.to_string(), - kind: item.kind.to_string(), - text: item.text.to_string(), - clips, - }); - } - - // Explicit fresh-engine cold-start clips for the two highest-signal texts. - // Idle runs intentionally omit them: they happen after the post-idle clips - // and add no valid idle observation. - for item in if idle_minutes.is_none() { CORPUS } else { &[] } { - if !matches!(item.id, "short_one_word" | "multi_relay_review") { - continue; - } - if only_item - .as_deref() - .is_some_and(|requested| requested != item.id) - { - continue; - } - let cold_id = format!("cold_{}", item.id); - let preprocessed = preprocess_for_tts(item.text); - let sentences: Vec = split_sentences(&preprocessed) - .into_iter() - .filter(|s| !s.trim().is_empty()) - .collect(); - let grouped = vec![sentences.join(" ")]; - let item_dir = output_dir.join(&cold_id); - fs::create_dir_all(&item_dir).map_err(|e| e.to_string())?; - let clip_order = blinded_order(&cold_id); - let mut clips = Vec::new(); - let mut rendered = Vec::new(); - for (condition_index, condition) in CONDITIONS.iter().copied().enumerate() { - let dir = match condition.precision { - Precision::Int8 => &int8_dir, - Precision::Fp32 => &fp32_dir, - }; - let engine = load_engine(dir, condition.precision)?; - let chunks = match condition.chunking { - Chunking::PerSentence => &sentences, - Chunking::Grouped => &grouped, - }; - let started = Instant::now(); - let audio = synth_chunks(&engine, chunks)?; - rendered.push(( - condition_index, - condition, - audio, - started.elapsed().as_millis(), - )); - } - loudness_match_item(&mut rendered); - for (condition_index, condition, audio, synth_ms) in rendered { - let clip_number = clip_order[condition_index] + 1; - let file_name = format!("clip{clip_number}.wav"); - write_wav(&item_dir.join(&file_name), &audio)?; - clips.push(KeyClip { - file: format!("{cold_id}/{file_name}"), - precision: condition.precision, - chunking: condition.chunking, - cold_start: true, - idle_minutes: None, - synthesis_ms: synth_ms, - audio_seconds: audio.len() as f32 / SAMPLE_RATE as f32, - }); - } - clips.sort_by(|a, b| a.file.cmp(&b.file)); - key_items.push(KeyItem { - id: cold_id, - kind: "cold-start".to_string(), - text: item.text.to_string(), - clips, - }); - } - - let key = KeyFile { - warning: "DO NOT OPEN UNTIL LISTENING SCORES ARE FINAL", - blinding_seed: BLINDING_SEED, - target_rms_dbfs: TARGET_RMS_DBFS, - items: key_items, - }; - fs::write( - output_dir.join("key.json"), - serde_json::to_vec_pretty(&key).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; - write_scoring_sheet(&output_dir, &key)?; - println!("Wrote blind corpus to {}", output_dir.display()); - println!("Give listeners the WAV folders and SCORING.md; withhold key.json."); - Ok(()) -} - -fn required_path(value: Option, label: &str) -> Result { - value - .map(PathBuf::from) - .ok_or_else(|| format!("missing {label}")) -} - -fn model_file(precision: Precision, base: &str) -> String { - match precision { - Precision::Int8 => format!("{base}.int8.onnx"), - Precision::Fp32 => format!("{base}.onnx"), - } -} - -fn validate_model_dir(dir: &Path, precision: Precision) -> Result<(), String> { - for file in [ - model_file(precision, "lm_main"), - model_file(precision, "lm_flow"), - "encoder.onnx".into(), - model_file(precision, "decoder"), - "text_conditioner.onnx".into(), - "vocab.json".into(), - "token_scores.json".into(), - "reference_sample.wav".into(), - ] { - if !dir.join(&file).is_file() { - return Err(format!("missing {}", dir.join(file).display())); - } - } - Ok(()) -} - -fn load_engine(dir: &Path, precision: Precision) -> Result { - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - let mut cfg = OfflineTtsConfig::default(); - cfg.model.pocket.lm_main = Some(p(&model_file(precision, "lm_main"))); - cfg.model.pocket.lm_flow = Some(p(&model_file(precision, "lm_flow"))); - cfg.model.pocket.encoder = Some(p("encoder.onnx")); - cfg.model.pocket.decoder = Some(p(&model_file(precision, "decoder"))); - cfg.model.pocket.text_conditioner = Some(p("text_conditioner.onnx")); - cfg.model.pocket.vocab_json = Some(p("vocab.json")); - cfg.model.pocket.token_scores_json = Some(p("token_scores.json")); - cfg.model.pocket.voice_embedding_cache_capacity = 16; - cfg.model.num_threads = 1; - cfg.model.debug = false; - let inner = - OfflineTts::create(&cfg).ok_or_else(|| format!("failed to create {precision:?} engine"))?; - let wave = - Wave::read(&p("reference_sample.wav")).ok_or("failed to read reference_sample.wav")?; - Ok(Engine { - inner, - voice: Voice { - samples: wave.samples().to_vec(), - sample_rate: wave.sample_rate(), - }, - }) -} - -fn synth_chunks(engine: &Engine, chunks: &[String]) -> Result, String> { - let mut out = Vec::new(); - for chunk in chunks { - let prepared = prepare_pocket_prompt(chunk).ok_or("empty prepared prompt")?; - let extra = prepared.max_frames.map(|max_frames| { - HashMap::from([( - "max_frames".to_string(), - serde_json::Value::from(max_frames), - )]) - }); - let cfg = GenerationConfig { - num_steps: NUM_STEPS, - silence_scale: SILENCE_SCALE, - reference_audio: Some(engine.voice.samples.clone()), - reference_sample_rate: engine.voice.sample_rate, - extra, - ..Default::default() - }; - let audio = engine - .inner - .generate_with_config(&prepared.text, &cfg, None:: bool>) - .ok_or_else(|| format!("synthesis failed for {chunk:?}"))?; - let mut samples: Vec = audio.samples().iter().map(|s| s.clamp(-1.0, 1.0)).collect(); - apply_fade_out(&mut samples); - out.extend(std::iter::repeat_n(0.0, LEAD_IN_SAMPLES)); - out.extend(samples); - out.extend(std::iter::repeat_n( - 0.0, - INTER_SENTENCE_SILENCE_SAMPLES - LEAD_IN_SAMPLES, - )); - } - Ok(out) -} - -fn apply_fade_out(samples: &mut [f32]) { - let fade = FADE_OUT_SAMPLES.min(samples.len() / 2); - for i in 0..fade { - samples[samples.len() - 1 - i] *= i as f32 / fade as f32; - } -} - -fn active_rms(samples: &[f32]) -> Option { - let (sum_squares, count) = samples - .iter() - .filter(|sample| sample.abs() > 1.0e-4) - .fold((0.0_f32, 0_usize), |(sum, count), sample| { - (sum + sample * sample, count + 1) - }); - (count > 0).then(|| (sum_squares / count as f32).sqrt()) -} - -/// Attenuate every clip in one comparison set to the quietest active-speech RMS. -/// This removes the louder-is-better confound without normalizing dynamics or -/// claiming standards-compliant integrated LUFS. The dBFS value is a ceiling. -fn loudness_match_item(rendered: &mut [(usize, Condition, Vec, u128)]) { - let ceiling = 10.0_f32.powf(TARGET_RMS_DBFS / 20.0); - let target = rendered - .iter() - .filter_map(|(_, _, samples, _)| active_rms(samples)) - .fold(ceiling, f32::min); - for (_, _, samples, _) in rendered { - let Some(rms) = active_rms(samples) else { - continue; - }; - let gain = (target / rms).min(1.0); - for sample in samples { - *sample *= gain; - } - } -} - -fn blinded_order(item_id: &str) -> [usize; 4] { - let mut keyed: Vec<(usize, Vec)> = (0..4) - .map(|index| { - let digest = Sha256::digest(format!("{BLINDING_SEED}:{item_id}:{index}")); - (index, digest.to_vec()) - }) - .collect(); - keyed.sort_by(|a, b| a.1.cmp(&b.1)); - let mut condition_to_clip = [0; 4]; - for (clip, (condition, _)) in keyed.into_iter().enumerate() { - condition_to_clip[condition] = clip; - } - condition_to_clip -} - -fn write_wav(path: &Path, samples: &[f32]) -> Result<(), String> { - let path = path - .to_str() - .ok_or_else(|| format!("non-UTF8 path: {}", path.display()))?; - if sherpa_onnx::write(path, samples, SAMPLE_RATE as i32) { - Ok(()) - } else { - Err(format!("failed to write {path}")) - } -} - -fn write_scoring_sheet(output_dir: &Path, key: &KeyFile) -> Result<(), String> { - let mut sheet = String::from("# Pocket TTS blind listening sheet\n\nDo not open `key.json` until this sheet is complete. Rank best to worst; ties are allowed.\n\n"); - for item in &key.items { - sheet.push_str(&format!( - "## {} ({})\n\n> {}\n\n", - item.id, item.kind, item.text - )); - sheet.push_str("Rank: `____ > ____ > ____ > ____`\n\n| Clip | seam | onset | garble | robotic | timbre | truncate | note |\n|---|---|---|---|---|---|---|---|\n"); - for clip in 1..=4 { - sheet.push_str(&format!( - "| clip{clip} | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | |\n" - )); - } - sheet.push('\n'); - } - fs::write(output_dir.join("SCORING.md"), sheet).map_err(|e| e.to_string()) -} diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs index 169ddf66c0..11c9ee4c7d 100644 --- a/desktop/src-tauri/src/huddle/models.rs +++ b/desktop/src-tauri/src/huddle/models.rs @@ -24,6 +24,10 @@ use std::sync::{Arc, Mutex, OnceLock}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use super::pocket::{ + april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION, +}; + // ── Integrity verification ──────────────────────────────────────────────────── // // All model artifacts are verified against pinned SHA-256 hashes before @@ -38,19 +42,15 @@ use sha2::{Digest, Sha256}; /// Computed from a known-good download. Update when upgrading model versions. const STT_ARCHIVE_SHA256: &str = "17f945007b52ccd8b7200ffc7c5652e9e8e961dfdf479cefcabd06cf5703630b"; -/// HuggingFace base URL for the sherpa-onnx Pocket TTS fp32 repackage. -/// -/// Pinned to commit 96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3 -/// (2026-02-10) for reproducible downloads. -/// -/// fp32 (not int8): a direct same-runtime A/B (k2-fsa/sherpa-onnx#3172) -/// found the ONNX int8 quantization audibly degraded Pocket TTS output and -/// that fp32 "significantly improved quality even at 1 step". The runtime -/// bundle grows from ~189 MB to ~473 MB; encoder, text conditioner, both -/// JSON tables, and LICENSE are byte-identical between the two repos — only -/// the three quantized sessions (lm_main, lm_flow, decoder) change. -const POCKET_HF_BASE: &str = - "https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26/resolve/96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3"; +fn pocket_artifact_url(filename: &str) -> String { + format!( + "https://huggingface.co/{APRIL_MODEL_ID}/resolve/{APRIL_MODEL_REVISION}/onnx/{APRIL_BUNDLE_ID}/{filename}" + ) +} + +fn pocket_license_url() -> String { + format!("https://huggingface.co/{APRIL_MODEL_ID}/resolve/{APRIL_MODEL_REVISION}/onnx/LICENSE") +} /// Reference voice WAV: "Mary (f, conversation)" from the Kyutai TTS demo /// voice set — VCTK speaker p333, ai-coustics-enhanced. Pinned to @@ -64,20 +64,19 @@ const POCKET_HF_BASE: &str = const POCKET_REFERENCE_WAV_URL: &str = "https://huggingface.co/kyutai/tts-voices/resolve/323332d33f997de8394f24a193e1a76df720e01a/vctk/p333_023_enhanced.wav"; -/// SHA-256 hashes for individual Pocket TTS model files. -/// Computed from known-good pinned downloads. Update when upgrading model versions. -#[rustfmt::skip] -const TTS_FILE_HASHES: &[(&str, &str)] = &[ - ("decoder.onnx", "f267880fde6c58b17b0a8f3647eaf8dcfad321f833f32d583ebc2fb2d1a15f10"), - ("encoder.onnx", "e8f2f6d301ffb96e398b138a7dc6d3038622d236044636b73d920bab85890260"), - ("lm_flow.onnx", "79c013a554a54e63319c33c0cc8830cbbedc9b7e448ae7e26f7923ae11f9873e"), - ("lm_main.onnx", "255d1a9263c5abdf36034abfc19c11d21cc5f40f0f87d8361288e972cbd5c578"), - ("text_conditioner.onnx", "0b84e837d7bfaf2c896627b03e3f080320309f37f4fc7df7698c644f7ba5e6b1"), - ("vocab.json", "6fb646346cf931016f70c4921aab0900ce7a304b893cb02135c74e294abfea01"), - ("token_scores.json", "5be2f278caf9b9800741f0fd82bff677f4943ec764c356f907213434b622d958"), - ("LICENSE", "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6"), - ("reference_sample.wav", "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f"), -]; +const TTS_LICENSE_ARTIFACT: PocketModelArtifact = PocketModelArtifact { + filename: "LICENSE", + sha256: "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6", + size_bytes: 18_655, + quantized: false, +}; + +const TTS_REFERENCE_ARTIFACT: PocketModelArtifact = PocketModelArtifact { + filename: "reference_sample.wav", + sha256: "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + size_bytes: 639_084, + quantized: false, +}; // ── Model versioning ────────────────────────────────────────────────────────── // @@ -92,15 +91,8 @@ const TTS_FILE_HASHES: &[(&str, &str)] = &[ /// honest (each version tag identifies one specific set of model bytes). const STT_MODEL_VERSION: &str = "2"; -/// Model manifest version for Pocket TTS. Increment when upgrading model files. -/// Bumped "1" → "2" when the bundled reference voice changed from KevinAHM's -/// anonymous 16 kHz sample to Mary (VCTK p333, 32 kHz, ai-coustics-enhanced) -/// from kyutai/tts-voices. The hash mismatch on `reference_sample.wav` would -/// fail readiness on its own, but the manifest bump makes the re-download -/// reason explicit and skips the failing-then-re-fetching transient state. -/// Bumped "2" → "3" for the int8 → fp32 model swap (see `POCKET_HF_BASE`): -/// existing int8 installs must re-download the suffixless fp32 sessions. -const TTS_MODEL_VERSION: &str = "3"; +/// Identifies the exact April INT8 asset set expected by readiness checks. +const TTS_MODEL_VERSION: &str = "4"; /// Filename for the version manifest written alongside model files. const MANIFEST_FILENAME: &str = ".buzz-model-manifest"; @@ -110,9 +102,9 @@ const MANIFEST_FILENAME: &str = ".buzz-model-manifest"; /// Maximum expected STT archive size (200 MB — actual is ~100 MB). const MAX_STT_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024; -/// Maximum expected Pocket TTS file size (400 MB per file — largest is -/// `lm_main.onnx` at ~303 MB fp32). -const MAX_TTS_FILE_BYTES: u64 = 400 * 1024 * 1024; +/// Maximum expected Pocket TTS file size. The largest pinned INT8 artifact is +/// `flow_lm_main_int8.onnx` at 76,341,079 bytes. +const MAX_TTS_FILE_BYTES: u64 = 100 * 1024 * 1024; /// NVIDIA Parakeet TDT-CTC 110M (English, int8) — packaged for sherpa-onnx by /// k2-fsa. Single ONNX file (CTC head) + tokens.txt. Avg WER ~7.5% across @@ -181,9 +173,9 @@ Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926). Mimi neural codec by Kyutai is bundled as part of the model. -ONNX export by KevinAHM: https://huggingface.co/KevinAHM/pocket-tts-onnx -Sherpa-onnx repackage by csukuangfj / k2-fsa: -https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26 +April 2026 ONNX export by KevinAHM: +https://huggingface.co/KevinAHM/pocket-tts-onnx +Pinned revision: 58a6d00cf13d239b6748cb0769f35c580a8f606c Bundled reference voice (reference_sample.wav): \"Mary (f, conversation)\" preset from the Kyutai TTS demo voice catalogue @@ -203,13 +195,14 @@ license text for full warranty disclaimer. /// All files that must be present for Pocket TTS to be considered ready. const TTS_EXPECTED_FILES: &[&str] = &[ - "decoder.onnx", - "encoder.onnx", - "lm_flow.onnx", - "lm_main.onnx", + "bundle.json", + "bos_before_voice.npy", + "flow_lm_main_int8.onnx", + "flow_lm_flow_int8.onnx", + "mimi_decoder_int8.onnx", + "mimi_encoder.onnx", "text_conditioner.onnx", - "vocab.json", - "token_scores.json", + "tokenizer.model", "LICENSE", "reference_sample.wav", TTS_LICENSE_FILE_NAME, @@ -404,6 +397,7 @@ struct ModelSlot { dir_name: &'static str, // subdir under ~/.buzz/models/ expected_files: &'static [&'static str], // files required for "ready" version: &'static str, // manifest version; increment to force re-download + expected_size: fn(&str) -> Option, status: Arc>, just_ready: Arc, // fires once when download completes } @@ -418,11 +412,17 @@ impl ModelSlot { dir_name, expected_files, version, + expected_size: |_| None, status: Arc::new(Mutex::new(ModelStatus::NotDownloaded)), just_ready: Arc::new(AtomicBool::new(false)), } } + fn with_expected_sizes(mut self, expected_size: fn(&str) -> Option) -> Self { + self.expected_size = expected_size; + self + } + fn model_dir(&self, models_dir: &Path) -> PathBuf { models_dir.join(self.dir_name) } @@ -432,7 +432,17 @@ impl ModelSlot { std::fs::read_to_string(dir.join(MANIFEST_FILENAME)) .map(|v| v.trim() == self.version) .unwrap_or(false) - && self.expected_files.iter().all(|f| dir.join(f).is_file()) + && self.expected_files.iter().all(|filename| { + let path = dir.join(filename); + path.is_file() + && (self.expected_size)(filename) + .map(|expected| { + path.metadata() + .map(|metadata| metadata.len() == expected) + .unwrap_or(false) + }) + .unwrap_or(true) + }) } fn dir_if_ready(&self, models_dir: &Path) -> Option { @@ -453,6 +463,39 @@ impl ModelSlot { self.just_ready.swap(false, Ordering::AcqRel) } + /// Recover or clean up the backup left by an interrupted atomic install. + fn recover_interrupted_install(&self, models_dir: &Path) { + let final_dir = self.model_dir(models_dir); + let backup_dir = final_dir.with_extension("old"); + if !backup_dir.exists() { + return; + } + if self.is_ready(models_dir) { + if let Err(error) = std::fs::remove_dir_all(&backup_dir) { + eprintln!( + "buzz-desktop: could not remove stale {} backup: {error}", + self.dir_name + ); + } + return; + } + if final_dir.exists() { + if let Err(error) = std::fs::remove_dir_all(&final_dir) { + eprintln!( + "buzz-desktop: could not remove incomplete {} install: {error}", + self.dir_name + ); + return; + } + } + if let Err(error) = std::fs::rename(&backup_dir, &final_dir) { + eprintln!( + "buzz-desktop: could not restore interrupted {} install: {error}", + self.dir_name + ); + } + } + /// Spawn a background download task if not already ready or downloading. fn start_download( &self, @@ -511,6 +554,9 @@ impl ModelSlot { )); } + std::fs::write(source_dir.join(MANIFEST_FILENAME), self.version) + .map_err(|e| format!("write model manifest: {e}"))?; + let final_dir = self.model_dir(models_dir); let backup_dir = final_dir.with_extension("old"); @@ -529,8 +575,6 @@ impl ModelSlot { return Err(format!("install new model: {e}")); } - std::fs::write(final_dir.join(MANIFEST_FILENAME), self.version) - .map_err(|e| format!("write model manifest: {e}"))?; let _ = tokio::fs::remove_dir_all(&backup_dir).await; if let Some(extra) = temp_cleanup { let _ = tokio::fs::remove_dir_all(extra).await; @@ -542,6 +586,25 @@ impl ModelSlot { } } +fn tts_expected_size(filename: &str) -> Option { + april_model_info() + .artifacts + .iter() + .find(|artifact| artifact.filename == filename) + .map(|artifact| artifact.size_bytes) + .or_else(|| { + [TTS_LICENSE_ARTIFACT, TTS_REFERENCE_ARTIFACT] + .iter() + .find(|artifact| artifact.filename == filename) + .map(|artifact| artifact.size_bytes) + }) +} + +fn tts_model_slot() -> ModelSlot { + ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION) + .with_expected_sizes(tts_expected_size) +} + // ── ModelManager ────────────────────────────────────────────────────────────── /// Manages download and location of STT/TTS model files. @@ -561,11 +624,13 @@ impl ModelManager { /// Returns `None` if the home directory cannot be resolved. pub fn new() -> Option { let models_dir = dirs::home_dir()?.join(".buzz").join("models"); - Some(Self { + let manager = Self { models_dir, stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION), - tts: ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION), - }) + tts: tts_model_slot(), + }; + manager.tts.recover_interrupted_install(&manager.models_dir); + Some(manager) } // ── STT accessors ──────────────────────────────────────────────────────── @@ -638,7 +703,7 @@ impl ModelManager { } } - /// Start a background Pocket TTS download (~189 MB). No-op if already ready or downloading. + /// Start a background Pocket TTS download. No-op if already ready or downloading. pub fn start_tts_download(&self, http_client: reqwest::Client) { let manager = self.clone(); self.tts.start_download( @@ -754,8 +819,8 @@ impl ModelManager { /// Download and verify the Pocket TTS model files from HuggingFace. /// /// Downloads files into `~/.buzz/models/pocket-tts/`: - /// - five ONNX sessions (Pocket TTS + Mimi codec) - /// - `vocab.json` / `token_scores.json` for sherpa-onnx text conditioning + /// - five ONNX sessions selected by the April INT8 bundle + /// - bundle metadata, SentencePiece tokenizer, and learned voice BOS /// - upstream `LICENSE` plus Buzz's `MODEL_LICENSE.txt` attribution sidecar /// - `reference_sample.wav` as the bundled default voice /// @@ -768,24 +833,18 @@ impl ModelManager { let temp_dir = self.models_dir.join("pocket-tts.tmp"); fresh_temp_dir(&temp_dir).await?; - let model_files = [ - "decoder.onnx", - "encoder.onnx", - "lm_flow.onnx", - "lm_main.onnx", - "text_conditioner.onnx", - "vocab.json", - "token_scores.json", - "LICENSE", - ]; - let mut downloads: Vec<(String, &'static str)> = model_files + let mut downloads: Vec<(String, PocketModelArtifact)> = april_model_info() + .artifacts .iter() - .map(|filename| (format!("{POCKET_HF_BASE}/{filename}"), *filename)) + .copied() + .map(|artifact| (pocket_artifact_url(artifact.filename), artifact)) .collect(); - downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), "reference_sample.wav")); + downloads.push((pocket_license_url(), TTS_LICENSE_ARTIFACT)); + downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), TTS_REFERENCE_ARTIFACT)); let total_files = downloads.len() as u32; - for (i, (url, filename)) in downloads.iter().enumerate() { + for (i, (url, artifact)) in downloads.iter().enumerate() { + let filename = artifact.filename; eprintln!("buzz-desktop: downloading Pocket TTS {filename} from {url}"); let response = fetch_url(&http_client, url, filename) @@ -822,16 +881,19 @@ impl ModelManager { })?; eprintln!("buzz-desktop: downloaded {bytes} bytes ({filename}), wrote to disk"); - let expected = TTS_FILE_HASHES - .iter() - .find(|(n, _)| *n == *filename) - .map(|(_, hash)| *hash) - .ok_or_else(|| format!("missing expected hash for Pocket TTS file: {filename}"))?; + if bytes != artifact.size_bytes { + let _ = tokio::fs::remove_dir_all(&temp_dir).await; + return Err(format!( + "Pocket TTS {filename} size check failed: expected {} bytes, got {bytes}", + artifact.size_bytes + )); + } let actual = sha256_file(&dest).await?; - if actual != expected { + if actual != artifact.sha256 { let _ = tokio::fs::remove_dir_all(&temp_dir).await; return Err(format!( - "Pocket TTS {filename} integrity check failed: expected {expected}, got {actual}" + "Pocket TTS {filename} integrity check failed: expected {}, got {actual}", + artifact.sha256 )); } @@ -931,24 +993,5 @@ pub fn is_tts_ready() -> bool { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tts_readiness_requires_license_sidecar() { - let temp = tempfile::tempdir().expect("tempdir"); - let slot = ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION); - let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); - std::fs::create_dir_all(&model_dir).expect("create model dir"); - - for file in TTS_EXPECTED_FILES { - std::fs::write(model_dir.join(file), b"test").expect("write expected file"); - } - std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest"); - - assert!(slot.is_ready(temp.path())); - - std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar"); - assert!(!slot.is_ready(temp.path())); - } -} +#[path = "models_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs new file mode 100644 index 0000000000..4bcb4081e0 --- /dev/null +++ b/desktop/src-tauri/src/huddle/models_tests.rs @@ -0,0 +1,148 @@ +use super::*; + +fn create_ready_model_dir(root: &Path) -> PathBuf { + let model_dir = root.join(TTS_MODEL_DIR_NAME); + std::fs::create_dir_all(&model_dir).expect("create model dir"); + for file in TTS_EXPECTED_FILES { + let path = model_dir.join(file); + let handle = std::fs::File::create(path).expect("create expected file"); + if let Some(size) = tts_expected_size(file) { + handle.set_len(size).expect("size expected file"); + } else { + std::fs::write(model_dir.join(file), b"test").expect("write expected file"); + } + } + std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest"); + model_dir +} + +#[test] +fn expected_files_match_april_int8_metadata() { + let mut expected = april_model_info() + .artifacts + .iter() + .map(|artifact| artifact.filename) + .chain([ + TTS_LICENSE_ARTIFACT.filename, + TTS_REFERENCE_ARTIFACT.filename, + TTS_LICENSE_FILE_NAME, + ]) + .collect::>(); + expected.sort_unstable(); + let mut actual = TTS_EXPECTED_FILES.to_vec(); + actual.sort_unstable(); + + assert_eq!(actual, expected); + assert!(!actual.contains(&"flow_lm_main.onnx")); + assert!(!actual.contains(&"flow_lm_flow.onnx")); + assert!(!actual.contains(&"mimi_decoder.onnx")); +} + +#[test] +fn tts_readiness_requires_license_sidecar() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + + assert!(slot.is_ready(temp.path())); + + std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar"); + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn tts_readiness_rejects_truncated_pinned_artifact() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + let artifact = april_model_info().artifacts[0]; + + std::fs::OpenOptions::new() + .write(true) + .open(model_dir.join(artifact.filename)) + .expect("open artifact") + .set_len(artifact.size_bytes - 1) + .expect("truncate artifact"); + + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn january_cache_is_not_ready_for_april_int8() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); + std::fs::create_dir_all(&model_dir).expect("create model dir"); + for file in [ + "decoder.onnx", + "encoder.onnx", + "lm_flow.onnx", + "lm_main.onnx", + "text_conditioner.onnx", + "vocab.json", + "token_scores.json", + "LICENSE", + "reference_sample.wav", + TTS_LICENSE_FILE_NAME, + ] { + std::fs::write(model_dir.join(file), b"january").expect("write January file"); + } + std::fs::write(model_dir.join(MANIFEST_FILENAME), "3").expect("manifest"); + + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn interrupted_install_restores_backup_when_destination_is_missing() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert_eq!( + std::fs::read(temp.path().join(TTS_MODEL_DIR_NAME).join("sentinel")) + .expect("restored sentinel"), + b"previous" + ); + assert!(!backup_dir.exists()); +} + +#[test] +fn interrupted_install_replaces_incomplete_destination_with_backup() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&model_dir).expect("create incomplete destination"); + std::fs::write(model_dir.join("incomplete"), b"april").expect("write incomplete file"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert_eq!( + std::fs::read(model_dir.join("sentinel")).expect("restored sentinel"), + b"previous" + ); + assert!(!model_dir.join("incomplete").exists()); + assert!(!backup_dir.exists()); +} + +#[test] +fn ready_destination_removes_stale_backup() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert!(slot.is_ready(temp.path())); + assert!(model_dir.exists()); + assert!(!backup_dir.exists()); +} diff --git a/desktop/src-tauri/src/huddle/pocket.rs b/desktop/src-tauri/src/huddle/pocket.rs index ee1faf928a..2154a25c22 100644 --- a/desktop/src-tauri/src/huddle/pocket.rs +++ b/desktop/src-tauri/src/huddle/pocket.rs @@ -1,184 +1,52 @@ -//! Pocket TTS engine wrapper around sherpa-onnx's `OfflineTts`. +//! April 2026 Pocket TTS engine for Buzz Desktop. //! -//! Pocket TTS is a small (~473 MB fp32 ONNX) zero-shot voice-cloning TTS -//! model from Kyutai. It runs quickly on CPU via sherpa-onnx, replacing the -//! previous Kokoro-82M engine that also required an espeak-free but -//! lexicon-heavy G2P pipeline (Misaki + CMUdict). -//! -//! Full-precision fp32 sessions, not the ~189 MB int8 quantization we -//! originally shipped: a direct same-runtime A/B (k2-fsa/sherpa-onnx#3172) -//! found the int8 ONNX export audibly degraded output quality, and fp32 -//! "significantly improved quality even at 1 step". +//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned +//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding. +//! Buzz selects the upstream three-graph INT8 variant while retaining the +//! full-precision Mimi encoder and text conditioner specified by that variant. //! //! ## Attribution //! -//! - **Model**: Kyutai *Pocket TTS* — Charles, Roebel, et al., 2026. -//! arXiv:2509.06926. Original repository: . -//! Licensed CC-BY-4.0. -//! - **Mimi neural codec**: Kyutai, bundled in the same release. CC-BY-4.0. -//! - **ONNX export**: KevinAHM — -//! . CC-BY-4.0. -//! - **sherpa-onnx repackage**: csukuangfj / k2-fsa — -//! . -//! Repackages KevinAHM's export with the file layout sherpa-onnx's -//! `OfflineTtsPocketModelConfig` expects. CC-BY-4.0. -//! - **Reference voice WAV** (`reference_sample.wav`): the "Mary -//! (f, conversation)" preset from the Kyutai TTS demo -//! (), which maps to `vctk/p333_023_enhanced.wav` -//! in . CC-BY-4.0, base recording -//! from the VCTK corpus, enhanced by ai-coustics. -//! -//! Buzz ships these files unmodified; see the on-disk `MODEL_LICENSE.txt` -//! sidecar written by `huddle::models` during install for the canonical -//! CC-BY-4.0 §3(a)(1) attribution block. -//! -//! ## Engine-module contract (see `huddle::tts`) +//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0. +//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0. +//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0. //! -//! `pocket.rs` exposes a fixed surface used by `tts.rs`. Mirroring this -//! contract is what lets the TTS pipeline stay engine-agnostic: -//! -//! - `SAMPLE_RATE: u32` — engine output sample rate in Hz. -//! - `DEFAULT_VOICE: &str` — default voice name (without extension). -//! - `VOICE_FILE_EXT: &str` — extension for per-voice files on disk. -//! - `load_text_to_speech(model_dir)` → `Result` -//! - `load_voice_style(path)` → `Result` -//! - `Engine::synth_chunk(&self, text, lang, &VoiceStyle, steps)` -//! → `Result, String>` -//! -//! `lang` and `steps` are accepted for API compatibility with the previous -//! Kokoro engine but are unused — Pocket TTS does its own language ID from -//! the input text and is not a diffusion model (consistency LM, one step). -//! There is no speed knob: sherpa-onnx's `GenerationConfig.speed` is only -//! read by some model families (vits), never by the Pocket impl -//! (`offline-tts-pocket-impl.h` — zero references), and upstream pocket-tts -//! has no speed parameter either. +//! `huddle::models` writes the complete attribution beside the cached bytes. -use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use sherpa_onnx::Wave; -use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave}; +#[path = "pocket_april.rs"] +mod pocket_april; +#[path = "pocket_models.rs"] +mod pocket_models; -// ── Engine-module contract: public consts ───────────────────────────────────── +use pocket_april::{prepare_april_prompt, AprilPocketTts}; +pub(crate) use pocket_models::{ + april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION, +}; -/// Pocket TTS emits 24 kHz mono PCM. Matches the previous Kokoro output rate, -/// so the rodio sink and inter-sentence silence buffer in `tts.rs` remain valid. +/// Pocket TTS emits 24 kHz mono PCM. pub const SAMPLE_RATE: u32 = 24_000; -/// Name (without extension) of the bundled reference voice. The model directory -/// is expected to contain `.` after install. +/// Bundled reference voice name without its extension. pub const DEFAULT_VOICE: &str = "reference_sample"; -/// Voice files for Pocket TTS are reference audio (WAV). Distinct from the -/// Kokoro `.bin` style vectors — the model conditions on raw waveform samples, -/// not a precomputed embedding, so the extension change is honest. +/// Pocket voice files are reference WAVs. pub const VOICE_FILE_EXT: &str = "wav"; -// ── Tuning ──────────────────────────────────────────────────────────────────── - -/// Single-threaded ONNX execution for predictable CPU contention with the STT -/// pipeline. Matches `STT_NUM_THREADS` in `stt.rs`; raise only if a benchmark -/// argues for it. -const TTS_NUM_THREADS: i32 = 1; - -/// LRU cache size for cloned voice embeddings inside the sherpa-onnx engine. -/// We bind to one voice per pipeline today, but the upstream example uses 16 -/// and the cost is negligible — keep room for future multi-voice support. -const VOICE_EMBEDDING_CACHE_CAPACITY: i32 = 16; - -/// Pocket TTS is a consistency-based LM. Generation quality saturates at one -/// denoising step — the upstream `GenerationConfig` default of 5 multiplies -/// synthesis time by ~5× with no audible benefit on this model. -const SYNTH_NUM_STEPS: i32 = 1; - -/// Leave the generated audio's silences untouched (1.0 is the identity). -/// -/// sherpa-onnx's `ScaleSilence` (`offline-tts.cc`) is *not* pre/post padding -/// control: it finds every interior silence run ≥ 0.2 s (|s| ≤ 0.01) and -/// multiplies its length by this factor. The previous value of 0.0 — set -/// under the mistaken belief it disabled lead-in/lead-out padding — deleted -/// every natural pause inside an utterance: clause breaks, breaths, the gap -/// after a comma. Words slammed together and endings cut abruptly. The -/// reference Pocket TTS pipeline does not post-process silence at all; -/// 1.0 restores parity. -const SYNTH_SILENCE_SCALE: f32 = 1.0; - -/// sherpa-onnx upstream default for `max_frames` (LM steps), in -/// `offline-tts-pocket-impl.h:Generate`. 500 steps ≈ 40 s of audio at the -/// Mimi 12.5 Hz frame rate. Referenced only by the regression test below; -/// production code path never raises (or even reads) this value — we just -/// leave sherpa-onnx's own default in place by not setting the override. -#[cfg(test)] -const SHERPA_ONNX_MAX_FRAMES_DEFAULT: i32 = 500; - -/// Tight `max_frames` we ask for on short, padded prompts to bound the -/// original "monster breathing" runaway. 100 LM steps ≈ 8 s of audio — -/// roomy for any one-to-four-word utterance the user is likely to elicit -/// while still well short of the 40 s upstream default. Chosen with slack so -/// we never *truncate* a legitimate short reply. -const SHORT_PROMPT_MAX_FRAMES: i32 = 100; - -/// Word-count threshold (inclusive) below which we pad the prompt with -/// leading spaces and cap `max_frames` tighter than the upstream default. -/// Matches upstream `pocket_tts.models.tts_model.prepare_text_prompt`. Above -/// this threshold we leave sherpa-onnx's own defaults in place — overriding -/// them caused the "first 'yep' is just static" regression seen on -/// 2026-05-18, where dropping `frames_after_eos` below the upstream default -/// of 3 clipped the leading audio of multi-clause sentences. -const SHORT_PROMPT_WORD_THRESHOLD: usize = 4; - -/// Number of leading spaces prepended to short prompts. The upstream Python -/// uses exactly 8 — keep parity rather than tuning blindly. -/// -/// This is upstream's *only* mitigation for the FlowLM cold-start smear on -/// short utterances (kyutai-labs/pocket-tts #91, #70): the autoregressive -/// generation has a 2–3 step "settle" period where the first phoneme can be -/// smeared. A previous revision added a sacrificial `". . "` prefix plus an -/// amplitude-threshold trim to strip the rendered prefix from the output — -/// but the trim's absolute threshold (0.02 against raw peaks of ~0.076) sat -/// in soft-onset territory and could eat real word starts, and its tuning -/// was calibrated against `silence_scale = 0.0` audio. Deleted in favour of -/// upstream parity: accept the occasional smeared first syllable rather -/// than risk trimming real speech. -const SHORT_PROMPT_PAD_SPACES: usize = 8; - -/// sherpa-onnx's documented `frames_after_eos` default. We deliberately do -/// *not* override this knob — the previous attempt to bump it for short -/// inputs and lower it for long inputs lowered it below the upstream default -/// of 3, which clipped the leading audio of multi-clause sentences (the -/// "first 'yep' is static" regression). The constant exists only for the -/// regression test below. Source: `offline-tts-pocket-impl.h:Generate`. -#[cfg(test)] -const SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT: i32 = 3; - -// ── ONNX file names (five Pocket TTS sessions plus two JSON tables) ─────────── +const TTS_NUM_THREADS: usize = 1; -const FILE_LM_MAIN: &str = "lm_main.onnx"; -const FILE_LM_FLOW: &str = "lm_flow.onnx"; -const FILE_ENCODER: &str = "encoder.onnx"; -const FILE_DECODER: &str = "decoder.onnx"; -const FILE_TEXT_COND: &str = "text_conditioner.onnx"; -const FILE_VOCAB: &str = "vocab.json"; -const FILE_TOKEN_SCORES: &str = "token_scores.json"; - -// ── Voice style ─────────────────────────────────────────────────────────────── - -/// Loaded reference voice — normalised f32 PCM samples plus their sample rate. -/// -/// Pocket TTS takes a reference waveform per generation call (not a -/// precomputed style embedding), so we keep the samples in memory and clone -/// the small `Vec` into each `GenerationConfig` rather than re-reading the -/// WAV from disk on every sentence. +/// Loaded reference voice samples and their original sample rate. #[derive(Debug, Clone)] pub struct VoiceStyle { samples: Vec, sample_rate: i32, } -/// Load a reference voice WAV from disk. -/// -/// Accepts any sample rate sherpa-onnx's `Wave::read` can decode — Pocket TTS -/// resamples internally using `reference_sample_rate`. The bundled -/// `reference_sample.wav` ("Mary" — VCTK p333, enhanced) is 32 kHz mono. +/// Load a Pocket reference voice WAV from disk. pub fn load_voice_style(path: &Path) -> Result { let path_str = path .to_str() @@ -195,199 +63,46 @@ pub fn load_voice_style(path: &Path) -> Result { }) } -// ── Engine ──────────────────────────────────────────────────────────────────── - -/// Pocket TTS engine handle. Cheap to construct (one `OfflineTts::create` -/// call). Owned by the TTS worker thread for the lifetime of a huddle session. -/// -/// `OfflineTts` does not implement `Debug`, so we don't derive it here — the -/// pipeline only needs to move the engine into the worker thread and call -/// `synth_chunk` on it, never to print it. +/// Resident April INT8 Pocket TTS engine. pub struct PocketTts { - inner: OfflineTts, + inner: Mutex, } -/// Build the Pocket TTS engine from the model directory installed by -/// `huddle::models`. Returns `Err` if any expected ONNX or JSON file is -/// missing — readiness is normally enforced by `is_tts_ready` upstream, but -/// the check is repeated here so a manually-modified model dir produces a -/// clear error string instead of an opaque sherpa-onnx `None`. +/// Load Buzz Desktop's pinned April INT8 model. pub fn load_text_to_speech(model_dir: &str) -> Result { let dir = PathBuf::from(model_dir); - for name in [ - FILE_LM_MAIN, - FILE_LM_FLOW, - FILE_ENCODER, - FILE_DECODER, - FILE_TEXT_COND, - FILE_VOCAB, - FILE_TOKEN_SCORES, - ] { - let p = dir.join(name); - if !p.is_file() { - return Err(format!("missing Pocket TTS file: {}", p.display())); - } - } - - let to_str = |name: &str| -> String { dir.join(name).to_string_lossy().into_owned() }; - - // Build the config by mutating defaults — mirrors `stt.rs` and stays - // resilient if sherpa-onnx adds unrelated model-family fields. - let mut cfg = OfflineTtsConfig::default(); - cfg.model.pocket.lm_main = Some(to_str(FILE_LM_MAIN)); - cfg.model.pocket.lm_flow = Some(to_str(FILE_LM_FLOW)); - cfg.model.pocket.encoder = Some(to_str(FILE_ENCODER)); - cfg.model.pocket.decoder = Some(to_str(FILE_DECODER)); - cfg.model.pocket.text_conditioner = Some(to_str(FILE_TEXT_COND)); - cfg.model.pocket.vocab_json = Some(to_str(FILE_VOCAB)); - cfg.model.pocket.token_scores_json = Some(to_str(FILE_TOKEN_SCORES)); - cfg.model.pocket.voice_embedding_cache_capacity = VOICE_EMBEDDING_CACHE_CAPACITY; - cfg.model.num_threads = TTS_NUM_THREADS; - // Explicit — defaults are not part of the API contract, and noisy debug - // logging in release builds would be expensive on every synthesized chunk. - cfg.model.debug = false; - - let inner = OfflineTts::create(&cfg) - .ok_or_else(|| "OfflineTts::create returned None for Pocket TTS".to_string())?; - Ok(PocketTts { inner }) -} - -// ── Prompt preparation ──────────────────────────────────────────────────────── - -/// Result of [`prepare_pocket_prompt`]: a synthesizer-ready prompt plus the -/// per-call generation overrides derived from the original text. -/// -/// `None` for either override means "leave sherpa-onnx's documented default -/// in place". The pipeline only sets `max_frames` (and only for short -/// padded inputs) so it can bound the original "monster breathing" runaway -/// without disturbing the rest of the LM sampling envelope. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct PreparedPrompt { - /// Text to hand to `OfflineTts::generate_with_config`. Capitalized, - /// punctuation-terminated, and (for short inputs) left-padded with - /// spaces — upstream's mitigation for the FlowLM cold-start smear. - pub text: String, - /// Value to pass via `GenerationConfig.extra["max_frames"]`, or `None` to - /// keep the upstream default of 500 LM steps. We only override on short - /// padded prompts where we have a tight expectation on output length. - pub max_frames: Option, -} - -/// Mirror of the *text-preparation* half of upstream -/// `pocket_tts.models.tts_model.prepare_text_prompt`. Sherpa-onnx's C++ -/// Pocket TTS impl does not run these preparation steps, so short / -/// unpunctuated / lowercase inputs can trigger up to 40 s of runaway -/// generation when the EOS logit never crosses its threshold. We replicate -/// the upstream Python recipe here: -/// -/// 1. Collapse interior whitespace (already done by `preprocess_for_tts`, but -/// cheap to re-check after sentence splitting). -/// 2. Capitalize the first letter. -/// 3. Append `.` if the text doesn't end in punctuation. -/// 4. If fewer than five words, prepend `SHORT_PROMPT_PAD_SPACES` spaces -/// (upstream's cold-start mitigation — see the constant's docstring) and -/// return a tight [`SHORT_PROMPT_MAX_FRAMES`] cap so the LM can't run -/// away if EOS still doesn't fire. -/// -/// We do **not** override `frames_after_eos` — sherpa-onnx's default of 3 -/// is what we want. An earlier version set it to 1 on long inputs, which -/// clipped the leading audio of multi-clause sentences ("first 'yep' is -/// just static" regression). Tests `prepare_prompt_never_lowers_frames_…` -/// lock this in. -/// -/// Returns `None` only if the input is empty after trimming — caller should -/// skip synthesis in that case. -pub(crate) fn prepare_pocket_prompt(input: &str) -> Option { - let trimmed = input.trim(); - if trimmed.is_empty() { - return None; - } - - // Collapse stray double-spaces / embedded newlines that may slip past - // `preprocess_for_tts` when sentences are spliced back together. - let mut cleaned = String::with_capacity(trimmed.len()); - let mut last_was_space = false; - for ch in trimmed.chars() { - let is_ws = ch.is_whitespace(); - if is_ws { - if !last_was_space { - cleaned.push(' '); - } - last_was_space = true; - } else { - cleaned.push(ch); - last_was_space = false; + for artifact in april_model_info().artifacts { + let path = dir.join(artifact.filename); + if !path.is_file() { + return Err(format!( + "incomplete Pocket TTS {} INT8 bundle: missing {}", + APRIL_BUNDLE_ID, + path.display() + )); } } - - // Capitalize first character. Uses `to_uppercase` (multi-codepoint safe). - let first = cleaned.chars().next().expect("cleaned non-empty above"); - if first.is_lowercase() { - let upper: String = first.to_uppercase().collect(); - let mut iter = cleaned.chars(); - iter.next(); - cleaned = upper + iter.as_str(); - } - - // Ensure terminal punctuation. Anything not in `.!?;:,` gets a period. - // The upstream Python only checks `isalnum` → period, but for our agent - // text we already may end in `!` `?` `.` etc. — treat any of those as OK. - let last = cleaned - .chars() - .next_back() - .expect("cleaned non-empty above"); - if !matches!(last, '.' | '!' | '?' | ';' | ':' | ',') { - cleaned.push('.'); - } - - // Word count of the *cleaned but not padded* text — padding is whitespace - // only and would just lie to the threshold check below. - let word_count = cleaned.split_whitespace().count(); - - let (final_text, max_frames) = if word_count <= SHORT_PROMPT_WORD_THRESHOLD { - let mut padded = String::with_capacity(cleaned.len() + SHORT_PROMPT_PAD_SPACES); - for _ in 0..SHORT_PROMPT_PAD_SPACES { - padded.push(' '); - } - padded.push_str(&cleaned); - (padded, Some(SHORT_PROMPT_MAX_FRAMES)) - } else { - // For everything ≥5 words, fall back to upstream defaults. Overriding - // these is what caused the "first 'yep' is static" regression — the - // upstream LM has been tuned for `frames_after_eos = 3` and - // `max_frames = 500`, and there's no clear win in second-guessing. - (cleaned, None) - }; - - Some(PreparedPrompt { - text: final_text, - max_frames, - }) -} - -/// Build the `GenerationConfig.extra` HashMap from a [`PreparedPrompt`]. -/// -/// Centralised so the regression test below can assert that we **never** -/// emit a `frames_after_eos` override — the previous attempt to override -/// that knob (setting it to 1 for ≥5-word inputs) clipped the leading -/// audio of multi-clause sentences (the "first 'yep' is static" bug on -/// 2026-05-18). The upstream sherpa-onnx default of 3 is what we want, and -/// the right way to keep it is to not set it at all. -fn build_generation_extra(prepared: &PreparedPrompt) -> Option> { - prepared.max_frames.map(|mf| { - let mut h: HashMap = HashMap::with_capacity(1); - h.insert("max_frames".to_string(), serde_json::Value::from(mf)); - h + Ok(PocketTts { + inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?), }) } impl PocketTts { - /// Synthesise `text` with the given reference voice. + /// Split text into synthesis units that satisfy the bundle's exact + /// 50-token input limit. + pub fn split_text_into_chunks(&self, text: &str) -> Result, String> { + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); + }; + self.inner + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())? + .split_prompt(&prepared) + } + + /// Synthesize text with the supplied reference voice. /// - /// `_lang` and `_steps` are accepted for API compatibility with the - /// previous Kokoro engine. Pocket TTS infers language from the input text - /// directly and is a one-step consistency model. Returns an empty buffer - /// for whitespace-only input. + /// Pocket detects language from text and this model uses one synthesis + /// step, so `_lang` and `_steps` intentionally do not affect output. pub fn synth_chunk( &self, text: &str, @@ -395,57 +110,21 @@ impl PocketTts { style: &VoiceStyle, _steps: usize, ) -> Result, String> { - // Mirror upstream pocket-tts prompt prep — without this short or - // unpunctuated inputs can cause the LM's EOS logit to never trip, - // producing up to 40 s of "monster breathing" garbage on the first - // utterance. See `prepare_pocket_prompt` for the full recipe. - let prepared = match prepare_pocket_prompt(text) { - Some(p) => p, - None => return Ok(Vec::new()), + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); }; - - // Per-call generation hints sherpa-onnx forwards to - // `offline-tts-pocket-impl.h`. We only override `max_frames`, and - // only for short padded prompts where we have a tight expectation - // on output length — that bounds the original runaway without - // disturbing the rest of the LM sampling envelope. See - // `prepare_pocket_prompt` docs for the regression history. - let extra = build_generation_extra(&prepared); - - let cfg = GenerationConfig { - num_steps: SYNTH_NUM_STEPS, - silence_scale: SYNTH_SILENCE_SCALE, - reference_audio: Some(style.samples.clone()), - reference_sample_rate: style.sample_rate, - extra, - // `speed` stays at its default: the Pocket impl never reads it - // (see the engine-contract note in the module docs). - ..Default::default() - }; - - // No progress callback — synthesis is fast enough that returning the - // whole buffer at once keeps the lookahead pipelining in `tts.rs` - // simple. `None:: bool>` pins the callback type for the - // `generate_with_config` generic parameter. - let audio = self + let mut engine = self .inner - .generate_with_config(&prepared.text, &cfg, None:: bool>) - .ok_or_else(|| { - format!( - "Pocket TTS synthesis failed for text ({} chars)", - prepared.text.len() - ) - })?; - - let sample_rate = audio.sample_rate(); - if sample_rate != SAMPLE_RATE as i32 { - eprintln!( - "buzz-desktop: Pocket TTS returned unexpected sample rate {sample_rate}Hz \ - (expected {SAMPLE_RATE}Hz); playback speed may be wrong" - ); + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?; + let chunks = engine.split_prompt(&prepared)?; + let mut samples = Vec::new(); + for chunk in chunks { + let prepared = prepare_april_prompt(&chunk) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + samples.extend(engine.synth_chunk(&prepared, style)?); } - - Ok(audio.samples().to_vec()) + Ok(samples) } } @@ -453,202 +132,35 @@ impl PocketTts { mod tests { use super::*; - // ── prepare_pocket_prompt ──────────────────────────────────────────────── - - #[test] - fn prepare_prompt_returns_none_for_empty_input() { - assert!(prepare_pocket_prompt("").is_none()); - assert!(prepare_pocket_prompt(" ").is_none()); - assert!(prepare_pocket_prompt("\n\t ").is_none()); - } - - /// Helper: the exact leading sequence prepended to every short prompt — - /// 8 spaces of padding (upstream's cold-start mitigation). - /// Centralising this keeps the assertions readable. - fn short_prefix() -> String { - " ".repeat(SHORT_PROMPT_PAD_SPACES) - } - - #[test] - fn prepare_prompt_pads_and_capitalizes_one_word() { - // The "yep" case Tyler hit in production — bare lowercase one-word - // utterance with no punctuation. Must be padded with the short-prompt - // space pad, capitalized, terminated, with a tight `max_frames` cap - // to bound runaway gen. - let out = prepare_pocket_prompt("yep").expect("non-empty"); - assert_eq!(out.text, format!("{}Yep.", short_prefix())); - assert_eq!(out.max_frames, Some(SHORT_PROMPT_MAX_FRAMES)); - const { - assert!( - SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT, - "short cap must be tighter than the upstream default" - ); - } - } - - #[test] - fn prepare_prompt_preserves_existing_punctuation() { - let out = prepare_pocket_prompt("yes!").expect("non-empty"); - assert_eq!(out.text, format!("{}Yes!", short_prefix())); // exclamation kept - let out = prepare_pocket_prompt("really?").expect("non-empty"); - assert_eq!(out.text, format!("{}Really?", short_prefix())); - } - #[test] - fn prepare_prompt_threshold_is_inclusive_at_four_words() { - // 4 words = short (padded + tight max_frames); 5 words = long - // (no padding, no overrides — upstream defaults stand). - let four = prepare_pocket_prompt("one two three four").expect("non-empty"); - assert_eq!( - four.text, - format!("{}One two three four.", short_prefix()), - "four-word input should get exactly the space pad" - ); - assert_eq!(four.max_frames, Some(SHORT_PROMPT_MAX_FRAMES)); - - let five = prepare_pocket_prompt("one two three four five").expect("non-empty"); - assert!( - !five.text.starts_with(' '), - "five-word input should NOT be padded" - ); - assert_eq!( - five.max_frames, None, - "long inputs must leave sherpa-onnx's max_frames default in place" - ); - } - - #[test] - fn prepare_prompt_does_not_pad_long_text() { - let long = "This is a longer sentence that the model should handle just fine."; - let out = prepare_pocket_prompt(long).expect("non-empty"); - assert!(!out.text.starts_with(' ')); - assert_eq!(out.max_frames, None); - assert!(out.text.ends_with('.')); + fn desktop_model_is_april_int8_only() { + let info = april_model_info(); + assert_eq!(info.max_token_per_chunk, 50); + assert_eq!(info.sample_rate, SAMPLE_RATE); + assert!(info + .artifacts + .iter() + .any(|artifact| artifact.filename == "flow_lm_main_int8.onnx")); + assert!(!info + .artifacts + .iter() + .any(|artifact| artifact.filename == "flow_lm_main.onnx")); } #[test] - fn prepare_prompt_collapses_whitespace() { - let out = prepare_pocket_prompt("Hello world\n\nfriend").expect("non-empty"); - // 3 words → short → padded. Interior whitespace collapsed. - assert_eq!(out.text, format!("{}Hello world friend.", short_prefix())); - } - - #[test] - fn prepare_prompt_does_not_double_capitalize_already_uppercase() { - let out = prepare_pocket_prompt("HELLO there").expect("non-empty"); - assert_eq!(out.text, format!("{}HELLO there.", short_prefix())); - } - - #[test] - fn prepare_prompt_handles_non_ascii_first_letter() { - // Cyrillic lowercase 'д' → uppercase 'Д'. Must not panic / produce - // mojibake. - let out = prepare_pocket_prompt("дa").expect("non-empty"); - assert!(out.text.contains("Дa.")); - } - - /// REGRESSION GUARD: short prompts must receive *only* whitespace - /// padding — no sacrificial text. A previous revision prepended a - /// `". . "` cold-start absorber and trimmed the rendered audio back out - /// with an amplitude threshold that could eat soft word onsets. If - /// non-whitespace ever reappears in the pad, the synth output will - /// contain audio for text the user never wrote. - #[test] - fn prepare_prompt_pad_is_whitespace_only() { - let out = prepare_pocket_prompt("I'm happy.").expect("non-empty"); - let pad_len = out.text.len() - "I'm happy.".len(); - assert!( - out.text[..pad_len].chars().all(|c| c == ' '), - "short-prompt pad must be spaces only, got {:?}", - &out.text[..pad_len] - ); - assert_eq!(out.text, format!("{}I'm happy.", short_prefix())); - } - - // ── build_generation_extra ─────────────────────────────────────────────── - // - // These tests pin down a behaviour we've now regressed twice on: - // 1) Not padding/punctuating short inputs → 40 s of "monster breathing" - // (pre-773a2a1). - // 2) Setting `frames_after_eos = 1` on long inputs → clipped leading - // audio of multi-clause sentences, e.g. "Yep, I can hear you. …" - // came out as a static burst (the 773a2a1 regression Tyler hit on - // 2026-05-18 ~14:30 UTC). - // - // The contract we enforce going forward: we **only** override - // `max_frames`, and only for ≤4-word inputs. Every other knob is left - // at sherpa-onnx's documented default (notably `frames_after_eos = 3`). - - #[test] - fn build_extra_short_prompt_sets_only_max_frames() { - let prepared = prepare_pocket_prompt("yep").expect("non-empty"); - let extra = build_generation_extra(&prepared).expect("short prompts get extra"); - // Exactly one key — `max_frames` — and nothing else. - assert_eq!(extra.len(), 1, "extra has unexpected keys: {extra:?}"); - assert_eq!( - extra.get("max_frames"), - Some(&serde_json::Value::from(SHORT_PROMPT_MAX_FRAMES)) - ); - assert!( - !extra.contains_key("frames_after_eos"), - "frames_after_eos must never be set — upstream default of {SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT} is what we want" - ); - } - - #[test] - fn build_extra_long_prompt_is_none() { - // ≥5 words: no extras at all. This is the key fix for the "first - // 'yep' in 'Yep, I can hear you. …' is static" regression — we - // were previously forcing `frames_after_eos = 1` on this path. - let prepared = prepare_pocket_prompt("Yep, I can hear you.").expect("non-empty"); - assert_eq!( - build_generation_extra(&prepared), - None, - "long prompts must not override any LM knob" - ); - } - - #[test] - fn build_extra_never_lowers_frames_after_eos_for_any_word_count() { - // Sweep a range of prompt lengths and assert the `extra` map (when - // present) never carries a `frames_after_eos` override that's lower - // than the upstream sherpa-onnx default. Implemented as a structural - // check — we just never set the key — but worth a property test in - // case someone reintroduces the override in the future. - let prompts: &[&str] = &[ - "hi", - "hi there", - "yes please", - "one two three four", - "one two three four five", - "a slightly longer reply, hopefully fine", - "This is a multi-clause sentence. It has two parts.", - "really really really really really long prompt with lots of words just to be sure", - ]; - for &p in prompts { - let prepared = prepare_pocket_prompt(p).expect("non-empty"); - if let Some(extra) = build_generation_extra(&prepared) { - if let Some(v) = extra.get("frames_after_eos") { - let n = v.as_i64().expect("frames_after_eos should be int"); - assert!( - n >= SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT as i64, - "prompt {p:?} set frames_after_eos={n}, below upstream default of {SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT}" - ); - } - } - } - } - - #[test] - fn short_prompt_max_frames_is_below_upstream_default() { - // Sanity: the override only ever *lowers* the cap, never raises it. - const { - assert!(SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT); - } - // …and is still large enough for a one-to-four-word reply. At Mimi's - // 12.5 Hz frame rate, 100 frames = 8 s, which is roomy. - const { - assert!(SHORT_PROMPT_MAX_FRAMES >= 50, "would risk truncation"); - } + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn production_api_emits_non_silent_april_int8_pcm() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory"); + let engine = load_text_to_speech(&dir).expect("load April INT8 engine"); + let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav")) + .expect("load reference voice"); + let samples = engine + .synth_chunk("Bright birds begin beside the bay.", "en", &style, 1) + .expect("synthesize through the production API"); + + assert!(!samples.is_empty()); + assert!(samples.iter().all(|sample| sample.is_finite())); + assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6)); } } diff --git a/desktop/src-tauri/src/huddle/pocket_april.rs b/desktop/src-tauri/src/huddle/pocket_april.rs new file mode 100644 index 0000000000..43826df5c9 --- /dev/null +++ b/desktop/src-tauri/src/huddle/pocket_april.rs @@ -0,0 +1,940 @@ +//! Native ONNX loader for Pocket TTS `english_2026-04`. +//! +//! The bundle uses SentencePiece, prepends a learned BOS voice embedding, and +//! describes recurrent state tensors in `bundle.json`. This module supplies +//! that frontend and state loop while reusing the ONNX Runtime linked by the +//! Desktop speech stack. + +use std::borrow::Cow; +use std::f32::consts::TAU; +use std::fs; +use std::path::{Path, PathBuf}; + +use ort::session::{Session, SessionInputValue}; +use ort::value::{DynValue, Tensor}; +use rand::{Rng, RngExt}; +use sentencepiece_model::SentencePieceModel; +use serde::Deserialize; +use sherpa_onnx::LinearResampler; +use tokenizers::models::unigram::Unigram; +use tokenizers::pre_tokenizers::metaspace::{Metaspace, PrependScheme}; +use tokenizers::Tokenizer; + +use super::VoiceStyle; + +const FILE_BUNDLE: &str = "bundle.json"; +const FILE_MIMI_ENCODER: &str = "mimi_encoder.onnx"; +const FILE_TEXT_CONDITIONER: &str = "text_conditioner.onnx"; +const FILE_FLOW_MAIN_INT8: &str = "flow_lm_main_int8.onnx"; +const FILE_FLOW_INT8: &str = "flow_lm_flow_int8.onnx"; +const FILE_MIMI_DECODER_INT8: &str = "mimi_decoder_int8.onnx"; + +const MODEL_LANGUAGE: &str = "english_2026-04"; +const DEFAULT_TEMPERATURE: f32 = 0.7; +const EOS_LOGIT_THRESHOLD: f32 = -4.0; +const DECODER_CHUNK_FRAMES: usize = 12; +const TOKENS_PER_SECOND_ESTIMATE: f32 = 3.0; +const GENERATION_SECONDS_PADDING: f32 = 2.0; + +#[derive(Debug, Deserialize)] +struct Bundle { + schema_version: u32, + language: String, + sample_rate: usize, + frame_rate: f32, + samples_per_frame: usize, + latent_dim: usize, + conditioning_dim: usize, + insert_bos_before_voice: bool, + pad_with_spaces_for_short_inputs: bool, + remove_semicolons: bool, + model_recommended_frames_after_eos: Option, + max_token_per_chunk: usize, + tokenizer_file: String, + bos_before_voice_file: String, + flow_lm_state_manifest: Vec, + mimi_state_manifest: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct StateSpec { + input_name: String, + output_name: String, + dtype: StateDtype, + shape: Vec, + fill: StateFill, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum StateDtype { + #[serde(rename = "float32")] + Float32, + #[serde(rename = "int64")] + Int64, + Bool, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum StateFill { + Empty, + Nan, + Ones, + Zeros, +} + +struct StateValue { + spec: StateSpec, + value: DynValue, +} + +struct CachedVoice { + samples_ptr: usize, + samples_len: usize, + sample_rate: i32, + embeddings: Vec, +} + +pub(crate) struct AprilPocketTts { + bundle: Bundle, + tokenizer: Tokenizer, + bos_embedding: Vec, + mimi_encoder: Session, + text_conditioner: Session, + flow_main: Session, + flow: Session, + mimi_decoder: Session, + cached_voice: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AprilPreparedPrompt { + pub(crate) text: String, + pub(crate) frames_after_eos: usize, +} + +pub(crate) fn prepare_april_prompt(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + let mut cleaned = String::with_capacity(trimmed.len()); + let mut last_was_space = false; + for ch in trimmed.chars() { + if ch.is_whitespace() { + if !last_was_space { + cleaned.push(' '); + } + last_was_space = true; + } else { + cleaned.push(ch); + last_was_space = false; + } + } + + let first = cleaned.chars().next().expect("cleaned non-empty above"); + if first.is_lowercase() { + let upper: String = first.to_uppercase().collect(); + let mut iter = cleaned.chars(); + iter.next(); + cleaned = upper + iter.as_str(); + } + + let last = cleaned + .chars() + .next_back() + .expect("cleaned non-empty above"); + if last.is_alphanumeric() { + cleaned.push('.'); + } + + let word_count = cleaned.split_whitespace().count(); + Some(AprilPreparedPrompt { + text: cleaned, + // Mirror the bundle's upstream heuristic: three generated frames plus + // two trailing frames for short prompts, one plus two otherwise. + frames_after_eos: if word_count <= 4 { 5 } else { 3 }, + }) +} + +impl AprilPocketTts { + pub(crate) fn load(dir: &Path, num_threads: usize) -> Result { + if num_threads == 0 { + return Err("Pocket TTS num_threads must be at least 1".to_string()); + } + let bundle_path = dir.join(FILE_BUNDLE); + let bundle: Bundle = serde_json::from_slice( + &fs::read(&bundle_path) + .map_err(|err| format!("read {}: {err}", bundle_path.display()))?, + ) + .map_err(|err| format!("parse {}: {err}", bundle_path.display()))?; + + if bundle.schema_version != 2 { + return Err(format!( + "unsupported Pocket TTS bundle schema {} in {}", + bundle.schema_version, + bundle_path.display() + )); + } + if bundle.language != MODEL_LANGUAGE { + return Err(format!( + "expected Pocket TTS language {MODEL_LANGUAGE}, got {}", + bundle.language + )); + } + if bundle.sample_rate != 24_000 + || bundle.frame_rate != 12.5 + || bundle.samples_per_frame != 1_920 + || bundle.latent_dim != 32 + || bundle.conditioning_dim != 1024 + { + return Err(format!( + "unexpected Pocket TTS dimensions: sample_rate={}, frame_rate={}, samples_per_frame={}, latent_dim={}, conditioning_dim={}", + bundle.sample_rate, + bundle.frame_rate, + bundle.samples_per_frame, + bundle.latent_dim, + bundle.conditioning_dim + )); + } + if !bundle.insert_bos_before_voice { + return Err("April Pocket TTS bundle must insert BOS before voice".to_string()); + } + if bundle.pad_with_spaces_for_short_inputs + || bundle.remove_semicolons + || bundle.model_recommended_frames_after_eos.is_some() + || bundle.max_token_per_chunk != 50 + { + return Err("unsupported April Pocket TTS prompt-policy metadata".to_string()); + } + + let tokenizer_path = dir.join(&bundle.tokenizer_file); + let tokenizer = load_tokenizer(&tokenizer_path)?; + let bos_path = dir.join(&bundle.bos_before_voice_file); + let bos_embedding = read_npy_f32(&bos_path)?; + if bos_embedding.len() != bundle.conditioning_dim { + return Err(format!( + "{} has {} values; expected {}", + bos_path.display(), + bos_embedding.len(), + bundle.conditioning_dim + )); + } + + let flow_main = FILE_FLOW_MAIN_INT8; + let flow = FILE_FLOW_INT8; + let mimi_decoder = FILE_MIMI_DECODER_INT8; + + Ok(Self { + // The INT8 layout quantizes only the three generation graphs; + // voice encoding and text conditioning remain full precision. + mimi_encoder: load_session(dir.join(FILE_MIMI_ENCODER), num_threads)?, + text_conditioner: load_session(dir.join(FILE_TEXT_CONDITIONER), num_threads)?, + flow_main: load_session(dir.join(flow_main), num_threads)?, + flow: load_session(dir.join(flow), num_threads)?, + mimi_decoder: load_session(dir.join(mimi_decoder), num_threads)?, + bundle, + tokenizer, + bos_embedding, + cached_voice: None, + }) + } + + pub(crate) fn split_prompt( + &self, + prepared: &AprilPreparedPrompt, + ) -> Result, String> { + if self.token_count(&prepared.text)? <= self.bundle.max_token_per_chunk { + return Ok(vec![prepared.text.clone()]); + } + + let mut chunks = Vec::new(); + let mut current = String::new(); + for word in prepared.text.split_whitespace() { + let candidate = if current.is_empty() { + word.to_string() + } else { + format!("{current} {word}") + }; + if self.prepared_token_count(&candidate)? <= self.bundle.max_token_per_chunk { + current = candidate; + continue; + } + if !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + } + + if self.prepared_token_count(word)? <= self.bundle.max_token_per_chunk { + current = word.to_string(); + continue; + } + + let mut fragment = String::new(); + for ch in word.chars() { + let candidate = format!("{fragment}{ch}"); + if !fragment.is_empty() + && self.prepared_token_count(&candidate)? > self.bundle.max_token_per_chunk + { + chunks.push(std::mem::take(&mut fragment)); + } + fragment.push(ch); + } + current = fragment; + } + if !current.is_empty() { + chunks.push(current); + } + + chunks + .into_iter() + .map(|text| { + let chunk = prepare_april_prompt(&text) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + let token_count = self.token_count(&chunk.text)?; + if token_count > self.bundle.max_token_per_chunk { + return Err(format!( + "Pocket TTS prompt chunk has {token_count} tokens; maximum is {}", + self.bundle.max_token_per_chunk + )); + } + Ok(chunk.text) + }) + .collect() + } + + pub(crate) fn synth_chunk( + &mut self, + prepared: &AprilPreparedPrompt, + style: &VoiceStyle, + ) -> Result, String> { + let voice_embeddings = self.voice_embeddings(style)?; + let mut flow_state = self.condition_voice(&voice_embeddings)?; + let token_ids = self + .tokenizer + .encode(prepared.text.as_str(), false) + .map_err(|err| format!("tokenize Pocket TTS prompt: {err}"))? + .get_ids() + .iter() + .copied() + .map(i64::from) + .collect::>(); + if token_ids.is_empty() { + return Ok(Vec::new()); + } + if token_ids.len() > self.bundle.max_token_per_chunk { + return Err(format!( + "Pocket TTS prompt has {} tokens; split_text_into_chunks maximum is {}", + token_ids.len(), + self.bundle.max_token_per_chunk + )); + } + + let token_count = token_ids.len(); + let text_embeddings = self.text_embeddings(token_ids)?; + self.run_flow_main_prefix(&text_embeddings, &mut flow_state)?; + let max_frames = estimate_max_frames(token_count, self.bundle.frame_rate); + let latents = + self.generate_latents(max_frames, prepared.frames_after_eos, &mut flow_state)?; + self.decode_latents(&latents) + } + + fn prepared_token_count(&self, text: &str) -> Result { + let prepared = prepare_april_prompt(text) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + self.token_count(&prepared.text) + } + + fn token_count(&self, text: &str) -> Result { + Ok(self + .tokenizer + .encode(text, false) + .map_err(|err| format!("tokenize Pocket TTS prompt: {err}"))? + .get_ids() + .len()) + } + + fn voice_embeddings(&mut self, style: &VoiceStyle) -> Result, String> { + let key = ( + style.samples.as_ptr() as usize, + style.samples.len(), + style.sample_rate, + ); + if let Some(cached) = &self.cached_voice { + if (cached.samples_ptr, cached.samples_len, cached.sample_rate) == key { + return Ok(cached.embeddings.clone()); + } + } + + let samples = if style.sample_rate == self.bundle.sample_rate as i32 { + style.samples.clone() + } else { + LinearResampler::create(style.sample_rate, self.bundle.sample_rate as i32) + .ok_or_else(|| { + format!( + "create Pocket TTS resampler {}Hz -> {}Hz", + style.sample_rate, self.bundle.sample_rate + ) + })? + .resample(&style.samples, true) + }; + let audio = Tensor::from_array(( + vec![1_i64, 1, samples.len() as i64], + samples.into_boxed_slice(), + )) + .map_err(ort_error("create voice audio tensor"))?; + let outputs = self + .mimi_encoder + .run(ort::inputs!["audio" => audio]) + .map_err(ort_error("run Mimi encoder"))?; + let (_, encoded) = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Mimi encoder output"))?; + if !encoded.len().is_multiple_of(self.bundle.conditioning_dim) { + return Err(format!( + "Mimi encoder returned {} values, not divisible by {}", + encoded.len(), + self.bundle.conditioning_dim + )); + } + let mut embeddings = + Vec::with_capacity(self.bos_embedding.len().saturating_add(encoded.len())); + embeddings.extend_from_slice(&self.bos_embedding); + embeddings.extend_from_slice(encoded); + self.cached_voice = Some(CachedVoice { + samples_ptr: key.0, + samples_len: key.1, + sample_rate: key.2, + embeddings: embeddings.clone(), + }); + Ok(embeddings) + } + + fn condition_voice(&mut self, embeddings: &[f32]) -> Result, String> { + let frames = embeddings.len() / self.bundle.conditioning_dim; + let sequence = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.latent_dim as i64], + ) + .map_err(ort_error("create empty voice sequence"))?; + let text_embeddings = Tensor::from_array(( + vec![1_i64, frames as i64, self.bundle.conditioning_dim as i64], + embeddings.to_vec().into_boxed_slice(), + )) + .map_err(ort_error("create voice embedding tensor"))?; + let mut state = initialize_state(&self.bundle.flow_lm_state_manifest)?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, &state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("condition Pocket TTS voice"))?; + replace_state_from_outputs(&mut state, &mut outputs)?; + Ok(state) + } + + fn text_embeddings(&mut self, token_ids: Vec) -> Result, String> { + let tokens = Tensor::from_array(( + vec![1_i64, token_ids.len() as i64], + token_ids.into_boxed_slice(), + )) + .map_err(ort_error("create token tensor"))?; + let outputs = self + .text_conditioner + .run(ort::inputs!["token_ids" => tokens]) + .map_err(ort_error("run text conditioner"))?; + let (_, embeddings) = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract text embeddings"))?; + Ok(embeddings.to_vec()) + } + + fn run_flow_main_prefix( + &mut self, + text_embeddings: &[f32], + state: &mut [StateValue], + ) -> Result<(), String> { + if !text_embeddings + .len() + .is_multiple_of(self.bundle.conditioning_dim) + { + return Err(format!( + "text conditioner returned {} values, not divisible by {}", + text_embeddings.len(), + self.bundle.conditioning_dim + )); + } + let frames = text_embeddings.len() / self.bundle.conditioning_dim; + let sequence = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.latent_dim as i64], + ) + .map_err(ort_error("create empty text sequence"))?; + let text_embeddings = Tensor::from_array(( + vec![1_i64, frames as i64, self.bundle.conditioning_dim as i64], + text_embeddings.to_vec().into_boxed_slice(), + )) + .map_err(ort_error("create text embedding tensor"))?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("prime Pocket TTS text state"))?; + replace_state_from_outputs(state, &mut outputs) + } + + fn generate_latents( + &mut self, + max_frames: usize, + frames_after_eos: usize, + state: &mut [StateValue], + ) -> Result, String> { + let mut current = vec![f32::NAN; self.bundle.latent_dim]; + let mut latents = Vec::with_capacity(max_frames * self.bundle.latent_dim); + let mut eos_step = None; + let mut rng = rand::rng(); + + for step in 0..max_frames { + let sequence = Tensor::from_array(( + vec![1_i64, 1, self.bundle.latent_dim as i64], + current.clone().into_boxed_slice(), + )) + .map_err(ort_error("create latent input"))?; + let text_embeddings = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.conditioning_dim as i64], + ) + .map_err(ort_error("create empty text input"))?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("run Pocket TTS Flow LM"))?; + let conditioning = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Flow LM conditioning"))? + .1 + .to_vec(); + let eos_logit = outputs[1] + .try_extract_tensor::() + .map_err(ort_error("extract Flow LM EOS logit"))? + .1 + .first() + .copied() + .ok_or_else(|| "Flow LM returned empty EOS logit".to_string())?; + replace_state_from_outputs(state, &mut outputs)?; + + if eos_logit > EOS_LOGIT_THRESHOLD && eos_step.is_none() { + eos_step = Some(step); + } + if eos_step.is_some_and(|eos| step >= eos + frames_after_eos) { + break; + } + + let mut noise = + normal_noise(&mut rng, self.bundle.latent_dim, DEFAULT_TEMPERATURE.sqrt()); + let conditioning = Tensor::from_array(( + vec![1_i64, self.bundle.conditioning_dim as i64], + conditioning.into_boxed_slice(), + )) + .map_err(ort_error("create flow conditioning"))?; + let s = Tensor::from_array((vec![1_i64, 1], vec![0.0_f32].into_boxed_slice())) + .map_err(ort_error("create flow start tensor"))?; + let t = Tensor::from_array((vec![1_i64, 1], vec![1.0_f32].into_boxed_slice())) + .map_err(ort_error("create flow end tensor"))?; + let x = Tensor::from_array(( + vec![1_i64, self.bundle.latent_dim as i64], + noise.clone().into_boxed_slice(), + )) + .map_err(ort_error("create flow noise tensor"))?; + let outputs = self + .flow + .run(ort::inputs![ + "c" => conditioning, + "s" => s, + "t" => t, + "x" => x, + ]) + .map_err(ort_error("run Pocket TTS flow"))?; + let flow = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Pocket TTS flow"))? + .1; + if flow.len() != noise.len() { + return Err(format!( + "flow returned {} values; expected {}", + flow.len(), + noise.len() + )); + } + for (sample, delta) in noise.iter_mut().zip(flow) { + *sample += *delta; + } + current.clone_from(&noise); + latents.extend_from_slice(&noise); + } + Ok(latents) + } + + fn decode_latents(&mut self, latents: &[f32]) -> Result, String> { + if latents.is_empty() { + return Ok(Vec::new()); + } + if !latents.len().is_multiple_of(self.bundle.latent_dim) { + return Err(format!( + "latent buffer has {} values, not divisible by {}", + latents.len(), + self.bundle.latent_dim + )); + } + let frame_count = latents.len() / self.bundle.latent_dim; + let mut state = initialize_state(&self.bundle.mimi_state_manifest)?; + let mut audio = Vec::new(); + + for start in (0..frame_count).step_by(DECODER_CHUNK_FRAMES) { + let end = (start + DECODER_CHUNK_FRAMES).min(frame_count); + let values = + latents[start * self.bundle.latent_dim..end * self.bundle.latent_dim].to_vec(); + let latent = Tensor::from_array(( + vec![1_i64, (end - start) as i64, self.bundle.latent_dim as i64], + values.into_boxed_slice(), + )) + .map_err(ort_error("create Mimi latent tensor"))?; + let mut inputs = vec![(Cow::Borrowed("latent"), SessionInputValue::from(latent))]; + append_state_inputs(&mut inputs, &state); + let mut outputs = self + .mimi_decoder + .run(inputs) + .map_err(ort_error("run Mimi decoder"))?; + let samples = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Mimi audio"))? + .1; + audio.extend_from_slice(samples); + replace_state_from_outputs(&mut state, &mut outputs)?; + } + Ok(audio) + } +} + +fn load_session(path: PathBuf, num_threads: usize) -> Result { + if !path.is_file() { + return Err(format!("missing Pocket TTS file: {}", path.display())); + } + Session::builder() + .map_err(ort_error("create ONNX session builder"))? + .with_intra_threads(num_threads) + .map_err(|err| format!("configure ONNX intra-op threads: {err}"))? + .with_inter_threads(1) + .map_err(|err| format!("configure ONNX inter-op threads: {err}"))? + .commit_from_file(&path) + .map_err(|err| format!("load {}: {err}", path.display())) +} + +fn load_tokenizer(path: &Path) -> Result { + let sentencepiece = SentencePieceModel::from_file(path) + .map_err(|err| format!("load {}: {err}", path.display()))?; + let trainer = sentencepiece + .trainer() + .ok_or_else(|| format!("{} has no SentencePiece trainer metadata", path.display()))?; + let normalizer = sentencepiece.normalizer().ok_or_else(|| { + format!( + "{} has no SentencePiece normalizer metadata", + path.display() + ) + })?; + if normalizer.name() != "identity" { + return Err(format!( + "{} uses unsupported SentencePiece normalizer {:?}", + path.display(), + normalizer.name() + )); + } + + let vocab = sentencepiece + .pieces() + .iter() + .map(|piece| (piece.piece().to_owned(), f64::from(piece.score()))) + .collect(); + let mut tokenizer = Tokenizer::new( + Unigram::from( + vocab, + Some(trainer.unk_id() as usize), + trainer.byte_fallback(), + ) + .map_err(|err| format!("construct tokenizer from {}: {err}", path.display()))?, + ); + // SentencePiece's identity normalizer still escapes spaces as U+2581 and + // prepends one marker to the input before unigram segmentation. + tokenizer.with_pre_tokenizer(Some(Metaspace::new('▁', PrependScheme::Always, false))); + Ok(tokenizer) +} + +fn initialize_state(specs: &[StateSpec]) -> Result, String> { + specs + .iter() + .cloned() + .map(|spec| { + let len = shape_len(&spec.shape)?; + let value = match spec.dtype { + StateDtype::Float32 => { + let fill = match spec.fill { + StateFill::Nan => f32::NAN, + StateFill::Empty | StateFill::Zeros => 0.0, + StateFill::Ones => 1.0, + }; + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty float state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create float state tensor"))? + .into_dyn() + } + } + StateDtype::Int64 => { + let fill = i64::from(matches!(spec.fill, StateFill::Ones)); + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty integer state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create integer state tensor"))? + .into_dyn() + } + } + StateDtype::Bool => { + let fill = matches!(spec.fill, StateFill::Ones); + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty bool state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create bool state tensor"))? + .into_dyn() + } + } + }; + Ok(StateValue { spec, value }) + }) + .collect() +} + +fn append_state_inputs<'a>( + inputs: &mut Vec<(Cow<'a, str>, SessionInputValue<'a>)>, + state: &'a [StateValue], +) { + for value in state { + inputs.push(( + Cow::Borrowed(value.spec.input_name.as_str()), + SessionInputValue::from(&value.value), + )); + } +} + +fn replace_state_from_outputs( + state: &mut [StateValue], + outputs: &mut ort::session::SessionOutputs<'_>, +) -> Result<(), String> { + for value in state { + value.value = outputs + .remove(&value.spec.output_name) + .ok_or_else(|| format!("missing state output {}", value.spec.output_name))?; + } + Ok(()) +} + +fn shape_len(shape: &[i64]) -> Result { + shape.iter().try_fold(1_usize, |len, &dim| { + let dim = usize::try_from(dim).map_err(|_| format!("negative state dimension {dim}"))?; + len.checked_mul(dim) + .ok_or_else(|| format!("state shape overflows usize: {shape:?}")) + }) +} + +fn estimate_max_frames(token_count: usize, frame_rate: f32) -> usize { + ((token_count as f32 / TOKENS_PER_SECOND_ESTIMATE + GENERATION_SECONDS_PADDING) * frame_rate) + .ceil() as usize +} + +fn normal_noise(rng: &mut impl Rng, len: usize, std_dev: f32) -> Vec { + let mut out = Vec::with_capacity(len); + while out.len() < len { + let u1 = rng.random::().max(f32::MIN_POSITIVE); + let u2 = rng.random::(); + let radius = (-2.0_f32 * u1.ln()).sqrt() * std_dev; + out.push(radius * (TAU * u2).cos()); + if out.len() < len { + out.push(radius * (TAU * u2).sin()); + } + } + out +} + +fn read_npy_f32(path: &Path) -> Result, String> { + let bytes = fs::read(path).map_err(|err| format!("read {}: {err}", path.display()))?; + if bytes.len() < 10 || &bytes[..6] != b"\x93NUMPY" { + return Err(format!("{} is not a NumPy array", path.display())); + } + let major = bytes[6]; + let header_len_bytes = match major { + 1 => 2, + 2 | 3 => 4, + _ => { + return Err(format!( + "unsupported NumPy version {major} in {}", + path.display() + )) + } + }; + let header_start = 8 + header_len_bytes; + if bytes.len() < header_start { + return Err(format!("truncated NumPy header in {}", path.display())); + } + let header_len = if header_len_bytes == 2 { + u16::from_le_bytes([bytes[8], bytes[9]]) as usize + } else { + u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize + }; + let data_start = header_start + .checked_add(header_len) + .ok_or_else(|| format!("NumPy header overflow in {}", path.display()))?; + if data_start > bytes.len() { + return Err(format!("truncated NumPy data in {}", path.display())); + } + let header = std::str::from_utf8(&bytes[header_start..data_start]) + .map_err(|err| format!("invalid NumPy header in {}: {err}", path.display()))?; + if !(header.contains("'descr': ' impl FnOnce(ort::Error) -> String { + move |err| format!("{context}: {err}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shape_len_supports_empty_state_dimensions() { + assert_eq!(shape_len(&[1, 128, 0]).expect("shape"), 0); + assert_eq!(shape_len(&[2, 1, 8, 1000, 64]).expect("shape"), 1_024_000); + } + + #[test] + fn normal_noise_has_requested_length() { + let mut rng = rand::rng(); + assert_eq!(normal_noise(&mut rng, 1, 1.0).len(), 1); + assert_eq!(normal_noise(&mut rng, 32, 1.0).len(), 32); + } + + #[test] + fn generation_frame_estimate_scales_with_token_count() { + assert_eq!(estimate_max_frames(3, 12.5), 38); + assert_eq!(estimate_max_frames(300, 12.5), 1_275); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn tokenizer_matches_sentencepiece_reference_including_unknown_words() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let tokenizer = + load_tokenizer(&Path::new(&dir).join("tokenizer.model")).expect("load April tokenizer"); + let cases: &[(&str, &[u32])] = &[ + ("Yep.", &[2462, 263]), + ("Hello there.", &[2994, 310, 263]), + ( + "quizzaciously xyzzy.", + &[ + 260, 1157, 1818, 362, 1814, 323, 260, 568, 327, 1818, 327, 263, + ], + ), + ("I'm listening.", &[268, 264, 283, 260, 604, 273, 263]), + ]; + for (text, expected) in cases { + let encoding = tokenizer.encode(*text, false).expect("tokenize"); + assert_eq!(encoding.get_ids(), *expected, "{text}"); + } + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn loader_splits_oversized_prompts_at_bundle_token_limit() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let text = "This deliberately long sentence repeats ordinary English words so the exact SentencePiece token limit is exercised without relying on punctuation, and it keeps adding more material until the prompt must be divided into multiple independently safe generation chunks before the recurrent state cache can be exhausted."; + let prepared = prepare_april_prompt(text).expect("prepare prompt"); + let chunks = engine.split_prompt(&prepared).expect("split prompt"); + + assert!(chunks.len() > 1); + assert!(chunks.iter().all(|chunk| { + engine.token_count(chunk).expect("tokenize chunk") <= engine.bundle.max_token_per_chunk + })); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn gary_provost_long_sentence_respects_bundle_token_limit() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let text = "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important."; + let prepared = prepare_april_prompt(text).expect("prepare prompt"); + let chunks = engine.split_prompt(&prepared).expect("split long sentence"); + let token_counts: Vec<_> = chunks + .iter() + .map(|chunk| engine.token_count(chunk).expect("count tokens")) + .collect(); + + assert_eq!( + chunks, + [ + "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the.", + "Impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important.", + ] + ); + assert_eq!(token_counts, [48, 44]); + } +} diff --git a/desktop/src-tauri/src/huddle/pocket_models.rs b/desktop/src-tauri/src/huddle/pocket_models.rs new file mode 100644 index 0000000000..de34c77a70 --- /dev/null +++ b/desktop/src-tauri/src/huddle/pocket_models.rs @@ -0,0 +1,130 @@ +//! Immutable capabilities for Buzz Desktop's April Pocket TTS bundle. + +/// Pinned upstream export repository. +pub const APRIL_MODEL_ID: &str = "KevinAHM/pocket-tts-onnx"; + +/// Pinned revision containing the `english_2026-04` bundle. +pub const APRIL_MODEL_REVISION: &str = "58a6d00cf13d239b6748cb0769f35c580a8f606c"; + +/// Language bundle selected from the pinned export. +pub const APRIL_BUNDLE_ID: &str = "english_2026-04"; + +/// Maximum input size declared by the April bundle. +pub const APRIL_MAX_TOKEN_PER_CHUNK: usize = 50; + +/// One immutable artifact required by the April INT8 runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PocketModelArtifact { + pub filename: &'static str, + pub sha256: &'static str, + pub size_bytes: u64, + pub quantized: bool, +} + +/// Capabilities of Buzz Desktop's sole Pocket model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PocketModelInfo { + pub bundle_id: &'static str, + pub source_model_id: &'static str, + pub revision: &'static str, + pub sample_rate: u32, + pub max_token_per_chunk: usize, + pub artifacts: &'static [PocketModelArtifact], + pub quantized_components: &'static [&'static str], +} + +const INT8_ARTIFACTS: [PocketModelArtifact; 8] = [ + PocketModelArtifact { + filename: "bundle.json", + sha256: "bab643150f437f37df080a710520ff39ed9ebd9a339f8ebdc739f7eddfc28b3f", + size_bytes: 24_381, + quantized: false, + }, + PocketModelArtifact { + filename: "bos_before_voice.npy", + sha256: "f46edf4f7007b7ba4ea58831f49d003e59e167b4641c44bb3addfe9231a780b1", + size_bytes: 4_224, + quantized: false, + }, + PocketModelArtifact { + filename: "tokenizer.model", + sha256: "d461765ae179566678c93091c5fa6f2984c31bbe990bf1aa62d92c64d91bc3f6", + size_bytes: 59_339, + quantized: false, + }, + PocketModelArtifact { + filename: "flow_lm_main_int8.onnx", + sha256: "f9bd8106b79a0192c1c43399ab938fb24900a95c1c599870d75a884e99000116", + size_bytes: 76_341_079, + quantized: true, + }, + PocketModelArtifact { + filename: "flow_lm_flow_int8.onnx", + sha256: "3dd781ee5abee9e195320bf0106bebd6372a852b3b36352524ee78b40554635d", + size_bytes: 9_962_530, + quantized: true, + }, + PocketModelArtifact { + filename: "mimi_decoder_int8.onnx", + sha256: "3630450a3297a101792a6ac66619ebc70ab916b265e6220c2afaef8b1673f925", + size_bytes: 22_684_077, + quantized: true, + }, + PocketModelArtifact { + filename: "mimi_encoder.onnx", + sha256: "853e2ca623b8782d94c3745ec6133bfdff7ce33d9b11128bd29ea03f28d76e3d", + size_bytes: 39_768_446, + quantized: false, + }, + PocketModelArtifact { + filename: "text_conditioner.onnx", + sha256: "4ecee995fb69f85c7a7493d11f7b5ee15d9950facc7ab3f5c9c49ef1e03847bb", + size_bytes: 16_388_344, + quantized: false, + }, +]; + +const INT8_COMPONENTS: [&str; 3] = ["flow_lm_main", "flow_lm_flow", "mimi_decoder"]; + +/// Return immutable metadata for Buzz Desktop's April INT8 model. +pub const fn april_model_info() -> PocketModelInfo { + PocketModelInfo { + bundle_id: APRIL_BUNDLE_ID, + source_model_id: APRIL_MODEL_ID, + revision: APRIL_MODEL_REVISION, + sample_rate: 24_000, + max_token_per_chunk: APRIL_MAX_TOKEN_PER_CHUNK, + artifacts: &INT8_ARTIFACTS, + quantized_components: &INT8_COMPONENTS, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_matches_pinned_int8_layout() { + let info = april_model_info(); + assert_eq!(info.artifacts.len(), 8); + assert_eq!( + info.quantized_components, + ["flow_lm_main", "flow_lm_flow", "mimi_decoder"] + ); + assert_eq!( + info.artifacts + .iter() + .map(|artifact| artifact.size_bytes) + .sum::(), + 165_232_420 + ); + assert!(info + .artifacts + .iter() + .any(|artifact| { artifact.filename == "mimi_encoder.onnx" && !artifact.quantized })); + assert!(!info + .artifacts + .iter() + .any(|artifact| artifact.filename == "mimi_encoder_int8.onnx")); + } +} diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 63a435cd8e..f084cca6d5 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -73,9 +73,8 @@ const SYNTH_STEPS: usize = 1; /// /// Applied only at the *end* of each synthesised sentence to eliminate the /// click that would otherwise occur when a non-zero waveform terminates -/// abruptly. **No fade-in is applied** — see `apply_fade_out` for the -/// rationale and `examples/pocket_onset_probe.rs` for the measurement that -/// motivated removing the leading fade. +/// abruptly. **No fade-in is applied** — see `apply_fade_out` for why preserving +/// the leading waveform is important. const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize; /// Length of the zero-sample cushion prepended before each synthesized @@ -101,13 +100,9 @@ const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; /// names chunk stitching as the reliability lever). Our previous /// sentence-per-call path created ~2–4× more seams than upstream. /// -/// We don't ship the SentencePiece tokenizer, so 50 tokens is approximated -/// with a character budget. The bundled 4k-entry vocab averages ~4 chars per -/// token, but usage-weighted English text leans on short common tokens, so -/// the effective ratio is ~2–4 chars/token and 200 chars ≈ 60–100 tokens — -/// modestly above upstream's 50, deliberately: erring large means fewer -/// seams, and even ~100 tokens is far below the model's 500-LM-step (~40 s) -/// ceiling. Do not shrink this budget to chase an exact 50-token match. +/// This character budget performs only coarse sentence packing. The April +/// engine applies its SentencePiece tokenizer afterward and refines every +/// result at the bundle's exact 50-token boundary. const MAX_CHUNK_CHARS: usize = 200; /// Silence inserted between sentences by the TTS pipeline (seconds). @@ -493,18 +488,17 @@ fn tts_worker( // Split into sentences, then group into synthesis chunks: the first // sentence stays alone (fast time-to-first-audio), the rest pack - // greedily up to MAX_CHUNK_CHARS. Each chunk is one `generate()` - // call; playback of chunk N overlaps synthesis of chunk N+1 - // (lookahead pipelining). Grouping matches upstream's ~50-token - // chunking and halves the exposed prosody seams on multi-sentence - // replies — see MAX_CHUNK_CHARS. + // greedily up to MAX_CHUNK_CHARS. Playback of each model unit overlaps + // synthesis of the next one. The Pocket engine applies its exact + // 50-token split; keeping those units within one playback chunk avoids + // adding fades and pauses at token-only boundaries. let sentences: Vec = split_sentences(&text) .into_iter() .filter(|s| !s.trim().is_empty()) .collect(); let chunks = group_sentences_into_chunks(&sentences, MAX_CHUNK_CHARS); - for chunk in &chunks { + 'playback_chunks: for chunk in &chunks { if handle_cancel_or_shutdown( &cancel, &shutdown, @@ -521,51 +515,76 @@ fn tts_worker( continue; } - match engine.synth_chunk(text, "en", &style, SYNTH_STEPS) { - Ok(samples) if !samples.is_empty() => { - let mut audio = clamp_to_full_scale(samples); - // Fade-out only — fading-in would attenuate the consonant - // onset (see `apply_fade_out` docstring + the - // 2026-05-18 "first little sound is missing" regression). - apply_fade_out(&mut audio); - - // Build one contiguous buffer per synthesized sentence: - // lead-in cushion + audio + trailing gap. Keeping this as - // a single rodio source preserves the original queue/drain - // semantics (one append per sentence) while still giving - // every chunk a quiet device warm-up window. - let buf = - build_sentence_append_buffer(&mut first_append, audio, silence_buf_len); - - // Check-and-append under `player_ops`, serialized with - // the monitor: a barge-in may have arrived during - // synthesis (the blocking window the monitor thread - // exists for). Don't append the now-stale sentence — the - // human interrupted; speaking it anyway would talk over - // them. Holding the lock for the check + append means the - // monitor can never clear between our check passing and - // the buffer landing. The flag is deliberately NOT - // consumed here: the loop-top handle_cancel_or_shutdown - // does the full consume (drain queue, reset lead-in) on - // the next iteration. - let _ops = lock_player_ops(&player_ops); - if cancel.load(Ordering::Acquire) { - // Nothing appended; the loop-top consume re-arms - // `first_append` (the flag is still set — the worker - // is its only consumer). + let model_chunks = match engine.split_text_into_chunks(text) { + Ok(model_chunks) => model_chunks, + Err(error) => { + eprintln!("buzz-desktop: TTS chunking failed: {error}"); + break; + } + }; + let model_chunk_count = model_chunks.len(); + for (model_chunk_index, model_chunk) in model_chunks.iter().enumerate() { + if handle_cancel_or_shutdown( + &cancel, + &shutdown, + &tts_active, + &text_rx, + Some((&player, &player_ops)), + ) { + first_append = true; + break 'playback_chunks; + } + + let ends_playback_chunk = model_chunk_index + 1 == model_chunk_count; + match engine.synth_chunk(model_chunk, "en", &style, SYNTH_STEPS) { + Ok(samples) if !samples.is_empty() => { + let mut audio = clamp_to_full_scale(samples); + if ends_playback_chunk { + // Fade only at the playback-chunk boundary. Applying + // it at the model's internal token boundary would + // create an audible dip between contiguous units. + apply_fade_out(&mut audio); + } + + let buf = build_sentence_append_buffer( + &mut first_append, + audio, + silence_buf_len, + model_chunk_index == 0 || player.empty(), + ends_playback_chunk, + ); + + // Check-and-append under `player_ops`, serialized with + // the monitor: a barge-in may have arrived during + // synthesis (the blocking window the monitor thread + // exists for). Don't append the now-stale sentence — the + // human interrupted; speaking it anyway would talk over + // them. Holding the lock for the check + append means the + // monitor can never clear between our check passing and + // the buffer landing. The flag is deliberately NOT + // consumed here: the loop-top handle_cancel_or_shutdown + // does the full consume (drain queue, reset lead-in) on + // the next iteration. + let _ops = lock_player_ops(&player_ops); + if cancel.load(Ordering::Acquire) { + // Nothing appended; the loop-top consume re-arms + // `first_append` (the flag is still set — the worker + // is its only consumer). + break; + } + player.append(SamplesBuffer::new(channels, rate, buf)); + // NOTE: tts_active is set AFTER player.append(), not + // before. Setting it before synthesis would cause STT to + // discard user speech during the synthesis window as + // "echo" even though no audio is actually playing yet. + // See crossfire review C3. + tts_active.store(true, Ordering::Release); + } + Ok(_) => {} + Err(e) => { + eprintln!("buzz-desktop: TTS synth failed: {e}"); break; } - player.append(SamplesBuffer::new(channels, rate, buf)); - // NOTE: tts_active is set AFTER player.append(), not - // before. Setting it before synthesis would cause STT to - // discard user speech during the synthesis window as - // "echo" even though no audio is actually playing yet. - // See crossfire review C3. - tts_active.store(true, Ordering::Release); - } - Ok(_) => {} - Err(e) => { - eprintln!("buzz-desktop: TTS synth failed: {e}"); } } } @@ -646,15 +665,10 @@ fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { /// Hard-clamp samples to ±1.0 full scale. /// -/// No gain is applied: Pocket TTS already emits speech-level audio -/// (peaks 0.4–0.97, RMS ≈ −20 dBFS across varied sentences — measured by -/// `examples/pocket_clip_probe`), matching the kyutai reference pipeline, -/// which applies no output scaling. Two earlier gain stages were both -/// regressions against that baseline: per-sentence peak normalization caused -/// level pumping between sentences, and the fixed 9.3× gain that replaced it -/// was calibrated on a single anomalously-quiet bench utterance (peak 0.076) -/// and clipped 13–34% of samples on real speech ("blown out", 2026-06-12). -/// The clamp alone remains as the safety net against outlier transients. +/// No gain is applied because Pocket TTS already emits speech-level audio and +/// the reference pipeline applies no output scaling. Normalizing each sentence +/// would cause level pumping between chunks. The clamp remains only as a safety +/// net against outlier transients. fn clamp_to_full_scale(samples: Vec) -> Vec { samples.into_iter().map(|s| s.clamp(-1.0, 1.0)).collect() } @@ -667,14 +681,10 @@ fn clamp_to_full_scale(samples: Vec) -> Vec { /// /// # Why no fade-in /// -/// An earlier revision (pre 2026-05) symmetrically faded *in* over the same -/// 8 ms window. That swallowed the leading consonant attack on every -/// sentence — Pocket TTS produces real audio energy inside the first -/// millisecond (RMS ≈ 0.02, peak ≈ 0.03 measured across four prompts in -/// `examples/pocket_onset_probe.rs`), and a linear 0→1 ramp over 192 samples -/// scales those onset samples by ≤50 % for the first ~4 ms. The result was -/// the "first little sound or two is missing" regression heard on -/// 2026-05-18. +/// A symmetric fade-in would attenuate the leading consonant attack because +/// Pocket TTS produces real audio energy inside the first millisecond. A +/// linear 0→1 ramp over 192 samples scales those onset samples by ≤50% for the +/// first ~4 ms, which can make the first phoneme sound clipped. /// /// The first sample of Pocket output measures ≈ 0.0018 (≈ −54 dBFS) — well /// below the threshold at which a DC-jump would be audible as a click — so @@ -689,13 +699,12 @@ fn apply_fade_out(samples: &mut [f32]) { } } -/// Build the single buffer appended to the rodio `Player` for one synthesised -/// sentence. +/// Build one buffer appended to the rodio `Player` for a synthesis unit. /// -/// Every sentence chunk gets a short lead-in pad immediately before its audio. -/// This matters for chunks that start with soft first phonemes (`I'm`, `I've`): -/// the synthesized buffer can begin with speech within the first millisecond, -/// so the playback layer must provide the device/mixer cushion. +/// Every playback boundary gets a short lead-in pad immediately before its +/// audio. This matters for chunks that start with soft first phonemes (`I'm`, +/// `I've`): the synthesized buffer can begin with speech within the first +/// millisecond, so the playback layer must provide the device/mixer cushion. /// To keep the audible gap unchanged, the trailing silence after this chunk is /// shortened by the same amount (`silence_buf_len - SENTENCE_LEAD_IN_SAMPLES`): /// sentence N contributes 80 ms of post-speech silence and sentence N+1 @@ -706,6 +715,11 @@ fn apply_fade_out(samples: &mut [f32]) { /// tracked source per synthesized sentence, avoiding source-boundary/drain /// regressions from enqueueing the lead-in, audio, and tail as separate sounds. /// +/// A playback chunk may contain several model-sized synthesis units. Only the +/// first unit receives the onset cushion and only the last receives the +/// remaining gap. If playback underruns while the next unit is synthesized, +/// that unit becomes a new playback boundary and receives a fresh cushion. +/// /// `first_append` is flipped on the first call after the player goes idle. /// The worker uses it in the idle branch of the main loop to distinguish /// "never queued anything since last drain" from "drained after speaking", @@ -714,14 +728,25 @@ fn build_sentence_append_buffer( first_append: &mut bool, audio: Vec, silence_buf_len: usize, + starts_playback_chunk: bool, + ends_playback_chunk: bool, ) -> Vec { if *first_append { *first_append = false; } - let trailing_silence_len = silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES); - let mut buf = Vec::with_capacity(SENTENCE_LEAD_IN_SAMPLES + audio.len() + trailing_silence_len); - buf.extend(std::iter::repeat_n(0.0_f32, SENTENCE_LEAD_IN_SAMPLES)); + let lead_in_len = if starts_playback_chunk { + SENTENCE_LEAD_IN_SAMPLES + } else { + 0 + }; + let trailing_silence_len = if ends_playback_chunk { + silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES) + } else { + 0 + }; + let mut buf = Vec::with_capacity(lead_in_len + audio.len() + trailing_silence_len); + buf.extend(std::iter::repeat_n(0.0_f32, lead_in_len)); buf.extend(audio); buf.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len)); buf @@ -734,9 +759,8 @@ fn build_sentence_append_buffer( /// single-sentence cost. Subsequent sentences pack greedily: a sentence /// joins the current chunk while the combined length stays within /// `max_chars`; otherwise it starts a new chunk. A single sentence longer -/// than `max_chars` becomes its own chunk unsplit — Pocket TTS handles long -/// single sentences fine (the ceiling is the 500-LM-step default), it's the -/// *seams* we're minimizing. +/// than `max_chars` becomes its own chunk here, then the Pocket engine splits +/// it at the April bundle's exact token limit before synthesis. /// /// Sentences within a chunk are joined with a single space; sentence-ending /// punctuation is preserved by `split_sentences`, so the model sees natural diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 7887f8bbdb..1908b096b1 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -9,6 +9,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; +#[path = "tts_tests/token_split.rs"] +mod token_split; + // ── Remote interrupt tracker ────────────────────────────────────────────── // // Models the per-peer frame counting logic in the recv task of @@ -785,16 +788,6 @@ fn apply_fade_out_single_sample() { assert_eq!(samples[0], 1.0); } -/// Sanity-check the per-sentence cushion length: 20 ms at 24 kHz must -/// land at exactly 480 samples. This is a const computation, so the -/// real value of this test is documenting *why* 20 ms was chosen — it -/// covers a typical CoreAudio buffer turnover (256–1024 samples) -/// without being audible as user-facing latency. -#[test] -fn sentence_lead_in_is_sane() { - assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); -} - // ── build_sentence_append_buffer tests ─────────────────────────────────── /// REGRESSION: every chunk needs an onset cushion; synthesized chunks @@ -812,6 +805,8 @@ fn lead_in_pad_is_present_for_every_sentence_chunk() { &mut first, vec![0.5_f32; SENTENCE_AUDIO_LEN], SILENCE_BUF_LEN, + true, + true, ); assert_eq!(buf.len(), SENTENCE_AUDIO_LEN + SILENCE_BUF_LEN); @@ -840,11 +835,11 @@ fn lead_in_pad_is_present_for_every_sentence_chunk() { #[test] fn build_sentence_append_buffer_flips_first_append() { let mut first = true; - let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(!first, "first call must flip the flag"); // Subsequent call: still has a per-sentence lead-in, flag stays false. - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); assert!(!first); } @@ -853,7 +848,7 @@ fn build_sentence_append_buffer_flips_first_append() { #[test] fn first_sentence_leading_silence_is_exactly_lead_in() { let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5); } @@ -863,8 +858,10 @@ fn first_sentence_leading_silence_is_exactly_lead_in() { fn sentence_gap_budget_is_preserved() { let mut first = true; let silence_buf_len = 2400; - let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len); - let second_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len); + let first_buf = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); + let second_buf = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); let first_tail = &first_buf[SENTENCE_LEAD_IN_SAMPLES + 100..]; let second_lead = &second_buf[..SENTENCE_LEAD_IN_SAMPLES]; @@ -877,7 +874,7 @@ fn sentence_gap_budget_is_preserved() { #[test] fn sentence_append_buffer_is_one_contiguous_source() { let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert_eq!(buf.len(), 2400 + 100); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); @@ -949,9 +946,8 @@ fn chunk_grouping_packs_up_to_budget_then_spills() { assert_eq!(chunks[2], d); } -/// A single sentence longer than the budget is passed through unsplit — -/// long single sentences are fine (the LM cap bounds runaway); only seams -/// are being minimized. +/// A single sentence longer than the coarse budget is passed through here; +/// the loaded April engine subsequently enforces its exact 50-token limit. #[test] fn chunk_grouping_oversized_sentence_passes_through() { let long = "word ".repeat(60).trim_end().to_string() + "."; diff --git a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs new file mode 100644 index 0000000000..b9249c9afc --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs @@ -0,0 +1,24 @@ +use super::*; + +/// The onset cushion covers 20 ms at the production sample rate. +#[test] +fn sentence_lead_in_is_sane() { + assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); +} + +/// Model-token splits remain contiguous: only the playback chunk as a whole +/// receives its onset cushion and trailing sentence gap. +#[test] +fn token_split_units_do_not_add_sentence_boundary_padding() { + let mut first = true; + let silence_buf_len = 2400; + let first_unit = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, false); + let last_unit = + build_sentence_append_buffer(&mut first, vec![0.25; 100], silence_buf_len, false, true); + + assert_eq!(first_unit.len(), SENTENCE_LEAD_IN_SAMPLES + 100); + assert_eq!(first_unit.last(), Some(&0.5)); + assert_eq!(last_unit.first(), Some(&0.25)); + assert_eq!(first_unit.len() + last_unit.len(), 200 + silence_buf_len); +} From 081f805d5ea25841ab885c7b67a568618a34aa59 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:02:48 -0400 Subject: [PATCH 90/99] feat(agent): optional reply guard reminds a silent turn to publish (#3763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why A Buzz agent's assistant text and reasoning are never shown to anyone — only what it posts through the CLI. A turn that runs fifteen tool calls and never publishes is a silent failure: the requester waits on a result that was produced and thrown away. This adds an optional reminder at the end-of-turn gate, off by default. Tyler asked for it in buzz-mesh; plan iterated to **9.5/10 with @Wren** (Minimalness 9.7, Elegance 9.5, Correctness 9.3). ## What `BUZZ_AGENT_REQUIRE_REPLY=1` (default off, per-agent opt-in). A turn about to end with no recognized attempt to post gets a reminder and is rerolled. **At most two, then the turn ends regardless** — the guard catches accidental omission, it does not compel speech. The reminder text explicitly licenses silence so it cannot fight the base prompt's "silence is usually correct." **This is not a new MCP hook.** `RunCtx::run` *is* the turn, so the two per-turn locals need no plumbing, and every tool call already passes through it with arguments visible. The objection is appended at the existing `_Stop` gate and rides `push_hook_outputs_as_tool_results`, so the model receives it as a lower-trust tool result with `{hook, server, text}` attribution. No new trust path, no new lifecycle event, no dev-mcp or CLI protocol change. Earlier revisions of this plan needed four crates (a `_UserPromptSubmit` hook, a marker file, a `buzz-cli` change, dev-mcp state). Tyler pointed out the agent already knows both facts; that deleted all of it. Net runtime change is ~35 lines in `agent.rs` + ~4 in `config.rs`. ### Recognition contract A registered non-hook tool whose qualified name ends in `__shell`, whose `command` argument contains `messages send` or `reactions add`. - **The `__` separator is exact, not approximate.** Given `has()` + `!is_hook()`, `ends_with("__shell")` is *provably equivalent* to a bare name of `shell`: registration forbids `__` in server and bare names (`mcp.rs:227,268`) and qnames are `{server}__{bare}`, so a trailing `__shell` could only straddle the separator if the bare name began with `_` — which `is_hook` excludes. Without the separator, `powershell` and `noshell` would match. - **Reads the structured `command` field**, not serialized arguments, so a `description` that quotes a send cannot disarm the guard, and a non-string `command` is rejected rather than coerced. - **Detects an attempt, not a successful publish.** A failed send already returns non-zero exit and error JSON — louder than this reminder. The variable is named `buzz_reply_call_seen` so the code can't pretend otherwise. - **Checked after the per-turn tool-call cap**, since a discarded call never ran. - `messages send` also covers `messages send-diff`. Reactions count because the base prompt directs agents to react rather than post a bare acknowledgement. **Known limits, both deliberate and documented:** a command assembled at runtime (`$CMD`) or hidden in a wrapper script is missed; text that merely quotes a send (`echo "buzz messages send"`) matches. Missing a real post is the expensive direction and substring matching is the forgiving one there. Neither edge is pinned by a test, so the matcher stays free to improve. ### Budget Reminders share `BUZZ_AGENT_STOP_MAX_REJECTIONS`, the existing outer cap on every end-turn objection. Default 3 fits both; at 1 only one fits; at 0 the guard is off with the hooks. A round carrying both a hook objection and a reminder costs one rejection and delivers both texts. An independent budget would either violate that bound or need a second arbitration rule. ## Prior art - **#3467** (closed) built the same detector one layer up in `buzz-acp` for a different remedy. None of its symbols are on main — this borrows its permission to be coarse, but reads structured data that ACP didn't have. - **#3648** (open) detects turns with *no output at all*; a turn with fifteen tool calls and no post counts as output there, so it does not cover this case. - **#3741** (merged) is mesh-only. ## Testing **14 new tests.** 4 unit tests on the matcher; 10 integration tests through the ACP wire harness: off by default, `=0` still off, opted-in silent → exactly 2 reminders then `end_turn`, registered `fake__shell` send → 0 reminders, hallucinated `fake__shell` → still reminded, publish call truncated past the 64-call cap → still reminded, budget 1 → 1 reminder, budget 0 → off, combined `_Stop` hook objection + reminder → one round both texts and after 2 reminders the hook objection continues alone, unparseable `=true` → startup error naming the key. **10 mutation checks, each breaking a specific named test** — neutralize the nag cap, stop sharing the budget, neutralize `buzz_reply_call_seen`, drop `has`/`is_hook`, ignore the flag, drop the `__`, drop `reactions add`, read serialized args, move detection before truncation. `tests/bin/fake_mcp.rs` gains `FAKE_MCP_SHELL_TOOL=1`: it previously exposed no tool with a bare name of `shell`, so the satisfied-guard path was untestable. Full `cargo test -p buzz-agent` green at 9e0ae1f04; clippy `-D warnings` and `cargo fmt --check` clean. **Unrelated flake found:** `cancelled_turn_with_usage_emits_notification_before_response` (`tests/fake_llm.rs`) is timing-sensitive. Under 10 loaded cores it fails **2/20 on this branch and 1/20 at unmodified `origin/main@02be413b8`** — pre-existing, not caused by this change (which is inert without the env var). Flagging so it isn't misattributed to the next PR that's open when CI hits it. ## Docs `crates/buzz-agent/README.md` is the primary home (env var, recognition contract, limits, budget interaction). `docs/MCP_DRIVEN_HOOKS.md` gets a short cross-reference explaining this is *not* a hook — otherwise readers hunt for a `_ReplyGuard` tool that doesn't exist. --------- Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Dawn (sprout agent) Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- crates/buzz-agent/README.md | 61 +++ crates/buzz-agent/src/agent.rs | 182 ++++++- crates/buzz-agent/src/config.rs | 12 + crates/buzz-agent/src/llm.rs | 1 + crates/buzz-agent/tests/bin/fake_mcp.rs | 26 +- crates/buzz-agent/tests/regressions.rs | 462 ++++++++++++++++++ .../src/managed_agents/relay_mesh.rs | 89 +++- docs/MCP_DRIVEN_HOOKS.md | 17 + 8 files changed, 847 insertions(+), 3 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index f138e4a4f1..5d942777d5 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -163,6 +163,67 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | | `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. | | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. | +| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. | + + +## Reply Guard + +Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop +sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is +about to end without any recognized attempt to post to Buzz gets a reminder that +its assistant text is invisible to humans, and is rerolled. + +This exists because a Buzz agent's reasoning and tool output are not shown to +anyone. A turn that does real work and never posts is a silent failure — the +requester waits on a result that was produced and thrown away. + +Mesh agents get it by default because they run on small local models, which are +the ones most likely to do the work and then end the turn without publishing it. +Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a +mesh agent back out; the default never overrides an explicit value. + +**Advisory, never a trap.** At most two reminders, then the turn ends whether or +not anything was published. The guard catches accidental omission; it does not +compel speech. The reminder text explicitly licenses silence, because the +built-in system prompt says publishing is optional and silence is often the +correct outcome. + +**Recognition contract.** A turn counts as having replied when it issues a call +that: + +- resolves to a registered, non-hook tool (a hallucinated tool name is rejected + at preflight and never runs, so it must not disarm the guard), +- whose qualified name ends in `__shell` — i.e. the bare tool name is exactly + `shell`, which is `buzz-dev-mcp`'s shell tool and any other server's, and +- whose `command` argument contains `messages send` or `reactions add`. + +`messages send` also covers `messages send-diff`. Reactions count because the +built-in prompt directs agents to react rather than post a bare +acknowledgement, so nagging an agent that reacted would punish documented +behavior. + +Detection is checked **after** the per-turn tool-call cap +(`MAX_TOOL_CALLS_PER_TURN`) is applied: a publish-shaped call that was discarded +never ran. + +**It recognizes an attempt, not a successful publish.** Only the command text is +inspected, never the exit status. A send that fails still satisfies the guard — +which is fine, since a failed send already returns a non-zero exit and error +JSON to the model, louder feedback than a reminder. + +**Known limits**, both deliberate. A command assembled at runtime (`$CMD`) or +buried in a wrapper script is missed, so that turn is reminded despite having +posted. Text that merely quotes a send (`echo "buzz messages send"`) matches, so +that turn is not reminded. Missing a real post is the expensive direction, and +substring matching is the forgiving one there. Neither edge is pinned by a test; +the matcher is free to improve. + +**Budget.** Reminders ride the existing `_Stop` gate and share +`BUZZ_AGENT_STOP_MAX_REJECTIONS` — the outer cap on every end-turn objection. +At the default 3 both reminders fit; at 1 only one does; at 0 the guard is off +along with the hooks. A round carrying both a `_Stop` hook objection and a +reminder costs one rejection and delivers both texts. This is not a new +lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md). ## Providers diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 48d4ea3b02..8e14fee195 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -21,6 +21,80 @@ use crate::wire::{self, WireSender}; const ERROR_REFLECTION_SUFFIX: &str = "\n\n[Reflect] Before retrying, identify the cause and change your approach."; +/// Maximum reply reminders emitted per prompt when `require_reply` is on. +/// +/// After this many, the turn is allowed to end whether or not anything was +/// published: the guard exists to catch accidental omission, not to compel +/// speech. The shared `stop_max_rejections` budget can cut this lower — see +/// [`Config::require_reply`](crate::config::Config::require_reply). +const MAX_REPLY_NAGS: u32 = 2; + +/// Server label on the synthetic reply-guard objection. +/// +/// Not a real MCP server. It rides the same tool-result path as `_Stop` hook +/// output, so the model sees `{hook, server, text}` attribution naming the +/// in-process guard rather than an MCP server that could be impersonated. +const REPLY_GUARD_SERVER: &str = "buzz-agent"; + +/// Reminder text emitted when a turn is about to end with nothing published. +/// +/// Explicitly licenses silence. The base prompt tells agents that publishing is +/// optional and "silence is usually correct"; a reminder that argued otherwise +/// would fight that instruction and make agents chattier. +const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send`. \ +Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, \ +or hit a blocker that someone is waiting on, it exists only if you publish it. \ +If you already posted, or if silence is genuinely correct for this turn, ignore this and end your turn."; + +/// Whether `call` is a recognized attempt to publish a reply to Buzz. +/// +/// Recognizes an *attempt*, not a successful publish: the command text is +/// inspected, never the exit status. That is deliberate — a send that fails +/// already returns a non-zero exit and error JSON to the model, which is louder +/// feedback than the reminder this gates. +/// +/// `has` + `!is_hook` are the same checks the dispatcher uses to accept a call +/// (see `execute_calls`), so a hallucinated `fake__shell` — rejected at preflight +/// and never executed — cannot disarm the guard. They must stay *before* +/// [`is_reply_shaped`]: together with them, and only with them, the `__shell` +/// suffix is exactly equivalent to "the bare tool name is `shell`". +fn is_buzz_reply_call(call: &ToolCall, mcp: &McpRegistry) -> bool { + mcp.has(&call.name) && !mcp.is_hook(&call.name) && is_reply_shaped(&call.name, &call.arguments) +} + +/// Whether a tool name and arguments have the shape of a Buzz publish command. +/// +/// Split from [`is_buzz_reply_call`] only so the matcher is testable without a +/// live [`McpRegistry`]; callers must apply the registry checks first. +/// +/// On the name: `ends_with("__shell")` is exact rather than approximate *given* +/// those checks. Registration rejects `__` in both server names and bare tool +/// names, and qualified names are `{server}__{bare}`, so a trailing `__shell` can +/// only straddle the separator if the bare name starts with `_` — which `is_hook` +/// already excludes. Dropping the separator would not be exact: `powershell` and +/// `noshell` both end in `shell`. +/// +/// On the command: a deliberately coarse substring test, scoped to the structured +/// `command` field so unrelated metadata — a `description` that quotes a send — +/// cannot suppress the guard, and a non-string `command` is rejected rather than +/// coerced. Known limits, both accepted: a command assembled at runtime (`$CMD`) +/// or hidden in a wrapper script is missed, and text that merely quotes a send +/// (`echo "buzz messages send"`) matches. Missing a real post is the expensive +/// direction, and substring matching is the more forgiving one there. +fn is_reply_shaped(name: &str, arguments: &serde_json::Value) -> bool { + name.ends_with("__shell") + && arguments + .get("command") + .and_then(|v| v.as_str()) + .is_some_and(|cmd| { + // `messages send` also covers `messages send-diff`. `reactions + // add` counts because the base prompt directs agents to react + // rather than post a bare acknowledgement, so nagging an agent + // that reacted would punish documented-correct behavior. + cmd.contains("messages send") || cmd.contains("reactions add") + }) +} + pub struct RunCtx<'a> { pub cfg: &'a Config, /// Effective model for this session. Usually equals `cfg.model`; overridden @@ -102,6 +176,14 @@ impl RunCtx<'_> { // session) so a stubborn exchange can't permanently disable the stop // guard for a long-lived session; `max_rounds` still caps the loop. let mut stop_rejections = 0u32; + // Reply-guard state for this prompt. `prompt()` *is* the turn, so + // locals here are per-turn by construction — same shape as + // `stop_rejections` above. + // + // Named for what it proves: a *recognized attempt* to publish, not a + // successful publish. See `is_buzz_reply_call`. + let mut buzz_reply_call_seen = false; + let mut reply_nags = 0u32; loop { if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { return Ok(StopReason::MaxTurnRequests); @@ -264,7 +346,7 @@ impl RunCtx<'_> { if stop_rejections >= self.cfg.stop_max_rejections { return Ok(stop); } - let objections = self + let mut objections = self .mcp .call_hooks( "_Stop", @@ -273,6 +355,17 @@ impl RunCtx<'_> { &self.cfg.hook_servers, ) .await; + // Reply guard shares this gate and this budget, so a round + // carrying both a hook objection and a reply reminder costs + // one rejection and delivers both texts. + if self.cfg.require_reply + && !buzz_reply_call_seen + && reply_nags < MAX_REPLY_NAGS + { + reply_nags += 1; + objections + .push((REPLY_GUARD_SERVER.to_string(), REPLY_GUARD_NAG.to_string())); + } if !objections.is_empty() { stop_rejections = stop_rejections.saturating_add(1); push_hook_outputs_as_tool_results(self.history, "_Stop", &objections); @@ -290,6 +383,11 @@ impl RunCtx<'_> { ); calls.truncate(MAX_TOOL_CALLS_PER_TURN); } + // Deliberately after truncation: a publish-shaped call that was + // discarded never runs, so it must not suppress the reminder. + if self.cfg.require_reply && !buzz_reply_call_seen { + buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp)); + } self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: calls.clone(), @@ -799,6 +897,88 @@ mod tests { use super::*; use serde_json::json; + /// The shapes the guard must recognize as a publish attempt. Callers apply + /// the registry checks first; these cover the name suffix and command text. + #[test] + fn reply_shape_matches_documented_send_forms() { + for cmd in [ + "buzz messages send --channel X --content Y", + "buzz --relay wss://r messages send --channel X --content Y", + "/abs/path/buzz messages send", + "printf 'hi' | buzz messages send --content -", + "buzz messages send-diff --diff -", + "buzz reactions add --event E --emoji +", + // Assembled through another shell: rev 3's tokenizer missed this. + r#"sh -c "buzz messages send --channel X""#, + ] { + assert!( + is_reply_shaped("dev__shell", &json!({ "command": cmd })), + "expected {cmd:?} to count as a publish attempt" + ); + } + } + + /// Commands that do real work but do not reply in the originating + /// conversation must still be nagged. + #[test] + fn reply_shape_rejects_non_reply_commands() { + for cmd in [ + "buzz messages get --channel X", + "buzz channels list", + "buzz reactions remove --event E", + "buzz pr open --title T", + "buzz social publish --content hi", + "buzz notes set --name n", + "cargo test -p buzz-agent", + ] { + assert!( + !is_reply_shaped("dev__shell", &json!({ "command": cmd })), + "expected {cmd:?} not to count as a publish attempt" + ); + } + } + + /// The `__` separator is load-bearing: `ends_with("shell")` alone would + /// accept any registered tool whose name merely ends in those letters, and + /// `has()` proves registration, not the bare name. + #[test] + fn reply_shape_requires_the_qname_separator() { + let args = json!({ "command": "buzz messages send --channel X" }); + for name in [ + "dev__powershell", + "dev__noshell", + "shell", + "dev__send_message", + ] { + assert!( + !is_reply_shaped(name, &args), + "{name} must not satisfy the shell-tool check" + ); + } + assert!(is_reply_shaped("dev__shell", &args)); + assert!(is_reply_shaped("buzz-dev-mcp__shell", &args)); + } + + /// Only the field that carries the executable command counts. Searching + /// serialized arguments instead would let arbitrary metadata disarm the + /// guard, turning a description into an attempted send. + #[test] + fn reply_shape_reads_only_the_command_field() { + assert!(!is_reply_shaped( + "dev__shell", + &json!({ "description": "buzz messages send --channel X" }) + )); + assert!(!is_reply_shaped( + "dev__shell", + &json!({ "workdir": "buzz messages send" }) + )); + // Malformed `command` is rejected, not coerced — and must not panic. + assert!(!is_reply_shaped("dev__shell", &json!({ "command": 42 }))); + assert!(!is_reply_shaped("dev__shell", &json!({ "command": null }))); + assert!(!is_reply_shaped("dev__shell", &json!({}))); + assert!(!is_reply_shaped("dev__shell", &json!("not an object"))); + } + /// A9 regression: `reasoning_details` contributes real bytes to /// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a /// history item carrying a large opaque reasoning array must actually diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index a0e64f1a9d..afbda5379d 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -720,6 +720,16 @@ pub struct Config { /// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to /// disable `_Stop` hooks entirely (agent always honors end_turn). pub stop_max_rejections: u32, + /// Remind the model to publish when a turn is about to end without any + /// recognized attempt to post to Buzz. Default off; opt in per agent with + /// `BUZZ_AGENT_REQUIRE_REPLY=1`. + /// + /// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`), + /// then the turn ends regardless. Bounded by the same + /// `stop_max_rejections` budget as `_Stop` hooks, which is the outer cap on + /// all end-turn objections — at the default 3 both reminders fit; at 1 only + /// one does; at 0 the guard is off with the hooks. + pub require_reply: bool, /// Hook server allowlist. See [`HookServers`] for variant semantics. /// Default (env unset/empty) is `None` — hooks are off unless the /// operator explicitly opts in. @@ -851,6 +861,7 @@ impl Config { max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, + require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, @@ -893,6 +904,7 @@ impl Config { max_parallel_tools: 1, hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, + require_reply: false, hook_servers: HookServers::None, hints_enabled: false, thinking_effort: None, diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f595a165e5..73c7e1faf2 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2355,6 +2355,7 @@ mod tests { max_parallel_tools: 1, hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, + require_reply: false, hook_servers: HookServers::None, api_key: "key".into(), model: "model".into(), diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 0bbd1d3478..5b660da48c 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -33,6 +33,11 @@ //! — expose a `_PostCompact` hook tool //! FAKE_MCP_POSTCOMPACT_TEXT=text //! — `_PostCompact` returns this (default: "") +//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell` +//! (registered as `__shell`), taking a +//! `command` string. Lets a test drive the +//! reply guard's recognition of a real, +//! registered shell tool. use std::io::{BufRead, Write}; @@ -76,6 +81,7 @@ fn make_tools( desc: &str, include_stop_hook: bool, include_post_compact_hook: bool, + include_shell_tool: bool, ) -> Vec { let mut tools: Vec = (0..count) .map(|i| { @@ -100,6 +106,17 @@ fn make_tools( "inputSchema": { "type": "object", "properties": {} }, })); } + if include_shell_tool { + tools.push(json!({ + "name": "shell", + "description": "run a shell command", + "inputSchema": { + "type": "object", + "properties": { "command": { "type": "string" } }, + "required": ["command"], + }, + })); + } tools } @@ -136,6 +153,7 @@ fn main() { let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX); let mut stop_calls_seen: usize = 0; let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); + let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL"); let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); // Use a channel-based stdin reader so notifications (which carry no id) @@ -206,7 +224,13 @@ fn main() { write_response( id, json!({ - "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook) + "tools": make_tools( + tool_count, + &desc, + stop_hook, + post_compact_hook, + shell_tool, + ) }), ); } diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index 2e0b579c84..abb4f7b311 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -1819,3 +1819,465 @@ async fn cancel_sends_notifications_cancelled_to_any_mcp_server() { let _ = std::fs::remove_file(&call_received_marker); h.shutdown().await; } + +// --------------------------------------------------------------------------- +// Reply guard (`BUZZ_AGENT_REQUIRE_REPLY`) +// +// The guard reminds the model to publish when a turn is about to end without +// any recognized attempt to post to Buzz. It rides the existing `_Stop` gate +// and shares its rejection budget, so most of these tests count LLM calls: +// each reminder costs exactly one extra round. +// --------------------------------------------------------------------------- + +/// Number of reply-guard reminders present in one captured LLM request. +/// +/// A reminder is a tool-role message whose JSON body is attributed to the +/// in-process guard (`server: "buzz-agent"`) at the `_Stop` hook point — the +/// same lower-trust shape as real hook output. +fn reply_nag_count(request: &Value) -> usize { + request["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .filter(|m| { + m["role"] == "tool" + && serde_json::from_str::(m["content"].as_str().unwrap_or("")) + .map(|p| p["hook"] == "_Stop" && p["server"] == "buzz-agent") + .unwrap_or(false) + }) + .count() + }) + .unwrap_or(0) +} + +/// A publish-shaped call to a real registered shell tool. +fn openai_shell_send(id: &str) -> Value { + openai_tool_call( + id, + "fake__shell", + json!({ "command": "buzz messages send --channel c --content hi" }), + ) +} + +/// Run one prompt to completion, answering any permission requests, and +/// return the final response. +async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value { + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p) { + return v; + } + } +} + +/// Default off: a silent turn ends on the first end_turn with no extra round. +/// This is the invariant that keeps the feature free for everyone who hasn't +/// opted in. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_off_by_default() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "guard must be inert when unset, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// `BUZZ_AGENT_REQUIRE_REPLY=0` is off too — the toggle is numeric, so a +/// literal `0` must not read as "set, therefore on". +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_explicit_zero_is_off() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "0")]).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "REQUIRE_REPLY=0 must behave as off, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// Opted in and silent: exactly two reminders, then the turn is allowed to +/// end. The guard is advisory — it must never trap a turn. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_nags_twice_then_lets_the_turn_end() { + // Budget defaults to 3, so the cap that stops the loop here is + // MAX_REPLY_NAGS = 2, not the rejection budget. + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("silent-3"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected 2 reminders then end_turn (3 LLM calls), got {}", + captured.len() + ); + assert_eq!( + reply_nag_count(&captured[0]), + 0, + "reminder before any end_turn" + ); + assert_eq!(reply_nag_count(&captured[1]), 1); + assert_eq!(reply_nag_count(&captured[2]), 2); + + // The reminder must name the command it wants and license silence, so it + // cannot fight the base prompt's "silence is usually correct". + let msgs = captured[2]["messages"].as_array().unwrap(); + let nag = msgs + .iter() + .filter_map(|m| serde_json::from_str::(m["content"].as_str().unwrap_or("")).ok()) + .find(|p| p["server"] == "buzz-agent") + .expect("reminder body"); + let text = nag["text"].as_str().unwrap_or(""); + assert!( + text.contains("buzz messages send"), + "reminder should name the command: {text}" + ); + assert!( + text.contains("silence is genuinely correct"), + "reminder must license silence: {text}" + ); + h.shutdown().await; +} + +/// A real publish attempt through a registered shell tool satisfies the guard: +/// no reminder, no extra round. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_satisfied_by_registered_shell_send() { + let llm = spawn_capturing_llm(vec![ + openai_shell_send("tc1"), + openai_text("posted"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await; + let sid = init_session_with_fake_mcp( + &mut h, + &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "a recognized send must not be nagged, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[1]), 0); + h.shutdown().await; +} + +/// A publish-shaped call to a shell tool that is *not registered* never runs — +/// preflight rejects it — so it must not disarm the guard. This is what the +/// `has`/`is_hook` checks in the predicate buy. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_ignores_unregistered_shell_tool() { + // FAKE_MCP_SHELL_TOOL is absent, so `fake__shell` is a hallucination. + let llm = spawn_capturing_llm(vec![ + openai_shell_send("tc1"), + openai_text("silent-1"), + openai_text("silent-2"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp(&mut h, &[("FAKE_MCP_TOOL_COUNT", "1")]).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected the hallucinated call to still be nagged, got {} LLM calls", + captured.len() + ); + let msgs = captured[1]["messages"].as_array().unwrap(); + assert!( + msgs.iter() + .any(|m| m["role"] == "tool" + && m["content"].as_str().unwrap_or("").contains("unknown tool")), + "expected preflight to reject the call: {msgs:?}" + ); + assert_eq!(reply_nag_count(&captured[2]), 1); + h.shutdown().await; +} + +/// A publish-shaped call discarded by the per-turn tool-call cap never runs, +/// so it must not suppress the reminder either. Pins the check's placement +/// after `calls.truncate(MAX_TOOL_CALLS_PER_TURN)`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_ignores_calls_lost_to_the_turn_cap() { + // 64 filler calls (the cap) followed by the publish attempt, which is + // therefore truncated away. The shell tool *is* registered here, so only + // the placement — not tool identity — can explain the reminder. + let mut calls: Vec = (0..64) + .map(|i| { + json!({ + "id": format!("c{i}"), + "type": "function", + "function": { "name": "fake__tool_0", "arguments": "{}" }, + }) + }) + .collect(); + calls.push(json!({ + "id": "c-send", + "type": "function", + "function": { + "name": "fake__shell", + "arguments": json!({ "command": "buzz messages send --channel c --content hi" }) + .to_string(), + }, + })); + let truncated_send = json!({ + "id": "cc-trunc", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": null, "tool_calls": calls }, + "finish_reason": "tool_calls", + }], + }); + let llm = spawn_capturing_llm(vec![ + truncated_send, + openai_text("silent-1"), + openai_text("silent-2"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "a truncated send must still be nagged, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[2]), 1); + h.shutdown().await; +} + +/// The shared `_Stop` rejection budget is the outer cap: at 1 the guard gets +/// one reminder instead of two. Documented degradation, not a bug. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_bounded_by_stop_rejection_budget() { + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "budget 1 must allow exactly one reminder, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[1]), 1); + h.shutdown().await; +} + +/// Budget 0 disables every objection at the gate, including this one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_off_when_stop_budget_is_zero() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "budget 0 must disable the guard, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// The two axes are independent inside one shared budget: a round carrying +/// both a `_Stop` hook objection and a reminder costs one rejection and +/// delivers both texts, and once the reminders are spent the hook objection +/// continues alone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_combines_with_stop_hook_objection() { + // The hook objects on its first 3 calls, then clears. Reminders stop + // after 2, so round 3 must carry the hook text and no new reminder. + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("silent-3"), + openai_text("silent-4"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("MCP_HOOK_SERVERS", "fake"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "you have open todos"), + ("FAKE_MCP_STOP_COUNT", "3"), + ], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 4, + "expected 3 objecting rounds then a clear end, got {}", + captured.len() + ); + + let hook_objections = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .filter(|m| { + m["content"] + .as_str() + .unwrap_or("") + .contains("you have open todos") + }) + .count() + }) + .unwrap_or(0) + }; + + // Round 2 carries one of each — a single rejection bought both texts. + assert_eq!(reply_nag_count(&captured[1]), 1); + assert_eq!(hook_objections(&captured[1]), 1); + // Round 4: the hook objected three times, the guard only twice. + assert_eq!(reply_nag_count(&captured[3]), 2); + assert_eq!(hook_objections(&captured[3]), 3); + h.shutdown().await; +} + +/// An unparseable toggle is a startup error, not a silent default. `parse_env` +/// is generic over `FromStr`, so this also pins the numeric type: a `bool` +/// field would have rejected the documented `1`. +#[test] +fn reply_guard_rejects_unparseable_toggle() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_buzz-agent")) + .env("BUZZ_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("BUZZ_AGENT_REQUIRE_REPLY", "true") + .stdin(Stdio::null()) + .output() + .expect("run buzz-agent"); + assert!( + !out.status.success(), + "expected a config error exit, got {:?}", + out.status + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("BUZZ_AGENT_REQUIRE_REPLY"), + "expected the offending key in the error, got: {stderr}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 327c106bc8..5c246feedc 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -47,6 +47,13 @@ pub fn apply_relay_mesh_env( // may deliberately choose a smaller cap or a different effort. This function // runs after those layers during readiness, so never clobber their values. insert_default_if_unset(env, "BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096"); + // Mesh agents run on small local models, which are the ones most likely to + // do the work and then end the turn without publishing it — the failure the + // reply guard exists to catch. Everywhere else it stays opt-in and unset. + // A default, not policy: an explicit `0` from the agent/persona/global env + // survives (see `insert_default_if_unset`, and the copy-forward list in + // `relay_mesh_process_env` that preserves it through the spawn path). + insert_default_if_unset(env, "BUZZ_AGENT_REQUIRE_REPLY", "1"); // Deliberately no BUZZ_AGENT_THINKING_EFFORT default: mesh translates // `reasoning_effort` into the chat template's `enable_thinking` flag, so any // value we pick overrides each model's own template default — and the right @@ -80,7 +87,15 @@ pub fn relay_mesh_process_env( model: &str, ) -> std::collections::BTreeMap { let mut env = std::collections::BTreeMap::new(); - for key in ["BUZZ_AGENT_MAX_OUTPUT_TOKENS", "BUZZ_AGENT_THINKING_EFFORT"] { + for key in [ + "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + "BUZZ_AGENT_THINKING_EFFORT", + // Must be copied forward for the user's value to survive: this map is + // written onto the command *after* the layered user env, so a key absent + // here is re-defaulted by `apply_relay_mesh_env` below and an explicit + // `BUZZ_AGENT_REQUIRE_REPLY=0` would be silently overridden back to `1`. + "BUZZ_AGENT_REQUIRE_REPLY", + ] { if let Some(value) = effective_env.get(key) { env.insert(key.to_string(), value.clone()); } @@ -145,6 +160,78 @@ mod tests { ); } + #[test] + fn native_provider_enables_reply_guard_by_default() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("1"), + "mesh agents opt into the reply guard automatically" + ); + } + + #[test] + fn native_provider_preserves_explicit_reply_guard_opt_out() { + let mut env = BTreeMap::from([("BUZZ_AGENT_REQUIRE_REPLY".to_string(), "0".to_string())]); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("0"), + "an explicit opt-out is a user decision, not a value to re-default" + ); + } + + #[test] + fn non_mesh_provider_leaves_reply_guard_unset() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some("anthropic"), Some("claude-haiku-4.5")); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY"), + None, + "the guard stays opt-in everywhere except mesh" + ); + assert!(env.is_empty(), "non-mesh providers get no mesh env at all"); + } + + /// The spawn path writes this map onto the command *after* the layered user + /// env, so an explicit opt-out only survives if it is copied forward. Without + /// the copy-forward, `apply_relay_mesh_env` re-defaults it to `1` here and + /// silently overrides the user at spawn while readiness still shows `0`. + #[test] + fn process_env_preserves_explicit_reply_guard_opt_out() { + let effective_env = + BTreeMap::from([("BUZZ_AGENT_REQUIRE_REPLY".to_string(), "0".to_string())]); + + let env = relay_mesh_process_env(&effective_env, "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("0") + ); + } + + #[test] + fn process_env_enables_reply_guard_when_user_is_silent() { + let env = relay_mesh_process_env(&BTreeMap::new(), "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("1") + ); + } + #[test] fn process_env_seeds_controls_without_restoring_unrelated_credentials() { let effective_env = BTreeMap::from([ diff --git a/docs/MCP_DRIVEN_HOOKS.md b/docs/MCP_DRIVEN_HOOKS.md index e510c378e7..6812a5b9a6 100644 --- a/docs/MCP_DRIVEN_HOOKS.md +++ b/docs/MCP_DRIVEN_HOOKS.md @@ -65,6 +65,23 @@ These constraints ensure a buggy or malicious hook cannot trap the agent. Hooks are **off by default**. The operator must explicitly opt in via `MCP_HOOK_SERVERS`. +### Not a hook: the reply guard + +`buzz-agent` has one in-process objection at the `_Stop` gate that is **not** an +MCP hook and exposes no hook tool: the reply guard +(`BUZZ_AGENT_REQUIRE_REPLY=1`), which reminds the model to publish when a turn is +about to end with nothing posted to Buzz. There is no `_ReplyGuard` tool to +implement and no server to allowlist — the env var and the recognition contract +are documented in +[crates/buzz-agent/README.md](../crates/buzz-agent/README.md#reply-guard). + +It is mentioned here only because it shares this lifecycle point and this +budget: its reminders count against `BUZZ_AGENT_STOP_MAX_REJECTIONS` like any +hook objection, and a round carrying both a hook objection and a reminder costs +one rejection and delivers both texts. Setting the budget to 0 disables both. +That the gate can carry in-process objections alongside hook output is +deliberate; hooks see no difference. + ## Implementing a Hook Any MCP server can expose hooks. Example: a test-runner server that blocks From 4632c55041c5d423d572a6f6411bb7b279c26f67 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Fri, 31 Jul 2026 07:06:41 -0400 Subject: [PATCH 91/99] feat(desktop): auto-enable huddle transcription for agents (#3180) ## Context Before this change, every huddle initialized with transcription off. Joining or adding an agent did not enable it, so the agent could not receive spoken conversation until a person clicked the transcript control. Starting a huddle from an agent DM could also omit that agent, and adding an agent who already belonged to the parent channel could attempt an unnecessary role change and show a warning. Agent detection uses authoritative huddle membership. A participant counts as an agent when the ephemeral membership identifies it with the `bot` role, or when the existing agent identity model identifies the participant in an agent DM. ## Summary Buzz now enables transcription once when the first authoritative agent is present. After that initial automatic action, explicit user control is authoritative: manual ON or OFF survives membership refreshes, reconnects, and UI remounts. Removing the last agent does not change the current transcription state. Agent-DM huddles enroll the agent automatically. Adding an agent who already belongs to the parent channel preserves the existing parent role and completes without a role-mutation warning. | Scenario | Before | With this change | | --- | --- | --- | | First authoritative agent joins or is hydrated | Transcription stays off | Transcription turns on once | | User explicitly turns transcription on or off | Manual control exists without an agent policy | The explicit choice suppresses later automatic changes | | Last agent leaves | No defined agent-presence behavior | The current transcription state remains unchanged | | Huddle starts from an agent DM | The agent can be omitted | The known agent is enrolled automatically | | Added agent already belongs to the parent channel | Buzz can attempt a role rewrite and warn | Existing parent membership and role are preserved | | Transcription is active | The control is not visually distinct | The control is highlighted and exposes `aria-pressed=true` | ## Changes - Derive agent presence from authoritative bot-role huddle membership and known agent-DM identity. - Apply the one-time auto-enable rule during create, join, membership hydration, reconnect, pipeline startup, and local agent addition. - Preserve explicit user state and use huddle-generation guards so stale asynchronous work cannot alter a replacement huddle. - Keep backend and React transcription state synchronized, with a visible and accessible active control. - Enroll known agent-DM participants and make parent-channel membership updates idempotent. - Cover hydration ordering, reconnects, remounts, explicit OFF, last-agent removal, DM enrollment, existing membership, and active styling. ## Related issue None found. ## Testing Manual validation in `pending-seed` confirmed the product contract: 1. Started a huddle from the owned, running Fizz agent DM. 2. Confirmed the authoritative roster contained the human and Fizz as an agent. 3. Confirmed transcription enabled without clicking the control: `Stop transcript`, `aria-pressed=true`, with the highlighted active background. 4. Turned transcription off and confirmed `Start transcript`, `aria-pressed=false` remained stable. 5. Removed Fizz while transcription was off and confirmed the state stayed off. 6. Left the huddle cleanly. ## Screenshots The same control has distinct active and inactive states. ![Active transcript control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--active-transcription.png) ![Inactive transcript control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--inactive-transcription.png) ## Reviewer-reproducible examples From a fresh checkout: ```bash pnpm --dir desktop build:e2e pnpm --dir desktop exec playwright test tests/e2e/huddle-transcription.spec.ts --project=smoke pnpm --dir desktop exec playwright test tests/e2e/mentions.spec.ts --project=smoke --grep "system agent profile exposes owned agent actions|system agent avatar exposes owned agent actions|owned bot profile exposes message and huddle actions|owned agent mention profile exposes message and huddle actions" ``` The huddle scenario exercises initial authoritative hydration, exactly one automatic enable, explicit OFF persistence, unchanged state after last-agent removal, newer events winning over delayed hydration, agent-DM enrollment, and idempotent parent membership. It also asserts `aria-pressed` and distinct computed active styling. --------- Signed-off-by: John Tennant Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/huddle/agents.rs | 66 +++- desktop/src-tauri/src/huddle/mod.rs | 303 +++++++----------- desktop/src-tauri/src/huddle/pipeline.rs | 269 +++++++++++++--- desktop/src-tauri/src/huddle/state.rs | 239 +++++++++++++- desktop/src-tauri/src/huddle/transcription.rs | 27 +- .../channels/ui/ChannelMembersBar.tsx | 47 ++- desktop/src/features/huddle/HuddleContext.tsx | 30 +- .../features/huddle/components/HuddleBar.tsx | 12 +- .../profile/ui/UserProfilePopover.tsx | 18 +- .../src/shared/styles/globals/utilities.css | 9 + desktop/src/testing/e2eBridge.ts | 205 ++++++++++-- .../tests/e2e/huddle-transcription.spec.ts | 254 +++++++++++++++ desktop/tests/e2e/mentions.spec.ts | 38 ++- desktop/tests/helpers/bridge.ts | 21 ++ 15 files changed, 1232 insertions(+), 307 deletions(-) create mode 100644 desktop/tests/e2e/huddle-transcription.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index bba9218a1a..2b885e3e53 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -131,6 +131,7 @@ export default defineConfig({ "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", + "**/huddle-transcription.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 02c4045410..2de22f99d8 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -2,7 +2,8 @@ //! //! Mental model: //! add_agent_to_huddle → kind:9000 to ephemeral channel -//! → kind:9000 to parent channel (best-effort) +//! → preserve existing parent membership, or +//! kind:9000 to parent channel (best-effort) //! //! ACP spawning is NOT needed here: the running agent process auto-subscribes //! when it receives the kind:9000 membership notification. Huddle-specific @@ -11,7 +12,10 @@ use serde::Serialize; use uuid::Uuid; -use crate::{app_state::AppState, events, relay::submit_event}; +use crate::{ + app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles, + relay::submit_event, +}; // ── Constants ───────────────────────────────────────────────────────────────── @@ -61,8 +65,9 @@ with the next one. /// The field exists for forward compatibility with future batch-add operations /// where partial success may be meaningful. /// -/// `parent_added` reflects whether the parent-channel add succeeded; -/// `parent_error` carries the error string when it didn't. +/// `parent_added` reflects whether the parent already contained the agent or +/// the parent-channel add succeeded; `parent_error` carries the error string +/// when neither condition could be confirmed. #[derive(Debug, Serialize)] pub struct AgentAddResult { /// Always `true` — invariant guaranteed by [`add_agent_to_huddle`]. @@ -91,17 +96,33 @@ pub async fn add_agent_to_huddle( let add_eph = events::build_add_member(ephemeral_channel_id, agent_pubkey, Some("bot"))?; submit_event(add_eph, state).await?; - // 2. Add agent to parent channel — so agent has full context. - // Best-effort: capture the error but don't propagate it. - let (parent_added, parent_error) = { + // 2. Preserve any active parent membership, regardless of role. Rewriting + // an existing DM member as `bot` is both unnecessary and forbidden for + // non-admins. Otherwise add the agent so it has full context. + // Best-effort: capture a real error but don't propagate it. + let parent_channel_id_string = parent_channel_id.to_string(); + let parent_already_contains_agent = + fetch_channel_members_with_roles(&parent_channel_id_string, state) + .await + .is_ok_and(|members| contains_member(&members, agent_pubkey)); + + let (parent_added, parent_error) = if parent_already_contains_agent { + (true, None) + } else { let add_parent = events::build_add_member(parent_channel_id, agent_pubkey, Some("bot"))?; match submit_event(add_parent, state).await { Ok(_) => (true, None), Err(e) => { - eprintln!( - "buzz-desktop: add agent to parent channel failed (may already be member): {e}" - ); - (false, Some(e)) + let active_after_error = + fetch_channel_members_with_roles(&parent_channel_id_string, state) + .await + .is_ok_and(|members| contains_member(&members, agent_pubkey)); + if active_after_error { + (true, None) + } else { + eprintln!("buzz-desktop: add agent to parent channel failed: {e}"); + (false, Some(e)) + } } } }; @@ -112,3 +133,26 @@ pub async fn add_agent_to_huddle( parent_error, }) } + +fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool { + members + .iter() + .any(|(member_pubkey, _)| member_pubkey.eq_ignore_ascii_case(pubkey)) +} + +#[cfg(test)] +mod tests { + use super::contains_member; + + #[test] + fn existing_parent_membership_is_preserved_regardless_of_role() { + let members = vec![ + ("agent-member".to_owned(), Some("member".to_owned())), + ("agent-bot".to_owned(), Some("bot".to_owned())), + ]; + + assert!(contains_member(&members, "AGENT-MEMBER")); + assert!(contains_member(&members, "agent-bot")); + assert!(!contains_member(&members, "missing")); + } +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index a815bf2d06..0a84cffe00 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -66,13 +66,16 @@ pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; // ── Imports ─────────────────────────────────────────────────────────────────── -use std::sync::{atomic::Ordering, Arc}; +use std::sync::atomic::Ordering; use tauri::State; use uuid::Uuid; use crate::{app_state::AppState, events, relay::submit_event}; - -use pipeline::{maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup}; +pub use pipeline::check_pipeline_hotstart; +use pipeline::{ + maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup, + start_auto_enabled_transcription, PostConnectOutcome, +}; use relay_api::{ count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex, MAX_HUDDLE_AGENTS, @@ -186,7 +189,7 @@ pub async fn start_huddle( }; // Transition to Creating. - { + let huddle_generation = { let mut hs = state.huddle()?; if hs.phase != HuddlePhase::Idle { return Err(format!( @@ -194,9 +197,11 @@ pub async fn start_huddle( hs.phase )); } + let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Creating; hs.parent_channel_id = Some(parent_channel_id.clone()); - } + generation + }; let ephemeral_uuid = Uuid::new_v4(); let ephemeral_channel_id = ephemeral_uuid.to_string(); @@ -259,27 +264,33 @@ pub async fn start_huddle( match result { Ok(successful_agents) => { // 5. Store active state. - { + let committed = { let mut hs = state.huddle()?; - hs.phase = HuddlePhase::Connected; - hs.is_creator = true; - hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); - // Only store agents that were successfully enrolled. - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = - successful_agents.clone(); - // Include the current user + successfully enrolled agents as participants. - // Use successful_agents (not member_pubkeys) so failed enrollments - // are not reflected in the participant list. - let own_pubkey = state - .keys - .lock() - .map(|k| k.public_key().to_hex()) - .unwrap_or_default(); - let mut participants = successful_agents.clone(); - if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) { - participants.insert(0, own_pubkey); + if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { + false + } else { + hs.phase = HuddlePhase::Connected; + hs.is_creator = true; + hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = + successful_agents.clone(); + hs.maybe_auto_enable_transcription_for_agents(); + let own_pubkey = state + .keys + .lock() + .map(|k| k.public_key().to_hex()) + .unwrap_or_default(); + let mut participants = successful_agents.clone(); + if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) { + participants.insert(0, own_pubkey); + } + hs.participants = participants; + true } - hs.participants = participants; + }; + if !committed { + emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; + return Err("huddle start was superseded".to_owned()); } // 6. Notify frontend of state change. @@ -287,16 +298,30 @@ pub async fn start_huddle( // 7. Hydrate members, download models, start pipelines (incl. audio relay). // Audio relay failure is fatal — no point in a huddle without audio. - if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await { - // Rollback: audio relay failed after state was committed. - // Publish the terminal lifecycle event before archiving so - // other clients do not reconstruct a phantom active huddle. - emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; - if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { + Ok(PostConnectOutcome::Ready) => {} + Ok(PostConnectOutcome::Stale) => { + return Err("huddle start was superseded".to_owned()); + } + Err(e) => { + // Roll back only if this failed setup still owns the active + // huddle. A stale failure must not tear down its replacement. + let still_current = state + .huddle() + .map(|hs| hs.is_current_huddle(&ephemeral_channel_id, huddle_generation)) + .unwrap_or(false); + if still_current { + emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state) + .await; + if let Ok(mut hs) = state.huddle_state.lock() { + if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + hs.reset_preserving_generation(); + } + } + state.emit_huddle_state_changed(); + } + return Err(e); } - state.emit_huddle_state_changed(); - return Err(e); } Ok(HuddleJoinInfo { @@ -314,11 +339,11 @@ pub async fn start_huddle( } } } - // Reset state to Idle so the user can retry. - // Preserve session_generation so in-flight transcription tasks - // from a prior session still see a stale generation and exit. + // Reset only if this failed attempt still owns the Creating state. if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { + hs.reset_preserving_generation(); + } } Err(e) } @@ -340,7 +365,7 @@ pub async fn join_huddle( state: State<'_, AppState>, ) -> Result { // Transition to Connecting. - { + let huddle_generation = { let mut hs = state.huddle()?; if hs.phase != HuddlePhase::Idle { return Err(format!( @@ -348,10 +373,12 @@ pub async fn join_huddle( hs.phase )); } + let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Connecting; hs.parent_channel_id = Some(parent_channel_id.clone()); hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); - } + generation + }; // Seed participant list with own pubkey as a fallback until relay responds. let own_pubkey = state @@ -360,12 +387,20 @@ pub async fn join_huddle( .map(|k| k.public_key().to_hex()) .unwrap_or_default(); - { + let committed = { let mut hs = state.huddle()?; - hs.phase = HuddlePhase::Connected; - if !own_pubkey.is_empty() { - hs.participants = vec![own_pubkey]; + if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Connecting) { + false + } else { + hs.phase = HuddlePhase::Connected; + if !own_pubkey.is_empty() { + hs.participants = vec![own_pubkey]; + } + true } + }; + if !committed { + return Err("huddle join was superseded".to_owned()); } // Notify frontend of state change. @@ -373,15 +408,25 @@ pub async fn join_huddle( // Hydrate members, download models, start pipelines (incl. audio relay). // Audio relay failure is fatal — no point in a huddle without audio. - if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await { - // Rollback: audio relay failed after state was committed. - // Reset state to Idle so the user can retry. The ephemeral channel - // has a TTL and will expire — no manual archive needed for joiners. - if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { + Ok(PostConnectOutcome::Ready) => {} + Ok(PostConnectOutcome::Stale) => { + return Err("huddle join was superseded".to_owned()); + } + Err(e) => { + // Reset only the huddle lifetime that failed. + let mut did_reset = false; + if let Ok(mut hs) = state.huddle_state.lock() { + if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + hs.reset_preserving_generation(); + did_reset = true; + } + } + if did_reset { + state.emit_huddle_state_changed(); + } + return Err(e); } - state.emit_huddle_state_changed(); - return Err(e); } Ok(HuddleJoinInfo { @@ -675,123 +720,6 @@ pub fn push_audio_pcm( } } -/// Hot-start: check if voice models just finished downloading during an active -/// huddle and start the corresponding pipelines. -/// -/// Called by the frontend on a timer or after model status changes. No-op if -/// the huddle is not active or pipelines are already running. -#[tauri::command] -pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> { - let (is_active, ephemeral_channel_id) = { - let hs = state.huddle()?; - ( - matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active), - hs.ephemeral_channel_id.clone(), - ) - }; - - if !is_active { - return Ok(()); - } - - // Detect dead pipelines: if the worker thread has exited (init failure or crash), - // clear the pipeline handle so hot-start can retry on the next cycle. - { - let mut hs = state.huddle()?; - if let Some(ref p) = hs.stt_pipeline { - if p.is_finished() { - hs.stt_pipeline = None; - } - } - if let Some(ref p) = hs.tts_pipeline { - if p.is_finished() { - hs.tts_pipeline = None; - } - } - } - // Re-read after potential cleanup. - let (has_stt, has_tts, transcription_enabled) = { - let hs = state.huddle()?; - ( - hs.stt_pipeline.is_some(), - hs.tts_pipeline.is_some(), - hs.transcription_enabled, - ) - }; - - // Check if models just became ready (one-shot flags). - let stt_ready = models::global_model_manager() - .map(|m| m.take_stt_ready()) - .unwrap_or(false); - let tts_ready = models::global_model_manager() - .map(|m| m.take_tts_ready()) - .unwrap_or(false); - - // Start TTS first (so STT can capture tts_cancel). - if !has_tts && (tts_ready || models::is_tts_ready()) { - if let Err(e) = maybe_start_tts_pipeline(&state).await { - eprintln!("buzz-desktop: TTS hotstart failed: {e}"); - } - } - - if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) { - if let Some(eph_id) = &ephemeral_channel_id { - if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await { - eprintln!("buzz-desktop: STT hotstart failed: {e}"); - } - } - } - - // Periodically refresh agent_pubkeys from relay membership. - // This catches mid-huddle agent additions/removals by other participants, - // keeping STT p-tags authoritative throughout the session. - // Throttled to every 15 s (not on every 5 s hotstart poll). - // - // NOTE: The frontend ALSO polls agent membership independently (every 10 s - // via get_huddle_agent_pubkeys). This is intentional — the two polls have - // different failure semantics: - // - Rust (here): preserves stale list on failure (STT p-tags should not - // disappear on a transient network blip). - // - React (HuddleContext.tsx): clears list on failure (TTS authorization - // must fail-closed — never speak from a stale agent list). - // - // On Ok: always replace (even with empty — agents may have been removed). - // On Err: preserve the existing list (transient failure shouldn't zero it). - if let Some(eph_id) = &ephemeral_channel_id { - let should_refresh = { - let hs = state.huddle()?; - match hs.last_agent_refresh { - None => true, - Some(t) => t.elapsed() >= std::time::Duration::from_secs(15), - } - }; - if should_refresh { - // Fetch agents (for STT p-tags) and all members (for participant list). - // Sequential — tokio::join! requires the `macros` feature. - // Only update the throttle timestamp when at least one fetch succeeds, - // so transient failures retry immediately on the next poll cycle. - // Fetch both lists before acquiring the lock — no lock held across await. - let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state) - .await - .ok(); - let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); - - if fresh_agents.is_some() || fresh_members.is_some() { - let mut hs = state.huddle()?; - if let Some(agents) = fresh_agents { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - if let Some(members) = fresh_members { - hs.participants = members; - } - hs.last_agent_refresh = Some(std::time::Instant::now()); - } - } - } - - Ok(()) -} - /// Trigger a background download of voice models (Parakeet STT + Pocket TTS). /// /// Returns immediately — downloads run in tokio background tasks. @@ -924,7 +852,7 @@ pub async fn add_agent_to_huddle( ) -> Result { validate_pubkey_hex(&agent_pubkey)?; - let (eph_id, parent_id) = { + let (eph_id, parent_id, huddle_generation) = { let hs = state.huddle()?; if !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) { return Err("no active huddle".to_string()); @@ -948,7 +876,7 @@ pub async fn add_agent_to_huddle( .clone() .ok_or("no ephemeral channel")?; let parent = hs.parent_channel_id.clone().ok_or("no parent channel")?; - (eph, parent) + (eph, parent, hs.huddle_generation) }; let eph_uuid = Uuid::parse_str(&eph_id).map_err(|e| e.to_string())?; @@ -957,29 +885,30 @@ pub async fn add_agent_to_huddle( // Returns Err only if the ephemeral add fails — parent failure is in the result. let result = agents::add_agent_to_huddle(eph_uuid, parent_uuid, &agent_pubkey, &state).await?; - // Ephemeral add succeeded — safe to register for p-tagging. - // Clone the Arc first so we can drop the outer HuddleState lock before - // acquiring the inner pubkeys lock (avoids the E0597 borrow-checker error). - { - let agent_pubkeys_arc = { - let hs = state.huddle()?; - Arc::clone(&hs.agent_pubkeys) - }; - let mut pubkeys = agent_pubkeys_arc.lock().unwrap_or_else(|e| e.into_inner()); + // Ephemeral add succeeded — register it only if this is still the huddle + // that initiated the relay operation. + let transcription_auto_enabled = { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(&eph_id, huddle_generation) { + return Ok(result); + } + let mut pubkeys = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); if !pubkeys.contains(&agent_pubkey) { pubkeys.push(agent_pubkey.clone()); } - } + drop(pubkeys); + if !hs.participants.contains(&agent_pubkey) { + hs.participants.push(agent_pubkey.clone()); + } + hs.maybe_auto_enable_transcription_for_agents() + }; // No guidelines re-post needed — the agent sees the original kind:48106 // guidelines via EOSE replay when it subscribes to the ephemeral channel. - - // Also add the agent to the visible participants list. - { - let mut hs = state.huddle()?; - if !hs.participants.contains(&agent_pubkey) { - hs.participants.push(agent_pubkey); - } + if transcription_auto_enabled { + start_auto_enabled_transcription(&state, &eph_id).await; + } else { + state.emit_huddle_state_changed(); } Ok(result) diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 6a4cf26201..18b688a971 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -9,6 +9,7 @@ use std::sync::{ }; use nostr::JsonUtil; +use tauri::State; use uuid::Uuid; use crate::app_state::AppState; @@ -20,55 +21,224 @@ use super::state::{HuddlePhase, VoiceInputMode}; use super::stt; use super::tts; +pub(crate) enum PostConnectOutcome { + Ready, + Stale, +} + +/// Hot-start: check if voice models just finished downloading during an active +/// huddle and start the corresponding pipelines. +/// +/// Called by the frontend on a timer or after model status changes. No-op if +/// the huddle is not active or pipelines are already running. +#[tauri::command] +pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> { + let (is_active, ephemeral_channel_id, huddle_generation) = { + let hs = state.huddle()?; + ( + matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active), + hs.ephemeral_channel_id.clone(), + hs.huddle_generation, + ) + }; + + if !is_active { + return Ok(()); + } + + // Detect dead pipelines: if the worker thread has exited (init failure or crash), + // clear the pipeline handle so hot-start can retry on the next cycle. + { + let mut hs = state.huddle()?; + if let Some(ref p) = hs.stt_pipeline { + if p.is_finished() { + hs.stt_pipeline = None; + } + } + if let Some(ref p) = hs.tts_pipeline { + if p.is_finished() { + hs.tts_pipeline = None; + } + } + } + // Re-read after potential cleanup. + let (has_stt, has_tts, transcription_enabled) = { + let hs = state.huddle()?; + ( + hs.stt_pipeline.is_some(), + hs.tts_pipeline.is_some(), + hs.transcription_enabled, + ) + }; + + // Check if models just became ready (one-shot flags). + let stt_ready = models::global_model_manager() + .map(|m| m.take_stt_ready()) + .unwrap_or(false); + let tts_ready = models::global_model_manager() + .map(|m| m.take_tts_ready()) + .unwrap_or(false); + + // Start TTS first (so STT can capture tts_cancel). + if !has_tts && (tts_ready || models::is_tts_ready()) { + if let Err(e) = maybe_start_tts_pipeline(&state).await { + eprintln!("buzz-desktop: TTS hotstart failed: {e}"); + } + } + if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) { + if let Some(eph_id) = &ephemeral_channel_id { + if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await { + eprintln!("buzz-desktop: STT hotstart failed: {e}"); + } + } + } + + // Periodically refresh agent membership from the relay. + // This catches mid-huddle additions/removals by other participants, keeps + // STT p-tags authoritative, and auto-enables transcription when the first + // agent appears unless the user has already chosen a transcription state. + // Throttled independently from the more frequent hotstart poll. + // + // NOTE: The frontend ALSO polls agent membership independently via + // get_huddle_agent_pubkeys. This is intentional — the two polls have + // different failure semantics: + // - Rust (here): preserves stale list on failure (STT p-tags should not + // disappear on a transient network blip). + // - React (HuddleContext.tsx): clears list on failure (TTS authorization + // must fail-closed — never speak from a stale agent list). + // + // On Ok: always replace (even with empty — agents may have been removed). + // On Err: preserve the existing list (transient failure shouldn't zero it). + if let Some(eph_id) = &ephemeral_channel_id { + let should_refresh = { + let hs = state.huddle()?; + match hs.last_agent_refresh { + None => true, + Some(t) => t.elapsed() >= std::time::Duration::from_secs(15), + } + }; + if should_refresh { + // Fetch agents (for STT p-tags) before all members (for participant + // list) so relay membership queries remain ordered. + // Only update the throttle timestamp when at least one fetch succeeds, + // so transient failures retry immediately on the next poll cycle. + // Fetch both lists before acquiring the lock — no lock held across await. + let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state) + .await + .ok(); + let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); + let transcription_auto_enabled = if fresh_agents.is_some() || fresh_members.is_some() { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(eph_id, huddle_generation) { + return Ok(()); + } + if let Some(agents) = fresh_agents { + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + } + if let Some(members) = fresh_members { + hs.participants = members; + } + hs.last_agent_refresh = Some(std::time::Instant::now()); + hs.maybe_auto_enable_transcription_for_agents() + } else { + false + }; + if transcription_auto_enabled { + start_auto_enabled_transcription(&state, eph_id).await; + } + } + } + + Ok(()) +} + pub(crate) async fn post_connect_setup( state: &AppState, ephemeral_channel_id: &str, -) -> Result<(), String> { + huddle_generation: u64, +) -> Result { + { + let hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); + } + } + // Hydrate agent pubkeys and participants from relay in parallel // (authoritative — overrides local guesses). let (agents_result, all_members_result) = tokio::join!( fetch_channel_members(ephemeral_channel_id, Some("bot"), state), fetch_channel_members(ephemeral_channel_id, None, state), ); - if let Ok(agents) = agents_result { - let hs = state.huddle()?; - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - - if let Ok(all_members) = all_members_result { - if !all_members.is_empty() { - let mut hs = state.huddle()?; - hs.participants = all_members; + let transcription_auto_enabled = { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); } + if let Ok(agents) = agents_result { + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + } + if let Ok(all_members) = all_members_result { + if !all_members.is_empty() { + hs.participants = all_members; + } + } + hs.maybe_auto_enable_transcription_for_agents() + }; + + if transcription_auto_enabled { + state.emit_huddle_state_changed(); } - // Prepare TTS for agent voice. STT is transcript-specific and starts only - // when transcription is explicitly enabled. + // Prepare voice models. Agent presence may have auto-enabled transcription; + // explicit user choices remain authoritative. if let Some(mgr) = models::global_model_manager() { mgr.start_tts_download(state.http_client.clone()); + if state.huddle()?.transcription_enabled { + mgr.start_stt_download(state.http_client.clone()); + } } // Connect audio relay WebSocket (Opus encode/decode pipeline). // This is the core audio path — failure is fatal for the huddle. let parent_id = { let hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); + } hs.parent_channel_id.clone() }; - let (cancel, pcm_tx) = - relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await?; + let audio_result = + relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await; { let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + if let Ok((cancel, _)) = audio_result { + cancel.cancel(); + } + return Ok(PostConnectOutcome::Stale); + } + let (cancel, pcm_tx) = audio_result?; hs.audio_ws_cancel = Some(cancel); hs.audio_relay_pcm_tx = Some(pcm_tx); } - // Start TTS immediately. STT/transcript posting is opt-in and starts only - // after the user explicitly enables transcription. + // Start TTS immediately, then STT when transcription is enabled either by + // the user or by authoritative agent membership. + if !state + .huddle()? + .is_current_huddle(ephemeral_channel_id, huddle_generation) + { + return Ok(PostConnectOutcome::Stale); + } if let Err(e) = maybe_start_tts_pipeline(state).await { eprintln!("buzz-desktop: TTS pipeline failed to start: {e}"); } + if let Err(e) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await { + eprintln!("buzz-desktop: STT pipeline failed to start: {e}"); + } - Ok(()) + Ok(PostConnectOutcome::Ready) } /// Attempt to start the STT pipeline if models are present. @@ -83,12 +253,16 @@ pub(crate) async fn maybe_start_stt_pipeline( state: &AppState, ephemeral_channel_id: &str, ) -> Result { - { + let huddle_generation = { let hs = state.huddle()?; - if !hs.transcription_enabled { + if !hs.transcription_enabled + || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + || hs.ephemeral_channel_id.as_deref() != Some(ephemeral_channel_id) + { return Ok(false); } - } + hs.huddle_generation + }; if !models::is_stt_ready() { return Ok(false); // Models not downloaded yet — voice-only mode. @@ -97,21 +271,29 @@ pub(crate) async fn maybe_start_stt_pipeline( let channel_uuid = parse_channel_uuid(ephemeral_channel_id)?; - // Atomically claim the construction slot (mirrors tts_starting pattern). - { - let hs = state.huddle()?; - if hs.stt_starting.swap(true, Ordering::AcqRel) { - return Ok(false); // Another caller is already constructing. - } - } - - // Grab shared flags, agent pubkeys, and session generation from HuddleState. + // Atomically claim construction and grab shared state under one lock. // If replacing an existing pipeline, bump generation first so the old // transcription task's next POST sees a stale generation and exits. // Take the old pipeline OUT of the lock before dropping — Drop joins // the worker thread (~200ms) and must not block under the mutex. - let (tts_active, tts_cancel, agent_pubkeys_arc, session_gen, ptt_active_for_stt, old_stt) = { + let ( + tts_active, + tts_cancel, + agent_pubkeys_arc, + session_gen, + expected_generation, + stt_starting, + ptt_active_for_stt, + old_stt, + ) = { let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(false); + } + if hs.stt_starting.swap(true, Ordering::AcqRel) { + return Ok(false); + } + let stt_starting = Arc::clone(&hs.stt_starting); // Invalidate any existing transcription task before replacing the pipeline. if hs.stt_pipeline.is_some() { hs.session_generation.fetch_add(1, Ordering::Release); @@ -130,6 +312,8 @@ pub(crate) async fn maybe_start_stt_pipeline( Some(Arc::clone(&hs.tts_cancel)), Arc::clone(&hs.agent_pubkeys), Arc::clone(&hs.session_generation), + hs.session_generation.load(Ordering::Acquire), + stt_starting, ptt, old, ) @@ -144,13 +328,11 @@ pub(crate) async fn maybe_start_stt_pipeline( let (pipeline, text_rx) = match constructed { Ok(Ok(p)) => p, Ok(Err(e)) => { - let hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); return Err(e); } Err(e) => { - let hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); return Err(format!("spawn_blocking failed: {e}")); } }; @@ -158,10 +340,14 @@ pub(crate) async fn maybe_start_stt_pipeline( { let mut hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); // Phase check: huddle may have been torn down during construction. if !hs.transcription_enabled - || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + || !hs.is_current_transcription_generation( + ephemeral_channel_id, + huddle_generation, + expected_generation, + ) { return Ok(false); } @@ -172,6 +358,17 @@ pub(crate) async fn maybe_start_stt_pipeline( Ok(true) } +/// Start STT after agent presence automatically enables transcription. +pub(crate) async fn start_auto_enabled_transcription(state: &AppState, ephemeral_channel_id: &str) { + if let Some(manager) = models::global_model_manager() { + manager.start_stt_download(state.http_client.clone()); + } + if let Err(error) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await { + eprintln!("buzz-desktop: auto-enabled STT failed to start: {error}"); + } + state.emit_huddle_state_changed(); +} + /// Attempt to start the TTS pipeline if TTS models are present and TTS is enabled. /// /// Returns `Ok(true)` if the pipeline was started, `Ok(false)` if preconditions diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 876c2d688b..f0a2227ca8 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::sync::{ - atomic::{AtomicBool, AtomicU64}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, }; @@ -80,6 +80,15 @@ pub struct HuddleState { pub tts_enabled: bool, /// Whether STT transcript posting is enabled for this huddle. pub transcription_enabled: bool, + /// Whether the user has explicitly used the transcription control in this + /// huddle. Agent presence may auto-enable transcription only while this is + /// false, so membership refreshes never undo an explicit user choice. + /// + /// This is backend-only session state: keeping it in `HuddleState` makes it + /// survive frontend remounts and audio reconnects, while huddle teardown + /// resets it for the next session. + #[serde(skip)] + pub transcription_user_controlled: bool, /// Shared flag: true while TTS is playing audio. /// Shared with the STT pipeline for barge-in / echo gating. #[serde(skip)] @@ -103,6 +112,10 @@ pub struct HuddleState { /// Used to throttle the refresh in check_pipeline_hotstart to every 15 s. #[serde(skip)] pub last_agent_refresh: Option, + /// Monotonic identity for a local huddle lifetime. Unlike transcript + /// generation, this changes only when a new start/join attempt begins. + #[serde(skip)] + pub huddle_generation: u64, /// Session generation — incremented on every teardown. The transcription /// task captures this at spawn time and checks before each POST. If the /// generation has changed, the task silently drops the transcript. @@ -157,11 +170,13 @@ impl Clone for HuddleState { is_creator: self.is_creator, tts_enabled: self.tts_enabled, transcription_enabled: self.transcription_enabled, + transcription_user_controlled: self.transcription_user_controlled, tts_active: Arc::clone(&self.tts_active), tts_cancel: Arc::clone(&self.tts_cancel), tts_starting: Arc::clone(&self.tts_starting), stt_starting: Arc::clone(&self.stt_starting), last_agent_refresh: self.last_agent_refresh, + huddle_generation: self.huddle_generation, session_generation: Arc::clone(&self.session_generation), voice_input_mode: self.voice_input_mode.clone(), ptt_active: Arc::clone(&self.ptt_active), @@ -184,11 +199,13 @@ impl Default for HuddleState { is_creator: false, tts_enabled: true, transcription_enabled: false, + transcription_user_controlled: false, tts_active: Arc::new(AtomicBool::new(false)), tts_cancel: Arc::new(AtomicBool::new(false)), tts_starting: Arc::new(AtomicBool::new(false)), stt_starting: Arc::new(AtomicBool::new(false)), last_agent_refresh: None, + huddle_generation: 0, session_generation: Arc::new(AtomicU64::new(0)), voice_input_mode: VoiceInputMode::default(), ptt_active: Arc::new(AtomicBool::new(false)), @@ -197,13 +214,233 @@ impl Default for HuddleState { } impl HuddleState { + /// Begin a new local huddle lifetime and return its identity. + pub(crate) fn begin_huddle_lifetime(&mut self) -> u64 { + self.huddle_generation = self.huddle_generation.wrapping_add(1); + self.huddle_generation + } + + pub(crate) fn owns_huddle_lifetime(&self, huddle_generation: u64, phase: HuddlePhase) -> bool { + self.huddle_generation == huddle_generation && self.phase == phase + } + + /// Whether an async result still belongs to the active huddle that + /// initiated it. The channel id is the huddle-session identity; transcript + /// generation changes within the same huddle must not invalidate it. + pub(crate) fn is_current_huddle( + &self, + ephemeral_channel_id: &str, + huddle_generation: u64, + ) -> bool { + matches!(self.phase, HuddlePhase::Connected | HuddlePhase::Active) + && self.ephemeral_channel_id.as_deref() == Some(ephemeral_channel_id) + && self.huddle_generation == huddle_generation + } + + /// Whether an STT construction still belongs to the current transcript + /// generation within the active huddle. + pub(crate) fn is_current_transcription_generation( + &self, + ephemeral_channel_id: &str, + huddle_generation: u64, + session_generation: u64, + ) -> bool { + self.is_current_huddle(ephemeral_channel_id, huddle_generation) + && self.session_generation.load(Ordering::Acquire) == session_generation + } + + /// Invalidate in-flight transcription work and give the next constructor a + /// fresh sentinel that stale constructors cannot clear. + pub(crate) fn invalidate_transcription_pipeline(&mut self) { + self.session_generation.fetch_add(1, Ordering::Release); + self.stt_starting = Arc::new(AtomicBool::new(false)); + } + + /// Record an explicit transcription choice made through the existing user + /// control. Later agent membership refreshes must preserve this choice. + pub(crate) fn set_transcription_enabled_by_user(&mut self, enabled: bool) { + self.transcription_enabled = enabled; + self.transcription_user_controlled = true; + } + + /// Enable transcription when an agent is present and the user has not + /// explicitly chosen a transcription state for this huddle. + /// + /// Returns true only for the transition from disabled to enabled, allowing + /// callers to start models/pipelines and emit state exactly once. Removing + /// the last agent deliberately leaves the current state unchanged. + pub(crate) fn maybe_auto_enable_transcription_for_agents(&mut self) -> bool { + let has_agent = !self + .agent_pubkeys + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_empty(); + if has_agent && !self.transcription_user_controlled && !self.transcription_enabled { + self.transcription_enabled = true; + return true; + } + false + } + /// Reset to default state while preserving the session generation counter. /// Used by start_huddle rollback, join_huddle rollback, and teardown_huddle /// to invalidate in-flight transcription tasks without losing the generation. pub(crate) fn reset_preserving_generation(&mut self) { let gen = Arc::clone(&self.session_generation); + let huddle_generation = self.huddle_generation; *self = Self::default(); self.session_generation = gen; + self.huddle_generation = huddle_generation; + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use super::HuddleState; + + fn set_agents(state: &HuddleState, agents: &[&str]) { + *state + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) = + agents.iter().map(|agent| (*agent).to_owned()).collect(); + } + + #[test] + fn first_agent_auto_enables_transcription_once() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + + assert!(state.maybe_auto_enable_transcription_for_agents()); + assert!(state.transcription_enabled); + assert!(!state.maybe_auto_enable_transcription_for_agents()); + } + + #[test] + fn explicit_user_disable_is_not_undone_by_agent_presence() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + assert!(state.maybe_auto_enable_transcription_for_agents()); + + state.set_transcription_enabled_by_user(false); + + assert!(!state.maybe_auto_enable_transcription_for_agents()); + assert!(!state.transcription_enabled); + } + + #[test] + fn last_agent_leaving_preserves_current_transcription_state() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + assert!(state.maybe_auto_enable_transcription_for_agents()); + + set_agents(&state, &[]); + + assert!(!state.maybe_auto_enable_transcription_for_agents()); + assert!(state.transcription_enabled); + } + + #[test] + fn clone_preserves_user_control_across_frontend_state_reads() { + let mut state = HuddleState::default(); + state.set_transcription_enabled_by_user(false); + + let mut clone = state.clone(); + set_agents(&clone, &["agent"]); + + assert!(clone.transcription_user_controlled); + assert!(!clone.maybe_auto_enable_transcription_for_agents()); + } + + #[test] + fn stale_huddle_identity_is_rejected_after_replacement() { + let mut state = HuddleState { + phase: super::HuddlePhase::Active, + ephemeral_channel_id: Some("huddle-a".to_owned()), + ..HuddleState::default() + }; + let huddle_generation = state.begin_huddle_lifetime(); + let generation = state.session_generation.load(Ordering::Acquire); + assert!(state.is_current_huddle("huddle-a", huddle_generation)); + assert!(state.is_current_transcription_generation( + "huddle-a", + huddle_generation, + generation + )); + + state.session_generation.fetch_add(1, Ordering::Release); + assert!(state.is_current_huddle("huddle-a", huddle_generation)); + assert!(!state.is_current_transcription_generation( + "huddle-a", + huddle_generation, + generation + )); + + state.ephemeral_channel_id = Some("huddle-b".to_owned()); + + assert!(!state.is_current_huddle("huddle-a", huddle_generation)); + } + + #[test] + fn same_channel_rejoin_gets_a_new_huddle_lifetime() { + let mut state = HuddleState { + phase: super::HuddlePhase::Active, + ephemeral_channel_id: Some("huddle".to_owned()), + ..HuddleState::default() + }; + let first_generation = state.begin_huddle_lifetime(); + state.reset_preserving_generation(); + state.phase = super::HuddlePhase::Active; + state.ephemeral_channel_id = Some("huddle".to_owned()); + let second_generation = state.begin_huddle_lifetime(); + + assert_ne!(first_generation, second_generation); + assert!(!state.is_current_huddle("huddle", first_generation)); + assert!(state.is_current_huddle("huddle", second_generation)); + } + + #[test] + fn superseded_create_cannot_commit_or_reset_replacement_lifetime() { + let mut state = HuddleState::default(); + let first_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Creating; + assert!(state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Creating)); + + state.reset_preserving_generation(); + let replacement_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Creating; + + assert!(!state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Creating)); + assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Creating)); + } + + #[test] + fn superseded_join_cannot_commit_replacement_lifetime() { + let mut state = HuddleState::default(); + let first_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Connecting; + + state.reset_preserving_generation(); + let replacement_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Connecting; + + assert!(!state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Connecting)); + assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Connecting)); + } + + #[test] + fn stale_constructor_cannot_clear_replacement_sentinel() { + let mut state = HuddleState::default(); + let stale_sentinel = std::sync::Arc::clone(&state.stt_starting); + stale_sentinel.store(true, Ordering::Release); + + state.invalidate_transcription_pipeline(); + state.stt_starting.store(true, Ordering::Release); + stale_sentinel.store(false, Ordering::Release); + + assert!(state.stt_starting.load(Ordering::Acquire)); } } diff --git a/desktop/src-tauri/src/huddle/transcription.rs b/desktop/src-tauri/src/huddle/transcription.rs index 0d752c1de7..5962f57cf4 100644 --- a/desktop/src-tauri/src/huddle/transcription.rs +++ b/desktop/src-tauri/src/huddle/transcription.rs @@ -1,5 +1,3 @@ -use std::sync::atomic::Ordering; - use tauri::State; use crate::app_state::AppState; @@ -15,10 +13,12 @@ use super::{models, pipeline::maybe_start_stt_pipeline}; pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String> { let ephemeral_channel_id = { let mut hs = state.huddle()?; - hs.transcription_enabled = true; - hs.ephemeral_channel_id + let ephemeral_channel_id = hs + .ephemeral_channel_id .clone() - .ok_or("no active huddle — start or join a huddle first")? + .ok_or("no active huddle — start or join a huddle first")?; + hs.set_transcription_enabled_by_user(true); + ephemeral_channel_id }; match maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await { @@ -41,14 +41,17 @@ pub async fn set_huddle_transcription_enabled( ) -> Result<(), String> { let (ephemeral_channel_id, old_stt) = { let mut hs = state.huddle()?; - hs.transcription_enabled = enabled; + let ephemeral_channel_id = hs + .ephemeral_channel_id + .clone() + .ok_or("no active huddle — start or join a huddle first")?; + hs.set_transcription_enabled_by_user(enabled); if enabled { - (hs.ephemeral_channel_id.clone(), None) + (ephemeral_channel_id, None) } else { - hs.session_generation.fetch_add(1, Ordering::Release); - hs.stt_starting.store(false, Ordering::Release); - (hs.ephemeral_channel_id.clone(), hs.stt_pipeline.take()) + hs.invalidate_transcription_pipeline(); + (ephemeral_channel_id, hs.stt_pipeline.take()) } }; @@ -58,12 +61,10 @@ pub async fn set_huddle_transcription_enabled( drop(old_stt); if enabled { - let eph_id = - ephemeral_channel_id.ok_or("no active huddle — start or join a huddle first")?; if let Some(manager) = models::global_model_manager() { manager.start_stt_download(state.http_client.clone()); } - if let Err(e) = maybe_start_stt_pipeline(&state, &eph_id).await { + if let Err(e) = maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await { eprintln!("buzz-desktop: STT transcript start failed: {e}"); } } diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c87617ce9b..7b9bf2b79f 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -11,9 +11,15 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { + getDmHuddleMemberPubkeys, + hasOtherDmParticipant, +} from "@/features/channels/lib/dmHuddleMembers"; import { canStartHuddleInChannel } from "@/features/channels/lib/huddleAvailability"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { Channel } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -64,6 +70,41 @@ export function ChannelMembersBar({ const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); const members = membersQuery.data ?? []; + const dmProfilesQuery = useUsersBatchQuery( + channel.channelType === "dm" ? channel.participantPubkeys : [], + { enabled: channel.channelType === "dm" }, + ); + const huddleAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set( + mergeChannelKnownAgentPubkeys( + membersQuery.data, + managedAgentsQuery.data, + relayAgentsQuery.data, + ), + ); + for (const [pubkey, profile] of Object.entries( + dmProfilesQuery.data?.profiles ?? {}, + )) { + if (profile.isAgent) pubkeys.add(normalizePubkey(pubkey)); + } + return pubkeys; + }, [ + dmProfilesQuery.data?.profiles, + managedAgentsQuery.data, + membersQuery.data, + relayAgentsQuery.data, + ]); + const huddleMemberPubkeys = React.useMemo( + () => getDmHuddleMemberPubkeys(channel, huddleAgentPubkeys, currentPubkey), + [channel, currentPubkey, huddleAgentPubkeys], + ); + const huddleMemberPubkeysPending = + hasOtherDmParticipant(channel, currentPubkey) && + (membersQuery.isPending || + managedAgentsQuery.isPending || + relayAgentsQuery.isPending || + dmProfilesQuery.isPending || + dmProfilesQuery.isPlaceholderData); const memberCount = membersQuery.data?.length ?? channel.memberCount; const providers = React.useMemo( () => @@ -117,7 +158,7 @@ export function ChannelMembersBar({ try { await startHuddle( channel.id, - [], + [...huddleMemberPubkeys], buildHuddleChannelName({ channel, currentPubkey, @@ -133,7 +174,9 @@ export function ChannelMembersBar({ } }} renderMode={variant === "compact" ? "menu-item" : "button"} - startDisabled={!canStartHuddle || isStartingHuddle} + startDisabled={ + !canStartHuddle || isStartingHuddle || huddleMemberPubkeysPending + } /> ); diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index 804eb4bc0e..03620838a3 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -339,6 +339,28 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { [], ); + /** + * Clean up only this provider's media after its start token is superseded. + * The action that changed the token owns backend teardown; issuing a global + * leave here could terminate a replacement huddle started by a new provider. + */ + const cleanupSupersededStart = React.useCallback( + (worklet: AudioWorkletHandle | null) => { + try { + worklet?.stop(); + } catch { + /* best-effort */ + } + workletRef.current = null; + rustActiveRef.current = false; + setLocalAudioTrack(null); + setMicConnected(false); + setEphemeralChannelId(null); + setActiveSpeakers([]); + }, + [], + ); + /** Shared media setup: get mic, setup AudioWorklet, confirm active. * Used by both startHuddle and joinHuddle after the Rust backend call succeeds. */ const connectAndSetupMedia = React.useCallback( @@ -443,7 +465,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { await connectAndSetupMedia(joinInfo, myToken); } catch (e) { if (e instanceof Error && e.message === "superseded") { - await cleanupFailedStart(workletRef.current, true); + cleanupSupersededStart(workletRef.current); return; } throw e; @@ -466,7 +488,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { busyRef.current = false; } }, - [cleanupFailedStart, connectAndSetupMedia], + [cleanupFailedStart, cleanupSupersededStart, connectAndSetupMedia], ); const joinHuddle = React.useCallback( @@ -489,7 +511,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { await connectAndSetupMedia(joinInfo, myToken); } catch (e) { if (e instanceof Error && e.message === "superseded") { - await cleanupFailedStart(workletRef.current, false); + cleanupSupersededStart(workletRef.current); return; } throw e; @@ -512,7 +534,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { busyRef.current = false; } }, - [cleanupFailedStart, connectAndSetupMedia], + [cleanupFailedStart, cleanupSupersededStart, connectAndSetupMedia], ); useTtsSubscription(ephemeralChannelId, selfPubkeyRef); diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 35d660e475..758726cf58 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -242,7 +242,10 @@ export function HuddleBar({ // Primary: listen for Rust-emitted state change events listen("huddle-state-changed", (event) => { - if (!cancelled) applyIncomingState(event.payload); + if (!cancelled) { + stateGenerationRef.current += 1; + applyIncomingState(event.payload); + } }).then((fn) => { if (cancelled) fn(); else unlisten = fn; @@ -757,14 +760,11 @@ export function HuddleBar({ transcriptionEnabled ? "Stop transcript" : "Start transcript" } aria-pressed={transcriptionEnabled} - className={cn( - "buzz-huddle-control-button h-12 w-12 shrink-0 rounded-md", - transcriptionEnabled && "text-foreground", - )} + className="buzz-huddle-control-button h-12 w-12 shrink-0 rounded-md" onClick={() => void handleToggleTranscript()} size="icon" type="button" - variant={transcriptionEnabled ? "secondary" : "ghost"} + variant="ghost" > diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index f2739088a6..da51ab1b88 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -260,11 +260,18 @@ export function UserProfilePopover({ const selfProfileQuery = useProfileQuery(open && showProfileActions); const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey); const viewerIsOwner = isCurrentUserOwner || isOwner === true; + const showHuddleAction = + showHumanProfileActions || + (showProfileActions && + isBotProfile && + viewerIsOwner && + !isAgentClassificationPending); const showMessageAction = showProfileActions && !isAgentClassificationPending && (!isBotProfile || viewerIsOwner); - const showAnyProfileActions = showHumanProfileActions || showMessageAction; + const showAnyProfileActions = + showHumanProfileActions || showMessageAction || showHuddleAction; const canViewActivity = isBotProfile && viewerIsOwner && canOpenAgentActivity(pubkey); const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; @@ -356,7 +363,7 @@ export function UserProfilePopover({ const handleHuddle = React.useCallback(async () => { if ( !showProfileActions || - !showHumanProfileActions || + !showHuddleAction || pendingAction !== null || isStartingHuddle ) { @@ -369,7 +376,7 @@ export function UserProfilePopover({ try { const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] }); await goChannel(dm.id); - await startHuddle(dm.id, []); + await startHuddle(dm.id, isBotProfile ? [pubkey] : []); await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); if (isMountedRef.current) { setOpen(false); @@ -389,7 +396,8 @@ export function UserProfilePopover({ pendingAction, pubkey, queryClient, - showHumanProfileActions, + isBotProfile, + showHuddleAction, showProfileActions, startHuddle, ]); @@ -722,7 +730,7 @@ export function UserProfilePopover({ Message ) : null} - {showHumanProfileActions ? ( + {showHuddleAction ? ( + + + { + if (settings) void savePocketVoice(voiceKey); + }} + value={selectedVoice?.key} + > + {voices.map((voice) => ( + + {voiceOptionLabel(voice, voices)} + + ))} + + + + + + + + + + {error && ( +

+ {error} +

+ )} + +
+ ); +} diff --git a/desktop/src/features/settings/ui/voiceSettingsLogic.test.mjs b/desktop/src/features/settings/ui/voiceSettingsLogic.test.mjs new file mode 100644 index 0000000000..8ffc918471 --- /dev/null +++ b/desktop/src/features/settings/ui/voiceSettingsLogic.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + selectedVoiceForBackend, + voiceOptionLabel, + voicesForBackend, +} from "./voiceSettingsLogic.ts"; + +const voice = (key, displayName, fallbackKey = "pocket:mary") => ({ + key, + displayName, + backend: "pocket", + backendName: "Pocket TTS", + availability: "bundled", + fallbackKey, + referenceFile: `${key}.wav`, + provenance: { + source: "bundled", + contentHash: null, + license: null, + sourceUrl: null, + }, +}); + +test("Pocket-only V1 filters the shared registry by backend", () => { + const registry = [ + voice("pocket:mary", "Mary", null), + { ...voice("siri:aaron", "Aaron"), backend: "siri" }, + ]; + assert.deepEqual( + voicesForBackend(registry, "pocket").map((entry) => entry.key), + ["pocket:mary"], + ); +}); + +test("local selection uses the first compatible qualified preference", () => { + const voices = [ + voice("pocket:mary", "Mary", null), + voice("pocket:eve", "Eve"), + ]; + assert.equal( + selectedVoiceForBackend(["siri:aaron", "pocket:eve", "pocket:mary"], voices) + ?.key, + "pocket:eve", + ); +}); + +test("duplicate display labels remain distinct by content-derived key", () => { + const voices = [ + voice("pocket:imported:aaa", "Jim"), + voice("pocket:imported:bbb", "Jim"), + ]; + assert.equal( + selectedVoiceForBackend(["pocket:imported:bbb"], voices)?.key, + "pocket:imported:bbb", + ); + assert.equal(voiceOptionLabel(voices[0], voices), "Jim · aaa"); + assert.equal(voiceOptionLabel(voices[1], voices), "Jim · bbb"); +}); diff --git a/desktop/src/features/settings/ui/voiceSettingsLogic.ts b/desktop/src/features/settings/ui/voiceSettingsLogic.ts new file mode 100644 index 0000000000..30352f2d0f --- /dev/null +++ b/desktop/src/features/settings/ui/voiceSettingsLogic.ts @@ -0,0 +1,57 @@ +export type VoiceAvailability = + | "bundled" + | "installed" + | "downloadable" + | "unavailable"; + +export type VoiceRegistryEntry = { + key: string; + displayName: string; + backend: string; + backendName: string; + availability: VoiceAvailability; + fallbackKey: string | null; + referenceFile: string | null; + provenance: { + source: string; + contentHash: string | null; + license: string | null; + sourceUrl: string | null; + }; +}; + +export function voicesForBackend( + registry: readonly VoiceRegistryEntry[], + backend: string, +): VoiceRegistryEntry[] { + return registry.filter( + (voice) => + voice.backend === backend && + (voice.availability === "bundled" || voice.availability === "installed"), + ); +} + +export function selectedVoiceForBackend( + preferences: readonly string[], + voices: readonly VoiceRegistryEntry[], +): VoiceRegistryEntry | undefined { + for (const key of preferences) { + const voice = voices.find((candidate) => candidate.key === key); + if (voice) return voice; + } + return voices.find((voice) => voice.fallbackKey === null) ?? voices[0]; +} + +export function voiceOptionLabel( + voice: VoiceRegistryEntry, + voices: readonly VoiceRegistryEntry[], +): string { + const duplicateLabel = voices.some( + (candidate) => + candidate.key !== voice.key && + candidate.displayName === voice.displayName, + ); + if (!duplicateLabel) return voice.displayName; + const identitySuffix = voice.key.split(":").at(-1)?.slice(-8) ?? voice.key; + return `${voice.displayName} · ${identitySuffix}`; +} diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts index c7446f4e70..121b00c800 100644 --- a/desktop/src/shared/api/readOnlyRelayClient.ts +++ b/desktop/src/shared/api/readOnlyRelayClient.ts @@ -12,7 +12,7 @@ import { AUTH_TIMEOUT_MS, HISTORY_TIMEOUT_MS, PUBLISH_TIMEOUT_MS, -} from "@/shared/api/relayClientSession"; +} from "@/shared/api/relayClientTimings"; type PendingHistory = { events: RelayEvent[]; diff --git a/desktop/src/shared/api/relayChannelFilters.test.mjs b/desktop/src/shared/api/relayChannelFilters.test.mjs index 3519c82146..503f1a5d58 100644 --- a/desktop/src/shared/api/relayChannelFilters.test.mjs +++ b/desktop/src/shared/api/relayChannelFilters.test.mjs @@ -6,6 +6,7 @@ import { buildChannelAuxFilter, buildChannelReactionAuxFilter, buildChannelStructuralAuxFilter, + buildHuddleTtsLiveFilter, } from "./relayChannelFilters.ts"; const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; @@ -14,6 +15,14 @@ const IDS = [ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ]; +test("huddle TTS filter is future-only for both message kinds", () => { + assert.deepEqual(buildHuddleTtsLiveFilter(CHANNEL), { + kinds: [9, 40002], + "#h": [CHANNEL], + limit: 0, + }); +}); + // Regression: reaction (kind:7) and reaction-removal (kind:5) events carry only // an `e` tag, no channel `h` tag. An `#h`-scoped aux query never matches them, // so removed historical reactions reappear. The aux filters must key on `#e` diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts index d0c7e7938e..0d432d422e 100644 --- a/desktop/src/shared/api/relayChannelFilters.ts +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -6,6 +6,8 @@ import { KIND_DELETION, KIND_NIP29_DELETE_EVENT, KIND_REACTION, + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_EDIT, } from "@/shared/constants/kinds"; import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; @@ -40,6 +42,17 @@ export function buildChannelFilter( return filter; } +/** Strictly live huddle message filter: zero stored rows, future messages only. */ +export function buildHuddleTtsLiveFilter( + channelId: string, +): RelaySubscriptionFilter { + return { + kinds: [KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2], + "#h": [channelId], + limit: 0, + }; +} + /** * History filter for cold-load and scrollback: message kinds *only*, so the * `limit` budget buys visible message depth. Auxiliary events (reactions, diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 94438386eb..53d541ff0f 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -53,20 +53,19 @@ import { } from "@/shared/api/relayReconnectPolicy"; import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters"; import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog"; +import { + AUTH_TIMEOUT_MS, + BACKOFF_RESET_STABLE_MS, + EVENT_BATCH_MS, + HISTORY_TIMEOUT_MS, + PUBLISH_TIMEOUT_MS, + RECONNECT_BASE_DELAY_MS, + RECONNECT_MAX_DELAY_MS, + STALL_CHECK_INTERVAL_MS, + STALL_IDLE_TIMEOUT_MS, +} from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; -const RECONNECT_BASE_DELAY_MS = 1_000, - RECONNECT_MAX_DELAY_MS = 30_000, - EVENT_BATCH_MS = 16; - -export const AUTH_TIMEOUT_MS = 25_000; -export const HISTORY_TIMEOUT_MS = 25_000; -export const PUBLISH_TIMEOUT_MS = 25_000; - -export const BACKOFF_RESET_STABLE_MS = 60_000; - -const STALL_CHECK_INTERVAL_MS = 10_000; -const STALL_IDLE_TIMEOUT_MS = 60_000; export class RelayClient { private wsId: number | null = null; diff --git a/desktop/src/shared/api/relayClientTimings.ts b/desktop/src/shared/api/relayClientTimings.ts new file mode 100644 index 0000000000..dbe85a835c --- /dev/null +++ b/desktop/src/shared/api/relayClientTimings.ts @@ -0,0 +1,20 @@ +export const RECONNECT_BASE_DELAY_MS = 1_000; +export const RECONNECT_MAX_DELAY_MS = 30_000; +export const EVENT_BATCH_MS = 16; + +/** + * Op-level timeouts tolerate degraded networks where TLS handshakes and DNS + * resolution can take several seconds. + */ +export const AUTH_TIMEOUT_MS = 25_000; +export const HISTORY_TIMEOUT_MS = 25_000; +export const PUBLISH_TIMEOUT_MS = 25_000; + +/** + * A stability-gated reset prevents reconnect flapping from erasing backoff. + */ +export const BACKOFF_RESET_STABLE_MS = 60_000; + +/** Passive liveness thresholds for the relay heartbeat stream. */ +export const STALL_CHECK_INTERVAL_MS = 10_000; +export const STALL_IDLE_TIMEOUT_MS = 60_000; diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 54254e89de..59253a459f 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -5,6 +5,7 @@ import { buildReconnectReplayFilter, replayLiveSubscriptions, REPLAY_BATCH_SIZE, + shouldPageReconnectReplay, } from "./relayReconnectReplay.ts"; import { buildChannelFilter } from "./relayChannelFilters.ts"; @@ -113,6 +114,31 @@ test("reconnect replay caps large steady-state limits", () => { }); }); +test("reconnect replay preserves the live-only zero-history contract", () => { + const filter = { + kinds: [9], + "#h": ["channel-1"], + limit: 0, + }; + + assert.deepEqual(replayFilter(filter, 123), { + kinds: [9], + "#h": ["channel-1"], + limit: 0, + since: 123, + }); +}); + +test("live-only subscriptions do not page reconnect history", () => { + const filter = { + kinds: [9], + "#h": ["channel-1"], + limit: 0, + }; + + assert.equal(shouldPageReconnectReplay(filter), false); +}); + test("reconnect replay keeps the stricter existing since window", () => { const filter = { kinds: [9], diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index dcad430bbf..97051b68ec 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -161,6 +161,11 @@ type MockHuddleSeed = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { + ttsSettings?: { + version: number; + agentTextToSpeech: boolean; + voicePreferences: string[]; + }; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ @@ -9954,6 +9959,161 @@ export function maybeInstallE2eTauriMocks() { } case "get_model_status": return { stt: "ready", tts: "ready" }; + case "get_tts_settings": + return ( + activeConfig?.mock?.ttsSettings ?? { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:mary"], + } + ); + case "list_voice_registry": + return [ + [ + "anna", + "Anna", + "anna.wav", + "p228_023_enhanced.wav", + "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856", + ], + [ + "vera", + "Vera", + "vera.wav", + "p229_023_enhanced.wav", + "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b", + ], + [ + "fantine", + "Fantine", + "fantine.wav", + "p244_023_enhanced.wav", + "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b", + ], + [ + "charles", + "Charles", + "charles.wav", + "p254_023_enhanced.wav", + "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756", + ], + [ + "paul", + "Paul", + "paul.wav", + "p259_023_enhanced.wav", + "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b", + ], + [ + "eponine", + "Eponine", + "eponine.wav", + "p262_023_enhanced.wav", + "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b", + ], + [ + "azelma", + "Azelma", + "azelma.wav", + "p303_023_enhanced.wav", + "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026", + ], + [ + "george", + "George", + "george.wav", + "p315_023_enhanced.wav", + "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae", + ], + [ + "mary", + "Mary", + "reference_sample.wav", + "p333_023_enhanced.wav", + "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + ], + [ + "jane", + "Jane", + "jane.wav", + "p339_023_enhanced.wav", + "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a", + ], + [ + "michael", + "Michael", + "michael.wav", + "p360_023_enhanced.wav", + "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad", + ], + [ + "eve", + "Eve", + "eve.wav", + "p361_023_enhanced.wav", + "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd", + ], + ].map( + ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({ + key: `pocket:${id}`, + displayName, + backend: "pocket", + backendName: "Pocket TTS", + availability: "bundled", + fallbackKey: id === "mary" ? null : "pocket:mary", + referenceFile, + provenance: { + source: "bundled", + contentHash, + license: "CC-BY-4.0", + sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`, + }, + }), + ); + case "set_tts_enabled": { + const enabled = (payload as { enabled?: boolean })?.enabled; + if (typeof enabled !== "boolean") + throw new Error("Missing text-to-speech enabled state"); + const settings = { + version: 1, + agentTextToSpeech: enabled, + voicePreferences: activeConfig?.mock?.ttsSettings + ?.voicePreferences ?? ["pocket:mary"], + }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.ttsSettings = settings; + } + return settings; + } + case "set_pocket_voice": { + const voiceKey = (payload as { voiceKey?: string })?.voiceKey; + if (!voiceKey) throw new Error("Missing Pocket voice key"); + const current = activeConfig?.mock?.ttsSettings ?? { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:mary"], + }; + const firstPocketIndex = current.voicePreferences.findIndex((key) => + key.startsWith("pocket:"), + ); + const preferences = current.voicePreferences.filter( + (key) => !key.startsWith("pocket:"), + ); + preferences.splice( + firstPocketIndex < 0 ? preferences.length : firstPocketIndex, + 0, + voiceKey, + ); + const settings = { ...current, voicePreferences: preferences }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.ttsSettings = settings; + } + return settings; + } + case "preview_pocket_voice": + return null; case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": { diff --git a/desktop/tests/e2e/voice-settings.spec.ts b/desktop/tests/e2e/voice-settings.spec.ts new file mode 100644 index 0000000000..490bee3424 --- /dev/null +++ b/desktop/tests/e2e/voice-settings.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const SCREENSHOT_PATH = "test-results/voice-settings/pocket-voices.png"; + +test.describe("Pocket voice settings", () => { + test.use({ viewport: { width: 1100, height: 760 } }); + + test("selects and retains a bundled voice while text to speech is off", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + const card = page.getByTestId("settings-voice"); + await expect(card).toBeVisible(); + await expect( + page.getByText("Agent text to speech", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Pocket TTS voice", { exact: true }), + ).toBeVisible(); + await expect(card).not.toContainText("April INT8"); + + await page.getByTestId("pocket-voice-selector").click(); + await expect(page.getByRole("menuitemradio")).toHaveCount(12); + await page.getByRole("menuitemradio", { name: "Eve" }).click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Eve", + ); + await expect( + page.getByRole("button", { name: "Pocket TTS voice: Eve" }), + ).toBeVisible(); + + await page.getByTestId("agent-text-to-speech-toggle").click(); + await expect( + page.getByTestId("agent-text-to-speech-toggle"), + ).toHaveAttribute("aria-checked", "false"); + await expect(page.getByTestId("pocket-voice-controls")).toHaveAttribute( + "aria-disabled", + "true", + ); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Eve", + ); + + const savedCommands = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter((entry) => + ["set_pocket_voice", "set_tts_enabled"].includes(entry.command), + ) + .map((entry) => ({ command: entry.command, payload: entry.payload })), + ); + expect(savedCommands).toEqual([ + { + command: "set_pocket_voice", + payload: { voiceKey: "pocket:eve" }, + }, + { + command: "set_tts_enabled", + payload: { enabled: false }, + }, + ]); + }); + + test("captures the complete VCTK preset settings surface", async ({ + page, + }) => { + await installMockBridge(page, { + ttsSettings: { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:eve"], + }, + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + const card = page.getByTestId("settings-voice"); + await expect(card).toBeVisible(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Eve", + ); + await page.getByTestId("pocket-voice-selector").click(); + await expect(page.getByRole("menuitemradio")).toHaveCount(12); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + await waitForAnimations(page); + const cardBox = await card.boundingBox(); + const menuBox = await menu.boundingBox(); + const viewport = page.viewportSize(); + if (!cardBox || !menuBox || !viewport) { + throw new Error("Voice settings screenshot bounds are unavailable"); + } + const x = Math.max(0, Math.min(cardBox.x, menuBox.x) - 16); + const y = Math.max(0, Math.min(cardBox.y, menuBox.y) - 16); + const right = Math.min( + viewport.width, + Math.max(cardBox.x + cardBox.width, menuBox.x + menuBox.width) + 16, + ); + const bottom = Math.min( + viewport.height, + Math.max(cardBox.y + cardBox.height, menuBox.y + menuBox.height) + 16, + ); + await page.screenshot({ + path: SCREENSHOT_PATH, + clip: { + x: Math.floor(x), + y: Math.floor(y), + width: Math.ceil(right - x), + height: Math.ceil(bottom - y), + }, + }); + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 49d12ec17a..2d41d27a5e 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -159,6 +159,11 @@ type MockInstallRuntimeResult = { }; type MockBridgeOptions = { + ttsSettings?: { + version: number; + agentTextToSpeech: boolean; + voicePreferences: string[]; + }; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Relay NIP-11 identity used to sign authoritative repository state. */ diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts index a63b52453e..c26c0e9195 100644 --- a/desktop/tests/helpers/settings.ts +++ b/desktop/tests/helpers/settings.ts @@ -3,6 +3,7 @@ import { expect, type Page } from "@playwright/test"; type SettingsSection = | "profile" | "notifications" + | "voice" | "agents" | "channel-templates" | "compute" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 0a3b2ed5a2..2e7e944482 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -84,6 +84,9 @@ run_unit_tests() { run_test_step "buzz-auth unit tests" \ cargo test -p buzz-auth --lib -- --nocapture + run_test_step "buzz-voice tests" \ + cargo test -p buzz-voice --lib -- --nocapture + run_test_step "buzz-cli tests" \ cargo test -p buzz-cli -- --nocapture From 39ce3dfc3cf2d12f0d6c64b4cd4293df86567663 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 31 Jul 2026 15:24:51 +0100 Subject: [PATCH 94/99] fix(desktop): open profiles from avatars (#3751) ## Summary - show profile descriptions in hover cards as a single truncated line - open the profile panel when avatars are clicked across desktop surfaces - make the direct-message intro avatar clickable ## Validation - Desktop static checks - 3,807 desktop tests via pre-push --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- desktop/scripts/check-pubkey-truncation.mjs | 6 +- desktop/src/app/AppHuddleBar.tsx | 25 ++ desktop/src/app/AppProfilePanelProvider.tsx | 22 + desktop/src/app/AppShell.tsx | 412 +++++++++--------- .../src/app/navigation/useAppNavigation.ts | 13 + .../channels/ui/ChannelScreenHeader.tsx | 64 ++- .../ui/CommunityMembersSettingsCard.tsx | 17 +- .../huddle/components/ParticipantList.tsx | 33 +- .../ui/DirectMessageIntroAvatarStack.tsx | 44 +- .../e2e/invites-settings-screenshots.spec.ts | 11 +- 10 files changed, 380 insertions(+), 267 deletions(-) create mode 100644 desktop/src/app/AppHuddleBar.tsx create mode 100644 desktop/src/app/AppProfilePanelProvider.tsx diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index 95e56fb282..d65db13545 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -18,12 +18,10 @@ const rules = [ // Non-display uses: array windows over pubkey lists, color/initials // derivation where the value is never presented as an identity. const overrides = new Set([ - // ProfileAvatar fallback label — decorative glyphs inside an avatar disc. - "src/features/huddle/components/ParticipantList.tsx:92", // HexAvatar: 6-char badge + hue derivation inside a color-coded disc, // clearly decorative (paired with a full truncatePubkey aria-label). - "src/features/huddle/components/ParticipantList.tsx:143", - "src/features/huddle/components/ParticipantList.tsx:144", + "src/features/huddle/components/ParticipantList.tsx:150", + "src/features/huddle/components/ParticipantList.tsx:151", // clientId (not a pubkey) sliced in a debug log next to the real thing. "src/features/channels/readState/readStateManager.ts:338", // Array windows (first N pubkeys), not string truncation. diff --git a/desktop/src/app/AppHuddleBar.tsx b/desktop/src/app/AppHuddleBar.tsx new file mode 100644 index 0000000000..9fa12d513f --- /dev/null +++ b/desktop/src/app/AppHuddleBar.tsx @@ -0,0 +1,25 @@ +import type * as React from "react"; + +import { HuddleBar } from "@/features/huddle"; + +import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; + +type AppHuddleBarProps = Pick< + React.ComponentProps, + "onOpenThread" | "onVisibilityChange" +>; + +export function AppHuddleBar({ + onOpenThread, + onVisibilityChange, +}: AppHuddleBarProps) { + return ( + + + + ); +} diff --git a/desktop/src/app/AppProfilePanelProvider.tsx b/desktop/src/app/AppProfilePanelProvider.tsx new file mode 100644 index 0000000000..213acec498 --- /dev/null +++ b/desktop/src/app/AppProfilePanelProvider.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; + +export function AppProfilePanelProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { goProfile } = useAppNavigation(); + const handleOpenProfilePanel = React.useCallback( + (pubkey: string) => { + void goProfile(pubkey); + }, + [goProfile], + ); + + return ( + + {children} + + ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b856434e61..4eb0a42bbe 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -63,7 +63,8 @@ import { type SettingsSection, isSettingsSection, } from "@/features/settings/ui/SettingsPanels"; -import { HuddleBar, HuddleProvider } from "@/features/huddle"; +import { HuddleProvider } from "@/features/huddle"; +import { AppHuddleBar } from "@/app/AppHuddleBar"; import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; @@ -97,7 +98,7 @@ import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; - +import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -160,7 +161,6 @@ export function AppShell() { ? locationSearchSection : DEFAULT_SETTINGS_SECTION; const startupReady = useDeferredStartup(); - const identityQuery = useIdentityQuery(); const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( identityQuery.data?.pubkey, @@ -303,7 +303,6 @@ export function AppShell() { ? (channels.find((channel) => channel.id === targetChannelId) ?? null) : null; }, [channels, managedChannelId, selectedChannelId]); - const { handleChannelNotification, handleDmNotification, @@ -518,7 +517,6 @@ export function AppShell() { }, [applyAgents, applyCanvas, createChannelMutation, goChannel], ); - const handleCreateForum = React.useCallback( async ({ description, @@ -586,7 +584,6 @@ export function AppShell() { }, [goHome, hideDmMutation, selectedChannelId], ); - const handleOpenSettings = React.useCallback( (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { setIsChannelManagementOpen(false); @@ -594,12 +591,10 @@ export function AppShell() { }, [goSettings], ); - const handleCloseSettings = React.useCallback( () => closeSettings(), [closeSettings], ); - // Section switches rewrite the settings entry rather than stacking one // history entry per section, so back always exits settings in one step. const handleSettingsSectionChange = React.useCallback( @@ -620,11 +615,8 @@ export function AppShell() { unreadChannelIds, unreadChannelNotificationCount, }); + // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); - const handleOpenNewDm = React.useCallback( - () => void goNewMessage(), - [goNewMessage], - ); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], @@ -657,7 +649,7 @@ export function AppShell() { if (key === "k" && event.shiftKey) { event.preventDefault(); - handleOpenNewDm(); + void goNewMessage(); return; } @@ -686,9 +678,9 @@ export function AppShell() { }; }, [ handleOpenBrowseChannels, - handleOpenNewDm, handleOpenCreateChannel, handleOpenSearch, + goNewMessage, goHome, settingsOpen, ]); @@ -770,216 +762,224 @@ export function AppShell() { /> ) : null} - {!settingsOpen ? ( - - ) : null} - {settingsOpen ? ( -
- - + {!settingsOpen ? ( + + ) : null} + {settingsOpen ? ( +
+ + + +
+ ) : ( +
+ { + const id = communitiesHook.addCommunity({ + ...community, + pubkey: + community.pubkey ?? + identityQuery.data?.pubkey, + }); + handleSwitchCommunity(id); + }} + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange } - notificationSettings={notificationSettings.settings} - onClose={handleCloseSettings} - onSectionChange={handleSettingsSectionChange} - onSetDesktopNotificationsEnabled={ - notificationSettings.setDesktopEnabled + onNewMessage={goNewMessage} + onBackgroundClick={requestFocusedThreadClose} + onCreateChannelOpenChange={setIsCreateChannelOpen} + onOpenAddCommunity={addCommunityDialog.openDialog} + onSendFeedback={() => setIsSendFeedbackOpen(true)} + onUpdateCommunity={communitiesHook.updateCommunity} + onRemoveCommunity={(id) => + void handleRemoveCommunity(id) } - onSetHomeBadgeEnabled={ - notificationSettings.setHomeBadgeEnabled + onSwitchCommunity={handleSwitchCommunity} + onCreateAgent={() => requestOpenCreateAgent()} + selfPresenceStatus={presenceSession.currentStatus} + communities={communitiesHook.communities} + onCreateChannel={handleCreateChannel} + onCreateForum={handleCreateForum} + onHideDm={handleHideDm} + onMarkAllChannelsRead={markAllChannelsRead} + onMarkChannelRead={markChannelRead} + onMarkChannelUnread={markChannelUnread} + onBrowseChannels={handleOpenBrowseChannels} + onOpenDm={async ({ pubkeys }) => { + const directMessage = + await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onSelectAgents={() => void goAgents()} + onSelectChannel={(channelId) => + void goChannel(channelId) } - onSetSlotAlertsEnabled={ - notificationSettings.setSlotAlertsEnabled + onOpenSearchResult={handleOpenSearchResult} + searchChannels={channels} + searchFocusRequest={searchFocusRequest} + onSelectHome={() => void goHome()} + onSelectProjects={() => void goProjects()} + onSelectPulse={() => void goPulse()} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => void goWorkflows()} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) } - onSetNotifyWhileViewing={ - notificationSettings.setNotifyWhileViewing + onSetUserStatus={(text, emoji) => + setUserStatusMutation.mutate({ text, emoji }) } - onSetAllSlotAlertsEnabled={ - notificationSettings.setAllSlotAlertsEnabled + onClearUserStatus={() => + setUserStatusMutation.mutate({ + text: "", + emoji: "", + }) } - onSetSoundForSlot={ - notificationSettings.setSoundForSlot + profile={profileQuery.data} + selfUserStatus={ + deferredPubkey + ? (selfStatusQuery.data?.[ + deferredPubkey.toLowerCase() + ] ?? undefined) + : undefined } - section={settingsSection} + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + unreadChannelCounts={unreadChannelCounts} + mutedChannelIds={mutedChannelIds} + onMuteChannel={muteChannel} + onUnmuteChannel={unmuteChannel} + starredChannelIds={starredChannelIds} + onStarChannel={starChannel} + onUnstarChannel={unstarChannel} /> - -
- ) : ( -
- { - const id = communitiesHook.addCommunity({ - ...community, - pubkey: - community.pubkey ?? identityQuery.data?.pubkey, - }); - handleSwitchCommunity(id); - }} - onAddCommunityOpenChange={ - addCommunityDialog.onOpenChange - } - onNewMessage={handleOpenNewDm} - onBackgroundClick={requestFocusedThreadClose} - onCreateChannelOpenChange={setIsCreateChannelOpen} - onOpenAddCommunity={addCommunityDialog.openDialog} - onSendFeedback={() => setIsSendFeedbackOpen(true)} - onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={(id) => - void handleRemoveCommunity(id) - } - onSwitchCommunity={handleSwitchCommunity} - onCreateAgent={() => requestOpenCreateAgent()} - selfPresenceStatus={presenceSession.currentStatus} - communities={communitiesHook.communities} - onCreateChannel={handleCreateChannel} - onCreateForum={handleCreateForum} - onHideDm={handleHideDm} - onMarkAllChannelsRead={markAllChannelsRead} - onMarkChannelRead={markChannelRead} - onMarkChannelUnread={markChannelUnread} - onBrowseChannels={handleOpenBrowseChannels} - onOpenDm={async ({ pubkeys }) => { - const directMessage = - await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onSelectAgents={() => void goAgents()} - onSelectChannel={(channelId) => - void goChannel(channelId) - } - onOpenSearchResult={handleOpenSearchResult} - searchChannels={channels} - searchFocusRequest={searchFocusRequest} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => void goWorkflows()} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - onSetUserStatus={(text, emoji) => - setUserStatusMutation.mutate({ text, emoji }) - } - onClearUserStatus={() => - setUserStatusMutation.mutate({ - text: "", - emoji: "", - }) - } - profile={profileQuery.data} - selfUserStatus={ - deferredPubkey - ? (selfStatusQuery.data?.[ - deferredPubkey.toLowerCase() - ] ?? undefined) - : undefined + + + + + + + + +
+ )} + + + { + setIsChannelManagementOpen(open); + if (!open) { + setManagedChannelId(null); } - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} - starredChannelIds={starredChannelIds} - onStarChannel={starChannel} - onUnstarChannel={unstarChannel} - /> - - - - - - - - -
- )} - - - { - setIsChannelManagementOpen(open); - if (!open) { + }} + onDeleteActiveChannel={() => { + setIsChannelManagementOpen(false); setManagedChannelId(null); - } - }} - onDeleteActiveChannel={() => { - setIsChannelManagementOpen(false); - setManagedChannelId(null); - void goHome({ replace: true }); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - + void goHome({ replace: true }); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> + +
- { void goChannel(channelId, { messageId, diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f928970610..d19ac03120 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -79,6 +79,18 @@ export function useAppNavigation() { [commitNavigation], ); + const goProfile = React.useCallback( + (pubkey: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/pulse", + search: { profile: pubkey }, + }, + behavior, + ), + [commitNavigation], + ); + const goProjects = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -303,6 +315,7 @@ export function useAppNavigation() { goProject, goProjects, goPulse, + goProfile, goSettings, goWorkflow, goWorkflows, diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index a3a8a20231..4c545baf68 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -13,6 +13,7 @@ import { ProfileAvatarWithStatus, scaleProfileAvatarStatusGeometry, } from "@/features/profile/ui/ProfileAvatarWithStatus"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -65,6 +66,7 @@ export function ChannelScreenHeader({ const isGroupDm = activeChannel?.channelType === "dm" && activeDmHeaderParticipants.length > 1; + const activeDmParticipant = activeDmHeaderParticipants[0] ?? null; const showJoinButton = activeChannel !== null && !activeChannel.isMember && @@ -113,6 +115,25 @@ export function ChannelScreenHeader({ + ) : activeDmParticipant ? ( + + + ) : (
- + + +
- {profile?.displayName || profile?.avatarUrl ? ( - - ) : ( - - )} + + {profile?.displayName || profile?.avatarUrl ? ( + + ) : ( + + )} +
diff --git a/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx b/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx index 1a1018fbe7..e2922d80b7 100644 --- a/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx +++ b/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx @@ -1,4 +1,5 @@ import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; export type DirectMessageIntroParticipant = { @@ -18,31 +19,36 @@ export function DirectMessageIntroAvatarStack({ return ( + { + if (!open) setDeleteCandidate(null); + }} + open={deleteCandidate !== null} + > + + + Delete imported voice? + + {deleteCandidate + ? `${deleteCandidate.displayName} and its local audio file will be removed.` + : "This imported voice and its local audio file will be removed."} + {selectedVoice?.key === deleteCandidate?.key && + " Mary will be selected instead."} + + + + Cancel + { + event.preventDefault(); + if (deleteCandidate) { + void deletePocketVoice(deleteCandidate.key); + } + }} + > + Delete voice + + + + ); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 97051b68ec..818144415a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -166,6 +166,8 @@ type E2eConfig = { agentTextToSpeech: boolean; voicePreferences: string[]; }; + /** Native picker boundary result for Pocket voice import tests. */ + pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ @@ -9911,7 +9913,25 @@ export function maybeInstallE2eTauriMocks() { deviceId: state === "running" ? "mock-endpoint-id" : null, deviceName: state === "running" ? "Mock desktop" : null, }); - const handleMockCommand = async (command: string, payload: unknown) => { + let mockImportedVoices: Array<{ + key: string; + displayName: string; + backend: string; + backendName: string; + availability: "installed"; + fallbackKey: string; + referenceFile: string; + provenance: { + source: string; + contentHash: string; + license: null; + sourceUrl: null; + }; + }> = []; + const handleMockCommand = async ( + command: string, + payload: unknown, + ): Promise => { const activeConfig = getConfig(); const identity = getActiveIdentity(activeConfig); window.__BUZZ_E2E_COMMANDS__?.push(command); @@ -9969,107 +9989,110 @@ export function maybeInstallE2eTauriMocks() { ); case "list_voice_registry": return [ - [ - "anna", - "Anna", - "anna.wav", - "p228_023_enhanced.wav", - "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856", - ], - [ - "vera", - "Vera", - "vera.wav", - "p229_023_enhanced.wav", - "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b", - ], - [ - "fantine", - "Fantine", - "fantine.wav", - "p244_023_enhanced.wav", - "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b", - ], - [ - "charles", - "Charles", - "charles.wav", - "p254_023_enhanced.wav", - "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756", - ], - [ - "paul", - "Paul", - "paul.wav", - "p259_023_enhanced.wav", - "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b", - ], - [ - "eponine", - "Eponine", - "eponine.wav", - "p262_023_enhanced.wav", - "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b", - ], - [ - "azelma", - "Azelma", - "azelma.wav", - "p303_023_enhanced.wav", - "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026", - ], - [ - "george", - "George", - "george.wav", - "p315_023_enhanced.wav", - "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae", - ], - [ - "mary", - "Mary", - "reference_sample.wav", - "p333_023_enhanced.wav", - "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", - ], - [ - "jane", - "Jane", - "jane.wav", - "p339_023_enhanced.wav", - "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a", - ], - [ - "michael", - "Michael", - "michael.wav", - "p360_023_enhanced.wav", - "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad", - ], - [ - "eve", - "Eve", - "eve.wav", - "p361_023_enhanced.wav", - "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd", - ], - ].map( - ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({ - key: `pocket:${id}`, - displayName, - backend: "pocket", - backendName: "Pocket TTS", - availability: "bundled", - fallbackKey: id === "mary" ? null : "pocket:mary", - referenceFile, - provenance: { - source: "bundled", - contentHash, - license: "CC-BY-4.0", - sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`, - }, - }), - ); + ...[ + [ + "anna", + "Anna", + "anna.wav", + "p228_023_enhanced.wav", + "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856", + ], + [ + "vera", + "Vera", + "vera.wav", + "p229_023_enhanced.wav", + "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b", + ], + [ + "fantine", + "Fantine", + "fantine.wav", + "p244_023_enhanced.wav", + "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b", + ], + [ + "charles", + "Charles", + "charles.wav", + "p254_023_enhanced.wav", + "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756", + ], + [ + "paul", + "Paul", + "paul.wav", + "p259_023_enhanced.wav", + "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b", + ], + [ + "eponine", + "Eponine", + "eponine.wav", + "p262_023_enhanced.wav", + "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b", + ], + [ + "azelma", + "Azelma", + "azelma.wav", + "p303_023_enhanced.wav", + "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026", + ], + [ + "george", + "George", + "george.wav", + "p315_023_enhanced.wav", + "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae", + ], + [ + "mary", + "Mary", + "reference_sample.wav", + "p333_023_enhanced.wav", + "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + ], + [ + "jane", + "Jane", + "jane.wav", + "p339_023_enhanced.wav", + "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a", + ], + [ + "michael", + "Michael", + "michael.wav", + "p360_023_enhanced.wav", + "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad", + ], + [ + "eve", + "Eve", + "eve.wav", + "p361_023_enhanced.wav", + "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd", + ], + ].map( + ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({ + key: `pocket:${id}`, + displayName, + backend: "pocket", + backendName: "Pocket TTS", + availability: "bundled", + fallbackKey: id === "mary" ? null : "pocket:mary", + referenceFile, + provenance: { + source: "bundled", + contentHash, + license: "CC-BY-4.0", + sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`, + }, + }), + ), + ...mockImportedVoices, + ]; case "set_tts_enabled": { const enabled = (payload as { enabled?: boolean })?.enabled; if (typeof enabled !== "boolean") @@ -10114,6 +10137,75 @@ export function maybeInstallE2eTauriMocks() { } case "preview_pocket_voice": return null; + case "import_pocket_voice": { + const importResult = + activeConfig?.mock?.pocketVoiceImportResult ?? "success"; + if (importResult === "cancel") return null; + if (importResult === "invalid") { + throw new Error("Voice WAV must contain PCM or 32-bit float audio"); + } + const contentHash = "1".repeat(64); + const imported = { + key: `pocket:imported:${contentHash}`, + displayName: "My voice", + backend: "pocket", + backendName: "Pocket TTS", + availability: "installed" as const, + fallbackKey: "pocket:mary", + referenceFile: `${contentHash}.wav`, + provenance: { + source: "local import", + contentHash, + license: null, + sourceUrl: null, + }, + }; + mockImportedVoices = [imported]; + const current = activeConfig?.mock?.ttsSettings ?? { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:mary"], + }; + const settings = { + ...current, + voicePreferences: [imported.key], + }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.ttsSettings = settings; + } + return { + settings, + registry: await handleMockCommand("list_voice_registry", null), + }; + } + case "delete_pocket_voice": { + const voiceKey = (payload as { voiceKey?: string })?.voiceKey; + if (!voiceKey?.startsWith("pocket:imported:")) + throw new Error("Missing imported Pocket voice key"); + mockImportedVoices = mockImportedVoices.filter( + (voice) => voice.key !== voiceKey, + ); + const current = activeConfig?.mock?.ttsSettings ?? { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:mary"], + }; + const settings = { + ...current, + voicePreferences: current.voicePreferences.includes(voiceKey) + ? ["pocket:mary"] + : current.voicePreferences, + }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.ttsSettings = settings; + } + return { + settings, + registry: await handleMockCommand("list_voice_registry", null), + }; + } case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": { diff --git a/desktop/tests/e2e/voice-settings.spec.ts b/desktop/tests/e2e/voice-settings.spec.ts index 490bee3424..9bd8742ed7 100644 --- a/desktop/tests/e2e/voice-settings.spec.ts +++ b/desktop/tests/e2e/voice-settings.spec.ts @@ -116,4 +116,97 @@ test.describe("Pocket voice settings", () => { }, }); }); + + test("imports, selects, and safely deletes a local voice", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + await page.getByTestId("pocket-voice-import").click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "My voice", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeVisible(); + await page.getByRole("button", { name: "Preview" }).click(); + + await page.getByTestId("pocket-voice-delete").click(); + await expect(page.getByText("Delete imported voice?")).toBeVisible(); + await page.getByTestId("confirm-pocket-voice-delete").click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + await page.getByRole("button", { name: "Preview" }).click(); + + const mutations = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter((entry) => + [ + "import_pocket_voice", + "preview_pocket_voice", + "delete_pocket_voice", + ].includes(entry.command), + ) + .map((entry) => ({ command: entry.command, payload: entry.payload })), + ); + expect(mutations).toEqual([ + { command: "import_pocket_voice", payload: {} }, + { + command: "preview_pocket_voice", + payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` }, + }, + { + command: "delete_pocket_voice", + payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` }, + }, + { + command: "preview_pocket_voice", + payload: { voiceKey: "pocket:mary" }, + }, + ]); + }); + + test("keeps the selected voice unchanged when the native picker is cancelled", async ({ + page, + }) => { + await installMockBridge(page, { pocketVoiceImportResult: "cancel" }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await page.getByTestId("pocket-voice-import").click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + await expect(page.getByTestId("voice-settings-error")).toBeHidden(); + + const audioCommands = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter((entry) => + ["preview_pocket_voice", "delete_pocket_voice"].includes(entry.command), + ), + ); + expect(audioCommands).toEqual([]); + }); + + test("surfaces invalid or unsupported WAV errors without changing selection", async ({ + page, + }) => { + await installMockBridge(page, { pocketVoiceImportResult: "invalid" }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + await page.getByTestId("pocket-voice-import").click(); + await expect(page.getByTestId("voice-settings-error")).toContainText( + "Voice WAV must contain PCM or 32-bit float audio", + ); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + }); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2d41d27a5e..8a9ab2be11 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -164,6 +164,8 @@ type MockBridgeOptions = { agentTextToSpeech: boolean; voicePreferences: string[]; }; + /** Native picker boundary result for Pocket voice import tests. */ + pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Relay NIP-11 identity used to sign authoritative repository state. */ From 052174a148f9f6bcbb2b5a1d20ce0317645e49f8 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 10:39:14 -0600 Subject: [PATCH 96/99] fix(release): make immutable desktop release operable (#3943) ## Summary - document `Prepare Desktop Release` as the canonical desktop release entry point - describe the frozen candidate, exact-head approval, and true merge-commit contract - document all platform outputs and complete release App/signing configuration - link the release runbook from the README - allow stable reruns to repair the rolling updater manifest after the versioned release has already published ## Release blocker The live repository cannot currently complete this flow: repository settings disable merge commits and the `main` ruleset allows only squash, while `scripts/verify-desktop-release-merge.sh` requires a two-parent merge whose second parent is the approved candidate. Those settings must allow merge commits before a desktop release PR is merged. ## Validation - `bash scripts/test-desktop-release-candidate.sh` - `bash scripts/test-release-ref-contract.sh` - `git diff --check` - verified live repository merge settings, `main` ruleset, release tag ruleset, Actions variable names, and secret names with GitHub API - independent review by Princess Donut; incorporated all findings, including the rolling-manifest retry gap and unsigned Windows labeling Signed-off-by: Wes Co-authored-by: Carl --- .github/workflows/release.yml | 2 +- README.md | 1 + RELEASING.md | 113 ++++++++++++++++++++------- scripts/test-release-ref-contract.sh | 5 +- 4 files changed, 89 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07951ef81d..7d5f3fbf40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -948,5 +948,5 @@ jobs: run: gh release edit "desktop-v${VERSION}" --draft=false - name: Upload latest.json to rolling release last - if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }} + if: ${{ !contains(needs.setup.outputs.version, '-') }} run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/README.md b/README.md index 72af92ce13..2c58ceecad 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Forge · Agents · Architecture · + Releasing · Apache 2.0

diff --git a/RELEASING.md b/RELEASING.md index 45f0f8638f..11f669fc9b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) | +| Desktop | `Prepare Desktop Release` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -16,13 +16,22 @@ remains manual because OSS CI cannot trigger private CI. ## Quick Start +Desktop releases are prepared from the current remote `main` by GitHub Actions: + ```sh -# Desktop release (next patch version) -just release-desktop +gh workflow run prepare-desktop-release.yml \ + --repo block/buzz \ + --ref main \ + -f version=0.5.3 +``` -# Desktop explicit version -just release-desktop 0.4.0 +The equivalent GitHub UI path is **Actions → Prepare Desktop Release → Run +workflow**, select `main`, enter the version without a `v` prefix, and run it. +The local `just release-desktop ` recipe uses the same candidate script, +but the Actions workflow is the canonical operator path because it runs with the +release App identity and does not depend on an operator checkout. +```sh # Relay release just release-relay just release-relay 0.4.0 @@ -31,8 +40,9 @@ just release-relay 0.4.0 scripts/mobile-release.sh candidate 0.5.0 ``` -Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each -`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. +Desktop uses an immutable generated candidate PR; relay continues using its +metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable +candidate and the artifact of record. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,11 +52,26 @@ or mobile GitHub Release. ### Desktop -1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR. -2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval. -3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs. -4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`. -5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions. +1. Run **Prepare Desktop Release** with an explicit version. Automation fetches + the current `origin/main`, regenerates `version-bump/` as one + deterministic candidate commit, records the frozen base and proposed + `desktop-v` tag in `.release/desktop-candidate.json`, updates every + desktop manifest and lockfile, writes a full-SHA changelog, and opens or + updates the PR. +2. Review the recorded base and candidate SHA, the complete changelog, and CI. + The candidate must receive an approval on its exact current head. Any + regeneration changes that head and therefore requires a fresh approval. +3. Merge with **Create a merge commit**. Squash and rebase are invalid for + desktop release PRs. Repository settings and the `main` ruleset must allow + merge commits for this option to exist. +4. `auto-tag-on-release-pr-merge` verifies the two-parent merge, exact candidate + approval, and every required check, then tags the reviewed candidate—not the + merge commit—as `desktop-v`. +5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel + macOS, Windows, and Linux artifacts; publishes the versioned release only + after the complete set succeeds; then updates the rolling updater manifest + last for stable versions. A failed platform leaves no partially published + versioned release. ### Relay @@ -143,12 +168,15 @@ for distributable builds or builds from an immutable release tag. --- -## Manual Release Retry +## Release Retry -The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `desktop-v` tag. Select that tag in the ref picker and -provide the matching semver version without the `desktop-v` prefix. It cannot build -from `main` or another caller-selected source ref. +`release.yml` has no manual dispatch and cannot build from `main` or another +caller-selected ref. If a run for an existing immutable +`desktop-v` tag fails, rerun that failed workflow from GitHub Actions +(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also +repairs `buzz-desktop-latest/latest.json` if the original run published the +versioned release but failed during that final rolling-manifest upload. Do not +recreate, move, or push the immutable tag again. Mobile intentionally has no branch or arbitrary-ref fallback. The private Buildkite pipeline accepts only an exact candidate tag. @@ -183,9 +211,11 @@ GitHub Release or a stable `mobile-vX.Y.Z` alias. The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel -(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and -`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `desktop-v` release. Intel users download the `_x64.dmg`. +(`darwin-x86_64`, the `release-macos-x64` job), an unsigned Windows x64 +NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and +`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached +to the same `desktop-v` release. Intel users +download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on @@ -205,18 +235,25 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 repository - `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch the candidate workflow +- Repository settings and the `main` ruleset configured to allow **merge + commits**; desktop release PRs cannot be squash- or rebase-merged - Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754) - active for `mobile-v*`, with creation, update, deletion, and non-fast-forward - protections and `buzz-release-bot` as its sole always-bypass actor + active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and + non-fast-forward protections and `buzz-release-bot` as its sole always-bypass + actor - The `buzz-release-bot` App credentials configured for GitHub Actions -- The following **GitHub Actions secrets** must also be configured for the +- The following **GitHub Actions variables and secrets** configured for the desktop release lane: - | Secret | Purpose | - |--------|---------| - | `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) | - | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key | - | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key | + | Name | Kind | Purpose | + |------|------|---------| + | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to prepare candidates and create tags | + | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key | + | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` | + | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket | + | `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key | + | `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key | + | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key | Mobile candidate publication requires workflow-dispatch access and the existing release App because strict tag protection denies direct human creation. The App @@ -231,10 +268,26 @@ actor list. ## Troubleshooting -### `just release-desktop` fails with "must be on main branch" +### The release PR does not offer **Create a merge commit** + +The immutable desktop flow cannot release until both the repository merge +settings and the `main` ruleset allow merge commits. Do not squash the PR: the +auto-tagger deliberately rejects a one-parent squash commit. Enable merge +commits, then merge the already-approved exact candidate head with **Create a +merge commit**. + +### `Prepare Desktop Release` fails before opening a PR + +Check the workflow run first. Confirm `BUZZ_RELEASE_TAGGER_CLIENT_ID` and +`BUZZ_RELEASE_TAGGER_PRIVATE_KEY` are configured and that the release App can +write contents and pull requests. Rerunning the preparer regenerates the +candidate from the then-current `origin/main`; if its head changes, obtain a new +approval before merging. + +### Local `just release-desktop` fails with "must be on main branch" Switch to `main` and pull latest before running the release recipe. -### `just release-desktop` fails with "working tree is dirty" +### Local `just release-desktop` fails with "working tree is dirty" Commit or stash your changes before running the release recipe. ### New commits land after publishing a mobile candidate diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index bd4eb75275..25ef5ca230 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -120,7 +120,10 @@ grep -Fq "needs.release-macos-x64.result == 'success'" "$release_workflow" grep -Fq "needs.release-linux.result == 'success'" "$release_workflow" grep -Fq "needs.release-windows.result == 'success'" "$release_workflow" grep -Fq "refs/tags/desktop-v{0}" "$release_workflow" -grep -Fq "if: \${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" +grep -Fq "if: \${{ !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" +if grep -Fq "env.already_published != 'true' && !contains(needs.setup.outputs.version, '-')" "$release_workflow"; then + echo "rolling updater retry is incorrectly gated by versioned publication state" >&2; exit 1 +fi grep -Fq 'group: desktop-release-${{ github.ref }}' "$release_workflow" grep -Fq 'cancel-in-progress: false' "$release_workflow" grep -Fq 'release artifact basename collision' "$release_workflow" From d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 10:43:30 -0600 Subject: [PATCH 97/99] chore(release): release Buzz Desktop version 0.5.3 Co-authored-by: Release Automation Signed-off-by: Wes --- .release/desktop-candidate.json | 8 ++++ CHANGELOG.md | 63 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 .release/desktop-candidate.json diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json new file mode 100644 index 0000000000..150f8023e7 --- /dev/null +++ b/.release/desktop-candidate.json @@ -0,0 +1,8 @@ +{ + "schema": 1, + "version": "0.5.3", + "base_sha": "052174a148f9f6bcbb2b5a1d20ce0317645e49f8", + "previous_tag": "v0.5.2", + "tag": "desktop-v0.5.3", + "commit_count": 53 +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d83087fc26..974f68c683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # Changelog +## v0.5.3 + +### Desktop and shared changes + +- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1)) +- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663)) +- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc)) +- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67)) +- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59)) +- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501)) +- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51)) +- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) +- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39)) +- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b)) +- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140)) +- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b)) +- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef)) +- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0)) +- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3)) +- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) +- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832)) +- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966)) +- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad)) +- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76)) +- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) +- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb)) +- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a)) +- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898)) +- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c)) +- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859)) +- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4)) +- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c)) +- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) +- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f)) +- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19)) +- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53)) +- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c)) +- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10)) +- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed)) +- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b)) +- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8)) +- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d)) +- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) +- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) +- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d)) +- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7)) + +### Other repository changes + +- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8)) +- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089)) +- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) +- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) +- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a)) +- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a)) +- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170)) +- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0)) +- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61)) +- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5)) +- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) + +[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3) + ## v0.5.2 - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) diff --git a/desktop/package.json b/desktop/package.json index 2226a0cb12..e8145f5468 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.2", + "version": "0.5.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 254b7070ac..00d3fba3b5 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 39aaf0dead..b80684f955 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.3" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2eba7815b2..1ff8bd20ef 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.2", + "version": "0.5.3", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 209536ade6c5ebf7fa82671d7ca0b74f599a40cc Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 31 Jul 2026 13:06:21 -0400 Subject: [PATCH 98/99] docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Amends `docs/nips/NIP-RS.md` with the manual mark-as-unread override layer and includes `docs/formal/nip-rs-unread/`, the bounded exhaustive verification model that preceded and informed the spec. All `ov_*` override state lives in exactly one coordinate per installation. That single constraint is what makes the rest of the amendment small: override state never moves between coordinates, so there is no slot lifecycle to make crash-safe, and the only durability obligation is carry-forward on `client_id` rotation. ## Spec changes (`docs/nips/NIP-RS.md`) - **Non-Goals:** drop the stale line stating mark-as-unread is out of scope; state the `ov_*` durability exception to the best-effort/time-horizon model. - **Reserved Namespace:** `ov_` stem and `esc:` escape marker reserved. Escape on publish (prepend `esc:` to raw IDs beginning with `ov_` or `esc:`), unescape on receive (strip exactly one `esc:`). Bijection, with the pre-amendment backward-compat residual documented as a stated limitation. - **Content Validation:** override entries are collected and validated as a complete logical group *before* any decoding, zero-filling, merging, or canonicalizing. Only two wire shapes are accepted — a complete live three-key group, or an `ov_c:`-only tombstone floor. Any other shape rejects the whole group while retaining the frontier entry; applying the generic per-entry discard rule first is prohibited. - **`d` Tag:** `` is exactly 32 lowercase hexadecimal characters, replacing "a random opaque string" of 1–64 ASCII characters. The fixed shape lets a relay recognize a read-state coordinate structurally from the `d` tag alone, without decrypting anything, and apply per-coordinate protections to it — under the old wording a conforming client could pick a shape that silently forfeits them. Recognizable coordinates are also what let a relay replace superseded versions outright rather than accumulating one retained row per publish, which keeps the coordinate count a full-state load must enumerate near one per installation. Every client designates one **primary** coordinate with a stable `` for the installation's lifetime. All `ov_*` entries, and the frontier entries of the contexts they belong to, MUST live in the primary. Additional coordinates remain legal for frontier volume but MUST NOT carry `ov_*`, which keeps them freely rewritable and freely deletable. - **`t` Tag:** described as a discoverability marker rather than a guarantee of relay-side selectivity. A relay MAY apply tag constraints after its result cap, and `kind:30078` is shared with unrelated application data, so clients MUST apply the tag as a correctness filter locally, MUST NOT infer completeness from a short result, and MUST omit the tag entirely when performing a full-state load. - **Fetching / Full-State Load:** clients implementing the override layer MUST NOT apply a finite `since` filter — an encrypted payload means a relay filter cannot select for override-bearing events, so any event-level window can exclude the only coordinate holding a tombstone floor. Removing `since` is not sufficient: relays MAY cap historical results, MAY cap below the requested `limit`, and emit end-of-stored-events after the capped query, so neither EOSE nor a short page proves completeness. No test against the client's requested `limit` can detect truncation either: the effective cap belongs to the relay, a relay MAY cap below what was requested, and an advertised maximum limit is not necessarily the limit enforced. A full-state load is therefore enumerated on `{"kinds": [30078], "authors": [], "limit": }` with **no tag constraint**. A relay MAY apply tag constraints only after its result cap and withhold the events that fail them, so under a tag-constrained filter the delivered count is not the count the cap selected — a delivered page can be empty while older coordinates still exist below it, and `kind:30078` is arbitrary application data whose `d` tag namespace is open to every application that has written under the user's key. Omitting the tag makes delivery observable; read-state selection moves client-side, where the validation rules already place it. Completeness is then established by enumeration on a strictly decreasing cursor: collect a page, descend on the lowest `created_at` across all delivered events, exhaust that second with a window pinned to it, continue below it, and treat only an empty delivery as complete. Every query carries the same explicit `limit` `n` with `n >= L`. Per-second exhaustion is discharged by comparing the pinned window's delivery against the largest delivery the relay has already demonstrated in the same load, floored at `L = 2` so that the ordinary single-coordinate installation can reach *complete* at all. The comparison fails safe: an inconclusive window reports *cannot prove complete* rather than *complete*, and that verdict is terminal for the load. Because these are addressable events, a coordinate republished mid-load moves *above* the descending cursor while its previous version stops existing, so neither is reachable by any later query. A full-state load is therefore fenced by a live subscription on the same tag-free filter, established — defined as receipt of end-of-stored-events — before the first enumeration query and held unbroken on the same connection for the load's duration. Fence deliveries are collected like enumerated events but do not contribute to the cursor or to the demonstrated-delivery bound. Collection deduplicates coordinates on the full NIP-01 addressable ordering — greatest `created_at`, lowest event id on ties — because an equal-timestamp replacement is legal and is the version the relay retains. A lapsed or reconnected fence makes the load potentially incomplete, and a client MUST NOT publish to its own coordinates during its own load. Five relay behaviours the *complete* verdict rests on are stated as normative conformance preconditions rather than assumptions, because none is verifiable from the responses a client receives: newest-first prefix delivery with lowest-id tie-breaking (what NIP-01 already specifies for `limit`), a non-decreasing effective cap within a load, the floor `L`, push delivery on an open subscription, and a delivery barrier ordering accepted matching events ahead of a query's end-of-stored-events on the same connection. Conditioning *complete* on positive proof of these instead would withdraw the override layer from every client rather than from the non-conforming relays. A client MUST NOT load against a relay it has evidence violates them, and MUST treat any such load as potentially incomplete. A load that is potentially incomplete, or that failed on any relay the client publishes to, MUST NOT authorize canonical compaction, publishing a canonicalized override blob, deleting or abandoning a coordinate, or reporting a mark-read as successful; the client falls back to local state. - **Client-ID Rotation / Orphaned Blob Deletion:** rotation is the only event that changes an override-bearing coordinate. Before deleting or abandoning its previous primary, a client MUST republish the componentwise `max()` of every register that primary holds — every tombstone ceiling included — under its new primary, and MUST confirm acceptance on **every relay** from which the old primary will be deleted or allowed to lapse. Acceptance on one relay does not authorize deletion on another. Frontier-only orphans are deletable unconditionally; an unknown same-`client_id` coordinate is treated as a live carrier until merged. - **Live Subscription and Convergence:** the re-publish trigger and its suppression are evaluated on canonicalized state, so a retained live peer blob the client has already tombstoned cannot trigger an identical write on every replay. - **Manual-Unread Override Layer** (new section): - **Wire encoding:** `ov_s:`, `ov_c:`, `ov_b:` as uint32 siblings in the existing `contexts` map. - **Merge rule:** componentwise `max()` per counter — no new wire merge logic. - **Liveness predicate:** `S > 0 AND F <= B AND S > C`, transcribed from `model.py::override_set_b`. - **Actions:** mark-unread bumps S and captures the effective frontier as B; mark-read bumps C; a natural frontier advance past B deactivates a stale set with no counter update. Every action requires a complete full-state load. At the uint32 ceiling, wrapping and resetting are prohibited: mark-unread is refused, and mark-read completes only if the resulting state has `override_active == false` — otherwise it fails visibly rather than reporting success over a still-live override. - **Tombstone floor:** a dead ever-active register compacts to `RegB(0, max(S,C), 0)` — a single `ov_c:` key. A virgin register is omitted entirely. This blocks counter reuse and the resulting resurrection. - **Mandatory canonical publication:** a protocol requirement, not an optimization. Publishing raw dead registers lets two independently-dead registers from different devices produce a live join. - **Override group co-location rule:** a context's frontier entry and all its `ov_*` siblings MUST travel in the same event, and that event MUST be the primary coordinate. An override-bearing context therefore has exactly one legal destination for its whole group; only frontier-only groups may be distributed across additional coordinates. Grouping is per logical context, never per key. - **Unescape-before-group rule:** the frontier wire key MUST be unescaped to its raw logical context ID before use as group identity. Equal normative weight to atomic grouping. - **Tie policy:** clear-wins is MUST. The tie verdict is not encoded on the wire, so a selectable policy makes two conforming clients diverge permanently on both the unread verdict and the canonical wire form. - **Override State Durability:** `ov_*` entries are exempt from age pruning and budget eviction permanently, and durability is defined over retrievable logical state — the containing event must stay reachable and the load must establish completeness, not merely retain keys. There is no safe finite GC horizon. - **Bounds and budget:** byte/key analysis at both small-counter and uint32-maximum values. Confining `ov_*` to one blob makes its plaintext budget a hard lifetime ceiling on ever-overridden contexts — roughly 600 tombstones at the worst-case ~54 bytes against 32 KiB, ~730 at the common ~45 bytes, ~199 simultaneously live overrides at ~164 bytes. At the ceiling a client MUST refuse mark-unread and MUST NOT split override state, drop floors, or publish a truncated override set. Same policy shape as counter exhaustion: visible failure, never silent degradation. - **Verification artifact:** `docs/formal/nip-rs-unread/`. The model is a broader predecessor of this NIP: its `split_blob_into_slots` permits override groups in any slot, so verified atomicity covers every arrangement this NIP allows, but the converse does not follow. The model does not verify the single-primary rule, the completeness procedure, the relay conformance requirements or the mutation fence, or carry-forward; malformed-group wire validation is likewise normative but outside verified scope. - **Abstract / Non-Goals / Backwards Compatibility:** the absolute "no relay-side logic" and "no relay behavior changes" claims are narrowed to what remains true — no new event kind, no new wire message, no relay-stored read-state logic — with the override layer's relay conformance contract named as the exception. Frontier sync and clients that skip the override layer are unaffected on any relay. ## Verification model (`docs/formal/nip-rs-unread/`) Four Python files constituting a bounded exhaustive verification model for the override layer's register algebra. **What it does:** constructs a toy universe — 2–3 devices, 2 channels, every action that can happen (mark-unread, mark-read, late/duplicate syncs, app reinstall, storage compaction) — and brute-forces every reachable ordering (14,258 BFS states; 672-point deep-history parameter cube; 9-mutant harness over ~45,000 merge pairs). After each world-state it asks: did all devices converge? Did any unread flag get resurrected after being cleared, or vanish while live? **What it found and fixed:** 1. **Killed candidate A.** The model produced a concrete kill sequence: an old client that doesn't know about the new field rewrites its read-state blob and silently erases unread flags. That witness is why the spec uses candidate B (two counters that only count up, plus a snapshot) instead. 2. **Candidate B passes everything.** All delivery orders converge; the frontier high-water mark never regresses; duplicated/replayed syncs are harmless; old clients can't destroy it; compaction never resurrects a dead unread or drops a live one, including cleanup-followed-by-weeks-late-stale-sync and tombstone-landing-on-unrelated-live-state corner cases. 3. **Caught a second real bug late.** Two devices each publishing "this unread is cleared" could, on merge, reactivate it. The fix (canonicalize before publishing) is a mandatory rule in the spec; the model re-checks it across ~45,000 merge pairs. **Scope and caveats:** bounded to 2–3 devices and 2 channels. Can't prove the infinite case. `NOTE.md` documents the exact verification scope and the gap between the model's `split_blob_into_slots` generality and the single-primary rule the spec adds on top. **Why it's in the repo:** the spec asserts "verified by bounded exhaustive model checking." Keeping the artifact in-repo means anyone who later amends the merge/compaction rules can `python3 exhaustive.py && python3 mutation.py` (deterministic, exit 0) and confirm the guarantees hold. Without it the spec claims a proof nobody can check. ## Diff scope `docs/nips/NIP-RS.md` — spec amendment, zero product code. `docs/formal/nip-rs-unread/{NOTE.md,model.py,exhaustive.py,mutation.py}` — bounded exhaustive verification model, zero product code. `.gitignore` — `__pycache__/` and `*.pyc` entries for the model directory. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .gitignore | 4 + docs/formal/nip-rs-unread/NOTE.md | 698 +++++++++++ docs/formal/nip-rs-unread/exhaustive.py | 1486 +++++++++++++++++++++++ docs/formal/nip-rs-unread/model.py | 492 ++++++++ docs/formal/nip-rs-unread/mutation.py | 519 ++++++++ docs/nips/NIP-RS.md | 289 ++++- 6 files changed, 3458 insertions(+), 30 deletions(-) create mode 100644 docs/formal/nip-rs-unread/NOTE.md create mode 100644 docs/formal/nip-rs-unread/exhaustive.py create mode 100644 docs/formal/nip-rs-unread/model.py create mode 100644 docs/formal/nip-rs-unread/mutation.py diff --git a/.gitignore b/.gitignore index 65ddcaf1c4..f26e74136c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ /dist/ /admin-web/dist/ +# Python cache +__pycache__/ +*.pyc + # lefthook-generated hook scripts (machine-specific) .hooks/ diff --git a/docs/formal/nip-rs-unread/NOTE.md b/docs/formal/nip-rs-unread/NOTE.md new file mode 100644 index 0000000000..7472c3dca3 --- /dev/null +++ b/docs/formal/nip-rs-unread/NOTE.md @@ -0,0 +1,698 @@ +--- +title: "NIP-RS manual-unread: bounded exhaustive model — candidates A vs B" +tags: [nostr, nip-rs, read-state, formal-model, buzz] +status: active +created: 2026-07-16 +--- + +# NIP-RS manual-unread encoding model + +Bounded exhaustive model comparing two candidate CRDT encodings for a +manual mark-as-unread override layer within NIP-RS read state. + +## Run + +```bash +python3 exhaustive.py +python3 mutation.py +``` + +Both scripts are deterministic and exit 0 on success. + +## Context + +NIP-RS v1 encodes read state as grow-only `max(timestamp)` frontiers per +context. Manual mark-as-unread requires a second source of truth (an +override layer) because the frontier cannot be lowered — a lower value is +indistinguishable from a stale replica under `max()` merge. + +The override layer must converge across devices, survive legacy client +rewrite cycles, and remain bounded within the existing 32 KiB plaintext +budget. Two candidate encodings are modeled: + +- **A — lexicographic operation register:** per context, one register + `{counter, client_tiebreak, op, baseline}` in a NEW top-level field. +- **B — two grow-only counters + baseline:** per context, `S` (set + counter), `C` (clear counter), `B` (frontier-at-set-time) encoded as + sibling keys under `contexts`. + +## Model universe + +- 2 upgraded devices + 1 legacy device +- 2 contexts (`c0`, `c1`) +- Actions: mark-unread, mark-read (with frontier advance), + advance-frontier, compact, reinstall (client_id loss), + deliver (including duplicate/replay) +- BFS over canonical global states with interleaved actions and deliveries + (not phased), depth-bounded +- All delivery permutations of published blobs at terminal states +- Multi-slot union (split blob across 2 slots, deliver separately) +- Directed deep-history check: compact → new local actions (counter + reuse) → delayed stale delivery, over a 672-point parameter cube + (stale `(S,C,B)` × post-compaction frontier × 7 action sequences × + 2 tie policies × 1 delivery shape). The prior 2,016-point count + included two duplicate split-delivery shapes (`split_fwd`/`split_rev`) + that became semantically identical to `single` once the atomic-grouping + rule made a single-context compliant split always whole-register+empty; + collapsed to one meaningful shape without loss of register-level + coverage. +- Cross-device compaction transparency check: same tombstone, delivered + to an unrelated device with its own live concurrent state, over a + 312-point parameter cube (stale `(S,C,B)` × post-compaction frontier × + 4 fresh-frontier values × 2 tie policies), plus a monotonicity lemma + over 1,728 points (2 tie policies × 4×4×3×3 receiving-register/frontier + combinations × 6 ceiling values) proving the ceiling can never + *strengthen* a receiving register's set-counter standing +- States explored: 7,129 per tie policy (14,258 total) +- Published-state merge closure: every override is canonicalized against + the device's own effective frontier at serialization time before + hitting the wire (mandatory, not optional) — live unchanged, dead + folded to the tombstone floor, virgin omitted. Checked over a directed + witness (Thufir's exact dead+dead pair) plus a general search: every + pairwise join of a bounded cube of 300 independently-dead published + states (156 clear-wins + 144 set-wins = 300 total across both tie + policies), including a one-hop relay republication to cover + delayed/multi-hop delivery — 45,074 pairs checked total (156² + 144² + + 2 directed witnesses) + +## Invariants checked + +| # | Invariant | A | B (clear-wins) | B (set-wins) | +|---|-----------|---|-----------------|--------------| +| I1 | Join associative/commutative/idempotent | PASS | PASS | PASS | +| I2 | Convergence (all delivery orders) | not exercised | PASS | PASS | +| I3 | No frontier regression | not exercised | PASS | PASS | +| I4 | Concurrent set/clear winner stable | not exercised | PASS | PASS | +| I5 | Compaction: no loss, no resurrection (immediate merge-back) | n/a | PASS | PASS | +| I5c | Deep-history: compact → reuse → delayed stale delivery (same-device replay) | n/a | PASS | PASS | +| I5d | Cross-device compaction transparency (suppress-only, not zero-divergence) | n/a | PASS | PASS | +| I5e | Published-state merge closure: dead+dead join stays inactive | n/a | PASS | PASS | +| I6 | Replay harmless | not exercised | PASS | PASS | +| I7 | Legacy rewrite safety | **FAIL** (witness) | PASS | PASS | +| I8 | Bounded key growth (3 keys/ctx live, 1 key/ctx tombstone) | n/a | PASS | PASS | +| I9 | DeviceA counter absorption | PASS | n/a | n/a | + +Note: Candidate A is exercised only for I1, I7, and I9. BFS/convergence, +frontier-regression, concurrent-winner, and replay tests (I2–I4, I6) are +Candidate B-only; adding A variants would fail minimalism since A is already +dead on I7 (legacy-rewrite erasure). + +I5 covers the immediate compacted-vs-pre-compaction merge shape (both +merge orders). I5c is the same-device deep-history property this round +was originally opened to close: it directly targets the ~9-transition +history a depth-4 BFS cannot structurally reach (compact → new local +set/clear → delayed stale delivery, including from a second slot), +asserting that compaction never resurrects a dead override or drops a +live one **when the delayed delivery is the compacting device's own +pre-compaction ancestor** (or an exact copy of it, e.g. a peer that +never advanced past the original snapshot). + +**I5c does not cover, and NOTE.md previously overstated, the +cross-device case.** Compaction is a storage optimization from the +compacting device's own point of view — its dead register's baseline +`B` was frontier-relative to *that device's* history, and dropping `S` +in favor of the `C` ceiling is safe against replays of *its own* past. +But once published, the tombstone's `C` ceiling is globally comparable +via componentwise `max()`, while the baseline-relative death that +produced it is not. I5d proves the resulting property precisely: +merging in a tombstone can **suppress** — never resurrect, per the +`test_tombstone_merge_monotonic` structural lemma — a different +device's concurrent fresh set whose own counters happen to be at or +below the tombstone's ceiling, and the suppression always recovers with +one more local mark-unread (verified replay-stable against the same +tombstone). This is a one-shot false-negative risk, not a correctness +violation of the CRDT join (idempotent/commutative/associative still +hold per I1) and not new: an *uncompacted* stale explicit clear already +suppresses a fresh concurrent set under clear-wins with no compaction +anywhere (verified directly — see "Tie policy evidence" below); the +tombstone extends the same false-negative-preferring shape to +baseline-dominated dead sets that were never explicitly cleared. + +**I5e — published-state merge closure — is a protocol requirement, not +an optimization.** I5d's suppress-only guarantee assumes the tombstone +was actually on the wire before the merge. Nothing forces that: +`compact_b()`/`do_compact` are a local storage-GC transition a device +may or may not have called before it serializes. Without a mandatory +canonicalization step, `publish_blob()` can emit a register's *raw* +`(S, C, B)` — dead by construction (baseline-dominated, clear-dominated, +or a clear-wins tie) but not yet folded into the tombstone's +globally-comparable `C` ceiling. Two such raw-dead registers, published +by two different devices for unrelated reasons, can componentwise-max +into a **live** join: each register's `S` and `B` came from a different +device history, and the merge recombines them independent of either +history's own death cause. This is a distinct hazard from I5d's +suppression (I5d is a live register losing to a stale dead one; I5e's +witness is two dead registers producing a live one) but the same root +cause — components taken from independent histories can be +recombined in ways neither history's own frontier ever permitted. + +**Fix: canonical publication is mandatory, not advisory.** +`DeviceB.publish_blob()` now canonicalizes every override against the +device's own effective frontier at serialization time, unconditionally +— live unchanged (3 keys), dead folded to the tombstone floor `RegB(0, +max(S,C), 0)` (1 key), virgin omitted (0 keys) — regardless of whether +`do_compact` was ever called locally first. This is a **spec-amendment +requirement for any production client implementing this override +layer**: publication MUST canonicalize before serialization, the same +way it MUST advance the frontier monotonically. It is load-bearing +correctness, not a storage optimization a client can opt out of. +`do_compact` remains available separately to mutate a device's own +`self.overrides` for local storage-GC purposes; it is no longer a +prerequisite for correct publication, because publication no longer +depends on prior local state having been compacted. + +**Proof obligation closed:** `exhaustive.py::test_published_merge_closure` +checks two ways — Thufir's exact witness pair +(`RegB(3,2,0)`@baseline-dead-50 join `RegB(1,2,100)`@clear-dead-100, +raw join is live `RegB(3,2,100)`) as a directed case under both tie +policies, and a general search over every pairwise join of a bounded +cube of 300 independently-dead published states (156 clear-wins + 144 +set-wins = 300 total across both tie policies), including a one-hop +relay republication step to cover delayed/multi-hop delivery (a relay +that receives one operand alone and republishes — re-canonicalizing — +before forwarding). The 45,074 ordered pairs checked comes from +156² + 144² + 2 directed witnesses. `mutation.py::mutant_m7` reverts +`publish_blob` to the pre-fix raw-serialization behavior and reproduces +Thufir's exact resurrection witness directly, confirming the new +invariant has teeth. + +## Candidate comparison + +### Convergence + +Both candidates converge under all tested delivery permutations (algebraic +property). +Candidate B achieves this with componentwise `max()` merge (a standard +state-based CRDT join). Candidate A uses a register with lexicographic +tuple comparison — also convergent, but the register requires a +client-identity tiebreak field. (Convergence for Candidate B is verified +by exhaustive BFS over all reachable states; I2–I4 and I6 are exercised +for Candidate B only — see invariant table.) + +### Legacy compatibility matrix + +| Scenario | A | B | +|----------|---|---| +| Upgraded publishes, legacy reads blob | Legacy drops `overrides` field | Legacy preserves `ov_*` sibling keys | +| Legacy rewrites same slot | **Overrides erased** (expected-witness confirmed) | Sibling keys survive sanitization | +| Upgraded reads legacy-rewritten blob | Override state lost | Override state intact | +| Legacy reads its own frontier | Inert (correct) | Inert (correct) | +| Legacy frontier advance past baseline | Cannot clear override (erased) | Stale set dominated (correct) | + +**Candidate A's legacy erasure is the decisive defect.** The desktop and +mobile parsers (`readStateFormat.ts:82-108`, `read_state_format.dart:100-141`) +reconstruct only `{v, client_id, contexts}`. A same-slot legacy rewrite +drops the top-level `overrides` field entirely and republishes without it. +There is no safe migration path: any user with a single legacy device +loses all manual-unread state on the next rewrite cycle. + +Candidate B's sibling keys (`ov_s:`, `ov_c:`, `ov_b:`) pass all legacy +validation gates — keys are <= 256 UTF-8 bytes, values are uint32 — +and round-trip through legacy rewrite unmodified. + +**Legacy carry-through simplification (documented divergence).** Row +"Legacy preserves `ov_*` sibling keys" is proven two different ways in +this model, and they are not the same claim: + +- `legacy_sanitize_blob` — the byte-sanitization function alone (drop + keys >256 UTF-8 bytes or non-uint32 values) — genuinely preserves + unknown keys as opaque pass-through, matching production + `sanitizeContexts`. `test_legacy_rewrite_b` (I7) exercises exactly + this: an upgraded device's blob is sanitized and received by a + *second upgraded* device; the sibling keys survive because + sanitization never touches keys it doesn't recognize. +- `DeviceB(is_legacy=True)` — the explorer's legacy *device* object used + in the multi-device BFS (`exhaustive.py`) — does **not** carry + through `ov_*` keys it receives. `receive_merge` parses them into a + local dict but the store step is gated on `not self.is_legacy` + (`model.py:268`), so a legacy device's own `publish_blob` only ever + republishes its own frontier keys, never sibling keys it received + from an upgraded peer. This is a deliberate model simplification, not + a claim about production: production's legacy client is a single + `sanitizeContexts` pass with no in-memory override model to gate on, + so it forwards unknown keys unchanged; the model's `DeviceB` needed an + explicit legacy/upgraded split to represent "does not understand or + act on overrides" for the BFS explorer's mark-unread/mark-read action + space, and that split was implemented as drop-on-receive rather than + store-opaque-and-forward. +- **Why this doesn't hide a defect:** every invariant that asserts + sibling-key survival through a legacy hop (I7) is checked via the + sanitize function directly, never via a `DeviceB(is_legacy=True)` + relay round-trip — the two paths are never conflated in a single + assertion. The BFS explorer's own legacy-device transitions are also + gated: `enabled_transitions` only enqueues `mark_unread`/`mark_read`/ + `compact` for a device `if not d.is_legacy` (`exhaustive.py:118-124`), + so a legacy device in the BFS never even attempts to act on overrides; + `do_mark_unread`/`do_mark_read` (`model.py:210-222`) additionally + carry an explicit `if self.is_legacy: return` no-op guard as + defense-in-depth for the same property. `do_compact` + (`model.py:227-236`) carries no such explicit guard — it is a no-op + for a legacy device only *transitively*, because `self.overrides` + is never populated for one (every write path into `self.overrides` + is already gated on `not self.is_legacy`), so `do_compact` finds + `self.overrides.get(ctx)` is always `None` and returns immediately. + Either way, the drop-on-receive simplification never + changes the BFS's own convergence or compaction verdicts (I2, I3, I5, + I5c, I5d) — those are computed only over upgraded devices' + `override_is_set`. The one place a real production legacy client + *does* matter for override survival — sanitizing an upgraded device's + own re-published blob — is I7's scope, and I7 uses the accurate + function. +- **Implication for implementation:** production's `sanitizeContexts` + pass-through behavior is correct and required; this note exists so a + future reader of `DeviceB.receive_merge` doesn't mistake the model's + drop-on-receive simplification for a claim that legacy relaying loses + override state in production — it doesn't, per the function-level + proof above. + +### Identity dependence + +- **A requires client_id** for the tiebreak field. After reinstall + (new `client_id`), the tiebreak changes. Convergence is preserved only + because the counter is strictly higher; a same-counter reinstall would + create an ambiguous merge. +- **B needs no client identity** — componentwise `max()` is + identity-free. Confirmed: reinstall with new `client_id` preserves + convergence. + +### Bytes per manually-unread context + +Sizes computed with realistic context IDs. Envelope cost +(`{"v":1,"client_id":"...","contexts":{}}`) is ~60 bytes and shared +across all contexts — amortized to near zero per context. + +| Context type | Context ID example | ID length | Live override keys (3) | Tombstone key (1) | +|--------------|-------------------|-----------|------------------------|--------------------| +| Channel | `b68cd7cb-6f8d-4641-b743-a7349eb4114b` | 36 | 138 bytes | 45 bytes | +| Message | `msg:` + 64-hex event ID | 68 | 234 bytes | 77 bytes | +| Thread | `thread:` + 64-hex event ID | 71 | 243 bytes | 80 bytes | + +Live-override bytes are unchanged by the reserved-namespace escaping +(below): every context ID Buzz actually generates (channel UUID, +`msg:hex64`, `thread:hex64`) is a no-op under `escape_context_key` — none +begin with `ov_` or `esc:` — so the escape marker costs 0 bytes in the +common case. Tombstone bytes are new in this revision: canonical +publication no longer serializes a dead register at 3 keys (see +"Compaction behavior" below and "Published-state merge closure" above) +but a single `ov_c:` key with the counter ceiling — this is now the +literal output of `publish_blob()` for any dead override, not merely +the output of the optional `do_compact` storage-GC step. + +Breakdown for channel context (worst real-world common case, live): +``` +"ov_s:b68cd7cb-6f8d-4641-b743-a7349eb4114b":1 → 44 chars +"ov_c:b68cd7cb-6f8d-4641-b743-a7349eb4114b":0 → 44 chars +"ov_b:b68cd7cb-6f8d-4641-b743-a7349eb4114b":10 → 45 chars + total ≈ 138 bytes (+ 2 commas) +``` + +Tombstone floor for channel context (dead override after compaction): +``` +"ov_c:b68cd7cb-6f8d-4641-b743-a7349eb4114b":3 → 45 chars ≈ 45 bytes +``` + +Candidate A for comparison: `{"counter":1,"tiebreak":"dev0","op":"SET","baseline":10}` +≈ 56 bytes per context as a JSON object, plus the top-level `overrides` +field overhead. However, this is moot since A's top-level field is erased +by legacy clients. + +### Reserved key namespace + +NIP-RS v1 context IDs are arbitrary UTF-8 (spec `:89`, `:113-114`), so a +pre-existing opaque context could legitimately begin with `ov_s:`, +`ov_c:`, or `ov_b:` and, once flattened into the same `contexts` map, +be misparsed as a control key for a *different* context. + +**Reservation:** the 3-byte stem `ov_` and the escape marker `esc:` are +reserved at the spec-amendment level. A raw context ID that begins with +either is escaped on publish by prepending `esc:`, and unescaped on +receive by stripping exactly one leading `esc:` (`model.py: +escape_context_key`, `unescape_context_key`). This is a bijection, not +an idempotent no-op: a context literally named `esc:foo` escapes to +`esc:esc:foo` on the wire and unescapes back to exactly `esc:foo` on +receipt — the two operations are inverses, so no collision or data +loss occurs even for context IDs that already contain the marker. + +**Cost:** zero bytes for every context ID Buzz generates today (channel +UUID, `msg:hex64`, `thread:hex64` — none start with `ov_` or `esc:`). +Only a context ID that happens to start with the reserved stem pays the +4-byte `esc:` prefix. + +**Backward-compatibility limitation (Thufir's qualification — not a +collision-safe migration of existing data):** a context published +*unescaped* by a client that predates this amendment, and that happens +to start with `ov_` (e.g. an already-published, pre-existing +`ov_s:evil`-style context), is **not safely migrated** by this scheme. +Retroactive escaping cannot rewrite a blob the original publisher never +knew needed escaping — the codec protects contexts generated by +amendment-aware clients going forward, not history that predates the +amendment. This is a theoretical concern for the reasons in the +">256-byte key drop hazard" section: Buzz's own key shapes cannot +trigger it, and no legacy client is known to generate `ov_`-prefixed +context IDs. Documented as a residual, unsolved, backward-compatibility +gap — not modeled further — per the same practical-risk reasoning +already applied to the 256-byte hazard below. + +**Verified:** `exhaustive.py::test_reserved_namespace_collision` — a +context literally named `ov_s:evil` round-trips through publish/receive +as frontier state (not misparsed as an override), and a real override on +a *different* context in the same blob is unaffected. + +### Counter headroom (uint32) + +Each counter (S, C) is a uint32: 2^32 - 1 = 4,294,967,295. At one +toggle per second, ~136 years. No practical concern for manual +right-click actions. + +### >256-byte key drop hazard + +Legacy `sanitizeContexts` drops any key with `len(key.encode('utf-8')) > 256`. +Adding the `ov_s:` prefix (5 bytes) to a context key creates a key of +`len(context_id) + 5` bytes. If the original context key is at or near +the 256-byte limit, the prefixed override key exceeds it and is silently +dropped by legacy sanitization. + +In practice, context keys are UUIDs (36 bytes), hex event IDs (64-68 bytes), +or thread IDs (71 bytes) — all well under 256 bytes. The longest common +override key (`ov_b:thread:` + 64-hex = 76 bytes) has 180 bytes of +headroom. This hazard is theoretical but should be documented in the spec. + +### 10,000-key validation limit + +Legacy `isValidBlob` rejects blobs with >10,000 context keys. Live +override keys consume 3 entries per overridden context; a compacted +(tombstoned) override consumes 1: + +| Overridden contexts | Live override keys | Typical frontier keys | Total | Headroom | +|--------------------|---------------------|-----------------------|-------|----------| +| 50 | 150 | ~500 | 650 | 93.5% | +| 100 | 300 | ~1,000 | 1,300 | 87% | +| 500 | 1,500 | ~2,000 | 3,500 | 65% | +| 3,000 | 9,000 | ~1,000 | 10,000 | 0% (limit) | + +The 32 KiB byte budget is the binding constraint long before key count. + +### Compaction behavior (tombstone-floor, policy-dependent) + +**Revision note:** the prior "compacts to zero" design (delete-on- +dominance: a dead register was dropped entirely, 0 keys) is retracted. +Thufir's pass-3 review found a stale-replay resurrection: dropping all +`(S,C)` state made counters reusable, so a new local set/clear pair +restarting from `S=0,C=0` could be dominated by a delayed stale peer +snapshot on replay (`RegB(3,0,10)` → compact → `None` → local +set+clear → `RegB(1,2,20)` → stale replay merges in → `RegB(3,2,20)`, +`S>C`, resurrected). Fixed by a tombstone floor: any register with +recorded activity (S>0 or C>0) is *never* fully deleted — dead state +compacts to `RegB(0, max(S,C), 0)` instead of `None`. Only a virgin +register (never set, S==0 and C==0) has no ceiling to protect and +compacts to `None`. + +**The compaction rule is now uniform across the dead cases — the +per-branch table collapses to a single test:** + +| Condition | Clear-wins | Set-wins | +|-----------|-----------|----------| +| `override_set_b(reg)` is True (live) | Do not compact | Do not compact | +| `override_set_b(reg)` is False and `S>0 or C>0` (dead, ever-active) | Compact to tombstone floor `RegB(0, max(S,C), 0)` | Compact to tombstone floor (same) | +| `S == 0, C == 0` (virgin, never set) | Drop entirely (`None`) | Drop entirely (same) | + +Because `override_set_b` is already policy-aware, "live" vs. "dead" +differs by policy exactly where it did before (`S == C, S > 0` is dead +under clear-wins, live under set-wins) — the tombstone floor rule itself +does not need to branch on policy; `compact_b` calls `override_set_b` +once and only tombstones the false branch. + +Under clear-wins, a dead override compacts to the ~45-byte tombstone +(one `ov_c:` key, channel context) — **not** to zero, because `C` must +persist as the reuse-blocking ceiling. Under set-wins, `S == C` overrides +remain live and are never compacted (3 keys, ~138 bytes for channel +contexts) — unchanged from the prior revision. + +**Proof obligation closed (same-device replay):** +`exhaustive.py::test_deep_history_compaction` (672-point parameter +cube) and `test_tombstone_stale_merge_direct` verify no resurrection +and no loss of a genuinely-live override across the compact → +new-action → delayed-stale-delivery shape, for both tie policies. +`mutation.py::mutant_m4` +reverts to the old delete-on-dominance rule and reproduces the exact +resurrection witness (`final_reg=RegB(s=3, c=2, b=20)`, +`override_is_set=True`) — confirming the suite would have caught the +defect this round was opened to fix. + +**Proof obligation closed (cross-device transparency, requalified — +suppress-only, not zero-divergence):** +`exhaustive.py::test_cross_device_compaction_suppression` (312-point +cube: stale ancestor `(S,C,B)` × post-compaction frontier × 4 +fresh-frontier values on the receiving device × 2 tie policies) proves +every divergence between "receive the tombstone" and "receive the +uncompacted ancestor" is a suppression of an unrelated device's live +set — never a resurrection — and that every suppression recovers with +one more local mark-unread and stays recovered after re-receiving the +same tombstone. `test_tombstone_merge_monotonic` proves the direction +structurally (not just over the bounded cube): merging in a tombstone +`RegB(0, k, 0)` for any ceiling `k` can only raise the receiving +register's `C`, never its `S` or `B`, so it can only weaken — never +strengthen — the receiving register's live/dead standing under +`override_set_b`. Together these close the compaction-safety proof +obligation to exactly what it can honestly claim: no resurrection ever, +one-shot suppression is a known and recoverable false-negative risk +inherent to the clear-wins/tombstone design, not an unbounded +correctness gap. + +### GC/tombstone behavior + +**Override keys with `ov_` prefix (legacy prune):** Legacy +`pruneStaleContexts` only drops `msg:`/`thread:`-prefixed keys past the +7-day horizon. Unknown-prefix keys (including `ov_*`) are kept forever: + +- **Permanent tombstones:** every override that is ever compacted while + dead leaves a permanent `ov_c:` key (~45 bytes, channel context) — this + is no longer a "harmless, can shrink to zero" cost; it is a durable + floor kept forever to block stale-replay resurrection. This is the + direct storage consequence of fixing the CRITICAL above and must be + budgeted, not treated as free. +- **Live overrides:** an override still live (per `override_set_b`) + keeps all 3 keys (~138 bytes, channel context) until it becomes dead + and is compacted down to the tombstone. + +**Alternative: nesting under `msg:`/`thread:` prefixes** — confirmed +**state-loss hazard**. Legacy prune would delete overrides at the 7-day +horizon, silently losing active unread markers. Rejected. + +### Legacy trim interaction + +Legacy `trimContextsToBudget` evicts only `msg:`/`thread:` keys. +Override `ov_*` keys (including tombstones) are never evicted. Budget +analysis by context type, worst case (all overrides still live, 3 keys +each — the tombstone floor only ever *reduces* this cost): + +| Overridden contexts | Context type | Live override bytes | With ~10 KiB frontiers | Fits 32 KiB? | +|--------------------|-------------|----------------|----------------------|-------------| +| 50 | Channel (UUID) | ~6.9 KiB | ~16.9 KiB | Yes | +| 100 | Channel (UUID) | ~13.8 KiB | ~23.8 KiB | Yes | +| 150 | Channel (UUID) | ~20.7 KiB | ~30.7 KiB | Marginal | +| 50 | Message (hex64) | ~11.9 KiB | ~21.9 KiB | Yes | +| 100 | Message (hex64) | ~23.7 KiB | ~33.7 KiB | **No** | + +At the 100-override cap with every override compacted to its tombstone +floor instead: ~4.5 KiB (channel contexts, 100 × 45 bytes) — well +within budget alongside a full frontier set. The permanent-tombstone +floor from the CRITICAL fix costs storage but is bounded and small; it +does not change the 32 KiB conclusion below. + +**Mitigation:** Upgraded clients should compact aggressively (any dead +override, not just baseline-dominated ones) and enforce a cap on active +override count. A cap of 100 channel-context overrides keeps *live* +override budget under ~14 KiB and *tombstoned* budget under ~4.5 KiB, +both within the 32 KiB limit alongside a full frontier set. + +### Tie policy evidence: clear-wins vs set-wins + +Both tie policies pass all invariants. The choice is a product-semantics +decision: + +- **Clear-wins (S == C → read):** If two devices concurrently set and + clear the same context, the result is "read." Conservative — no + spurious unread badges. Matches the "I already read this" signal being + more definitive than the "remind me" signal. Compaction advantage: + `S == C` states are compactable. +- **Set-wins (S == C → unread):** Concurrent set and clear results in + "unread." Preserves the reminder intent. Risk: a user who reads on one + device while another has a stale mark-unread gets a persistent badge + they can't clear without an explicit action. Compaction disadvantage: + `S == C` states are live and cannot be compacted. + +**Recommendation:** Clear-wins. A false negative (missing badge) is +recovered by re-marking unread. A false positive (badge that won't clear) +is more frustrating. This matches Slack's behavior: reading anywhere +clears everywhere. The compaction advantage further favors clear-wins. + +**Pre-existing false-negative risk (independent of compaction).** Under +clear-wins, a stale explicit clear (`RegB(0,1,0)`, no compaction +involved) merging into a device with a fresh concurrent set +(`RegB(1,0,30)`) already produces `RegB(1,1,30)`, tied, suppressed — +verified directly by evaluating `merge_reg_b`/`override_set_b` on those +two registers with no `compact_b` call anywhere in the path. The +cross-device tombstone-suppression finding (I5d, "Compaction behavior" +above) is the same tie shape reached via a different route: a +baseline-dominated *dead set* (never explicitly cleared) that gets +compacted to a `C`-ceiling tombstone, which is then globally comparable +in a way its pre-compaction, frontier-relative death was not. Compaction +widens the set of histories that can reach the tie, but clear-wins +already accepted this one-shot, re-mark-recoverable false-negative shape +as its stated tradeoff. + +### Multi-slot union + +Production splits blobs across up to 8 slots (`READ_STATE_MAX_SLOTS`). +`mergeReadStateEvents` merges all slots with per-context `max()`. Override +sibling keys are individual context entries and follow the same merge path. + +**Atomic slot-grouping rule (spec-amendment requirement):** a context's +frontier entry and ALL of its `ov_*` sibling entries MUST travel in the +same slot, including during slot growth/rebalancing. This is the transport +half of the same closure property as mandatory canonical publication: + +- Without it, an observer holding only a slot containing `ov_s:ctx` (but + not `ov_b:ctx`) reconstructs `RegB(s=1, c=0, b=0)` — baseline-dead at + any nonzero frontier — and canonically publishes tombstone `RegB(0,1,0)`. + After full eventual delivery of all original slots plus that transient + tombstone, the merged result is `RegB(s=1, c=1, b=10)` — dead under + clear-wins — permanently suppressing a live override. +- With the rule, a receiver always sees either the complete register group + or none of it; partial reconstruction is structurally impossible from a + compliant publisher's output. + +Implementation: amend `splitContextsIntoBudgetedSlots` to round-robin +per-context groups (frontier key + all `ov_*` sibling keys for that context) +rather than per-entry. `DeviceB.split_blob_into_slots` in `model.py` models +this correctly. + +**Unescape-before-group rule (corollary — spec-amendment requirement):** +When grouping context entries, a frontier wire key MUST be unescaped to its +raw logical context ID before being used as the group key. A raw context ID +starting with a reserved prefix (e.g. `ov_s:evil`) escapes to +`esc:ov_s:evil` as its frontier wire key, while its `ov_*` siblings are +keyed by the raw suffix (`ov_s:evil`). Without unescaping the frontier key +before grouping, these resolve to different groups and the register splits +across slots — reproducing the same partial-reconstruction poison across +publication cycles via old/new slot-coordinate mixtures. Fix: derive group +identity via `unescape_context_key(wire_key)` for frontier keys. +`mutation.py::mutant_m9` reverts to escaped-key grouping and confirms +`test_escaped_context_slot_grouping` catches the witness. + +`mutation.py::mutant_m8` reverts to per-entry splitting (M8's split puts +frontier+`ov_s:` in slot 0 and `ov_b:`+`ov_c:` in slot 1) and confirms +`test_interleaved_delivery_grouping` catches Thufir's exact witness. + +This rule carries the same normative weight as mandatory canonical publication: +both are protocol requirements for any client implementing this override layer, +not optional optimizations. + +Confirmed: splitting a published blob across 2 grouped slots and delivering +each separately produces the same final override and frontier state as +delivering the full blob, regardless of delivery order. Interleaved-delivery +test (`test_interleaved_delivery_grouping`) additionally verifies that +receive-one-slot → re-publish → receive-rest permutations, including delayed +transient delivery to a third observer, preserve the live override verdict. + +## Mutation harness + +9 mutants, all caught with recorded counterexamples: + +| Mutant | Rule dropped | Counterexample | +|--------|-------------|----------------| +| M1 | Baseline dominance check | `RegB(1,0,10)` at frontier=100: correct=inactive, mutant=active (stale set persists) | +| M2 | `max(S,C)+1` counter bump | After set→set→clear: correct `RegB(2,3,10)` (clear wins), mutant `RegB(2,1,10)` (set persists) | +| M3 | Tie policy | `RegB(1,1,10)` at frontier=10: clear-wins=False, set-wins=True | +| M4 | Tombstone-floor compaction (delete-on-dominance revert) | `RegB(3,0,10)` at frontier=20 compacts to `None` (vs. tombstone `RegB(0,3,0)`); local set+clear reuses counters from zero; delayed stale replay resurrects — `final_reg=RegB(s=3,c=2,b=20)`, `override_is_set=True` (reproduces Thufir's pass-3 CRITICAL) | +| M5 | uint32 value range | Value 4,294,967,296 rejected by legacy sanitization | +| M6 | Componentwise-max merge | LWW delivery-order-dependent: convergence breaks under permutation | +| M7 | Canonical publication (raw register serialization) | `RegB(3,2,0)`@frontier-50 join `RegB(1,2,100)`@frontier-100 = live `RegB(3,2,100)` (reproduces Thufir's pass-1/2 CRITICAL dead+dead resurrection) | +| M8 | Atomic slot-grouping rule (per-entry split) | Live `RegB(1,0,10)` at frontier=10 split as `{frontier+ov_s:}` / `{ov_b:+ov_c:}`; partial observer reconstructs `RegB(1,0,0)`, publishes tombstone `RegB(0,1,0)`; final merge = `RegB(1,1,10)` → inactive (reproduces Thufir's pass-2/2 CRITICAL transport witness) | +| M9 | Unescape-before-group rule (escaped-key grouping) | Live override on raw ctx `ov_s:evil` (frontier wire key `esc:ov_s:evil`); escaped-key grouping splits frontier from `ov_*` siblings; old/new slot-coordinate mixture → `RegB(1,0,0)` → tombstone `RegB(0,1,0)` → final merge = `RegB(1,1,10)` → inactive (reproduces Thufir's round-2 CRITICAL) | + +Each mutant is injected into the model via DeviceB subclass (M1, M2, M4, +M6, M7, M8, M9) or direct function evaluation (M3, M5), then the applicable +invariant suite is rerun. M4 reverts to the pre-fix delete-on-dominance +compaction rule and directly reproduces Thufir's pass-3 CRITICAL resurrection +witness — the exact `RegB(3,0,10)` → `None` → counter-reuse → stale +replay → `RegB(3,2,20)`,`override_is_set=True` sequence — with a +fallback to the directed deep-history cube (`test_deep_history_compaction`) +if the hand-built scenario doesn't trigger under a given tie policy. M7 +reverts `publish_blob` to raw serialization and reproduces Thufir's pass-1/2 +CRITICAL dead+dead resurrection. M8 reverts `split_blob_into_slots` to +per-entry assignment (frontier+`ov_s:` / `ov_b:`+`ov_c:`) and reproduces +Thufir's pass-2/2 CRITICAL transport witness via `test_interleaved_delivery_grouping`. +M9 reverts `split_blob_into_slots` to escaped-key grouping (groups frontier by +its wire key instead of its unescaped logical ID) and reproduces Thufir's +round-2 CRITICAL for escaped contexts via `test_escaped_context_slot_grouping`. + +## Recommendation + +**Candidate B (two grow-only counters + baseline) with clear-wins tie +policy.** + +Evidence: + +1. **Legacy safety:** B's sibling keys survive legacy rewrite; A's + top-level field is erased. Hard blocker for A — no migration path + tolerates a single legacy device. +2. **Identity-free:** B needs no client_id for correctness; A's + tiebreak creates a reinstall fragility. +3. **CRDT properties:** Candidate B passes all merge invariants (I2–I8) in + the exhaustive model. Candidate A's join is also correct algebraically + (I1, I9), but I2–I4 and I6 are not exercised for A — A is dead on I7 + regardless. B's componentwise max is simpler and more standard. +4. **Bytes:** B at 3 live keys costs 138 bytes/context (channel UUID) to + 243 bytes/context (thread hex64); a dead override compacts to a single + ~45-80 byte tombstone key instead. Cap of 100 overrides stays within + 32 KiB budget for both live and tombstoned cases. +5. **Compaction:** B supports safe policy-aware compaction — no + resurrection, ever (proved structurally, not just over a bounded + cube). Clear-wins allows compacting `S == C` states (set-wins does + not). Cross-device delivery of a tombstone can one-shot suppress an + unrelated device's concurrent fresh set whose counters are at or + below the tombstone's ceiling; this is recoverable by re-marking and + is the same false-negative shape clear-wins already accepts for a + stale explicit clear with no compaction involved (see "Tie policy + evidence"). +6. **Tie policy:** Clear-wins avoids persistent false-positive badges + and enables more aggressive compaction. + +## Honest limits + +- The model enumerates bounded abstract operations, not real encrypted + NIP-59 payloads or relay replacement semantics. +- Counter values in the general BFS explorer are bounded by its exploration + depth (max ~4 via BFS depth 4); the directed deep-history cube + (`test_deep_history_compaction`) reaches counter values up to the stale + parameter range (0-3) plus post-compaction action sequences, covering the + ~9-transition witness the BFS explorer cannot structurally reach. Real + uint32 overflow/wrap is tested only via the legacy sanitization mutant (M5). +- The BFS explorer (I5/I5c) checks compaction safety over reachable + multi-device histories up to depth 4, but its own terminal-state + compaction check (`check_compaction_safety`) only merges a device's + compacted register with its *own* pre-compaction snapshot — it does + not, by construction, exercise an unrelated device's independently- + live concurrent register. `test_cross_device_compaction_suppression` + (I5d) covers that shape directly but over a hand-parameterized cube, + not the full BFS state space; the accompanying + `test_tombstone_merge_monotonic` lemma is what extends the + no-resurrection guarantee beyond the cube's specific points. +- Two contexts are modeled. Production users may have hundreds of contexts, + but the CRDT properties are per-context — cross-context interactions are + limited to the shared byte budget (tested via trim/prune interaction). +- Multi-slot behavior is confirmed via split+merge convergence test, and + the atomic slot-grouping rule is modeled by `DeviceB.split_blob_into_slots` + (including the escaped-context identity fix — `split_blob_into_slots` + unescapes frontier keys before grouping). The production TypeScript + implementation (`splitContextsIntoBudgetedSlots`) is NOT modeled — only + the abstract grouping property is verified here. Implementation-level + testing is still needed for slot placement, slot rebalancing, and the + production d-tag coordinate assignment. +- The model assumes eventual delivery (all blobs eventually reach all + devices). Permanent message loss is not modeled. +- Byte sizes are computed from JSON serialization of realistic key names. + Actual encrypted blob overhead (NIP-59 envelope, relay metadata) adds + to the total but does not affect the 32 KiB plaintext budget. diff --git a/docs/formal/nip-rs-unread/exhaustive.py b/docs/formal/nip-rs-unread/exhaustive.py new file mode 100644 index 0000000000..82d54c6b43 --- /dev/null +++ b/docs/formal/nip-rs-unread/exhaustive.py @@ -0,0 +1,1486 @@ +"""Bounded transition-system explorer for NIP-RS manual-unread candidates. + +BFS over canonical global states. At each depth, enabled transitions are +local actions and message deliveries, interleaved — not phased. + +Universe: 2 upgraded devices + 1 legacy device, 2 contexts. +Transitions: mark_unread, mark_read (with frontier advance), + advance_frontier, compact, reinstall, deliver (including + duplicate/replay). Legacy rewrite semantics are exercised through the + deliver path (legacy_sanitize_and_publish), not as a separate + transition — a legacy device never mutates its own state outside + delivery, so a dedicated no-op transition added nothing (see NOTE.md). + +Invariants: + I1 merge_reg_b associative/commutative/idempotent + I2 convergence: all delivery orders -> identical override verdict + I3 no frontier regression + I4 concurrent set/clear winner stable (order-independent, ancestor-independent) + I5 compaction: no loss of live set, no resurrection of dead clear, + survives merge with stale pre-compaction state + I5c directed deep-history: compact -> new local actions (counter reuse) + -> delayed stale delivery does not resurrect a dead override or + lose a genuinely-live one. Scope: the compacting device's own + pre-compaction ancestor (or an exact copy of it) replayed back to + that same device. + I5d cross-device compaction transparency (requalified, NOT + zero-divergence): a tombstone's counter ceiling can one-shot + suppress an unrelated device's concurrent fresh set with no + resurrection, and the suppression is always recoverable by one + more local action. Proven suppress-only direction via a bounded + witness cube plus a structural monotonicity argument. + I6 replay harmless + I7 legacy rewrite: B sibling keys survive / A overrides erased (witness) + I8 bounded key growth per context + I9 DeviceA post-receive counter absorption +""" +from itertools import permutations +from copy import deepcopy +from model import ( + RegB, merge_reg_b, override_set_b, compact_b, + RegA, merge_reg_a, + DeviceB, DeviceA, + SET, CLEAR, + legacy_prune, legacy_trim, legacy_sanitize_blob, + escape_context_key, unescape_context_key, ESCAPE_PREFIX, +) + +CONTEXTS = ("c0", "c1") +FRONTIER_VALS = (10, 20) + + +# --------------------------------------------------------------------------- +# I1: algebraic properties +# --------------------------------------------------------------------------- + +def test_merge_algebra_b(): + vals = [0, 1, 2, 3] + regs = [RegB(s, c, b) for s in vals for c in vals for b in vals] + violations = [] + for a in regs: + if merge_reg_b(a, a) != a: + violations.append(("idempotent", a)) + for a in regs: + for b in regs: + if merge_reg_b(a, b) != merge_reg_b(b, a): + violations.append(("commutative", a, b)) + for a in regs: + for b in regs: + for c in regs: + if merge_reg_b(merge_reg_b(a, b), c) != merge_reg_b(a, merge_reg_b(b, c)): + violations.append(("associative", a, b, c)) + return violations + + +def test_merge_algebra_a(): + vals = [0, 1, 2] + tiebreaks = ["a", "b"] + ops = [SET, CLEAR] + baselines = [0, 10] + regs = [RegA(ct, t, o, bl) + for ct in vals for t in tiebreaks for o in ops for bl in baselines] + violations = [] + for tie_op in [CLEAR, SET]: + for a in regs: + if merge_reg_a(a, a, tie_op) != a: + violations.append(("idempotent", tie_op, a)) + for a in regs: + for b in regs: + if merge_reg_a(a, b, tie_op) != merge_reg_a(b, a, tie_op): + violations.append(("commutative", tie_op, a, b)) + for a in regs: + for b in regs: + for c in regs: + ab_c = merge_reg_a(merge_reg_a(a, b, tie_op), c, tie_op) + a_bc = merge_reg_a(a, merge_reg_a(b, c, tie_op), tie_op) + if ab_c != a_bc: + violations.append(("associative", tie_op, a, b, c)) + return violations + + +# --------------------------------------------------------------------------- +# BFS state explorer — Candidate B +# --------------------------------------------------------------------------- + +def next_frontier(device, ctx): + cur = device.effective_frontier(ctx) + for fv in FRONTIER_VALS: + if fv > cur: + return fv + return None + + +def enabled_transitions(devices, tie_policy): + """Generate (kind, args) tuples for all enabled transitions.""" + trans = [] + for di, d in enumerate(devices): + for ctx in CONTEXTS: + if not d.is_legacy: + trans.append(("mark_unread", di, ctx)) + fv = next_frontier(d, ctx) + if fv is not None: + trans.append(("mark_read", di, ctx, fv)) + if ctx in d.overrides: + trans.append(("compact", di, ctx)) + fv = next_frontier(d, ctx) + if fv is not None: + trans.append(("advance", di, ctx, fv)) + if not d.is_legacy: + trans.append(("reinstall", di)) + for si in range(len(devices)): + for di in range(len(devices)): + if si != di: + trans.append(("deliver", si, di)) + return trans + + +def apply_transition(devices, t, tie_policy): + kind = t[0] + if kind == "mark_unread": + devices[t[1]].do_mark_unread(t[2]) + elif kind == "mark_read": + devices[t[1]].do_mark_read(t[2], t[3]) + elif kind == "advance": + devices[t[1]].do_advance_frontier(t[2], t[3]) + elif kind == "compact": + devices[t[1]].do_compact(t[2], tie_policy) + elif kind == "reinstall": + devices[t[1]].do_reinstall() + elif kind == "deliver": + src = devices[t[1]] + dst = devices[t[2]] + if src.is_legacy: + blob = src.legacy_sanitize_and_publish(tie_policy) + else: + blob = src.publish_blob(tie_policy) + dst.receive_merge(blob) + + +def state_sig(devices, tie_policy): + return tuple(d.state_key(CONTEXTS, tie_policy) for d in devices) + + +def check_convergence(devices, tie_policy, trace, violations): + """Publish all blobs, deliver in every order, check upgraded devices + converge on override_is_set for each context. + + Tests with latest_ts=5 (below all frontiers) so the override is the + sole unread source — no masking by natural unread. + """ + blobs = [] + for d in devices: + if d.is_legacy: + blobs.append(d.legacy_sanitize_and_publish(tie_policy)) + else: + blobs.append(d.publish_blob(tie_policy)) + + verdicts_per_order = [] + for perm in permutations(range(len(blobs))): + receivers = deepcopy(devices) + for idx in perm: + for r in receivers: + r.receive_merge(blobs[idx]) + per_device = [] + for r in receivers: + if not r.is_legacy: + per_device.append( + tuple(r.override_is_set(ctx, tie_policy) for ctx in CONTEXTS) + ) + verdicts_per_order.append(tuple(per_device)) + + if len(set(verdicts_per_order)) > 1: + violations.append(("I2-convergence", trace, set(verdicts_per_order))) + + +def check_compaction_safety(devices, tie_policy, trace, violations): + """For each upgraded device with overrides: + 1. Check override_is_set directly (not via verdict/latest_ts). + 2. Compact and verify override_is_set unchanged. + 3. Merge compacted state with stale pre-compaction state in both orders. + Verify no resurrection and no loss. + """ + for di, d in enumerate(devices): + if d.is_legacy: + continue + for ctx in CONTEXTS: + reg = d.overrides.get(ctx) + if reg is None: + continue + front = d.effective_frontier(ctx) + ov_before = d._override_set(reg, front, tie_policy) + compacted = d._compact(reg, front, tie_policy) + ov_after = d._override_set(compacted, front, tie_policy) if compacted else False + + if ov_before and not ov_after: + violations.append(( + "I5-compaction-lost-set", trace, di, ctx, + reg, compacted, front, tie_policy + )) + if not ov_before and ov_after: + violations.append(( + "I5-compaction-resurrection", trace, di, ctx, + reg, compacted, front, tie_policy + )) + + if compacted is not None: + for merged in [merge_reg_b(compacted, reg), merge_reg_b(reg, compacted)]: + ov_merged = d._override_set(merged, front, tie_policy) + if not ov_before and ov_merged: + violations.append(( + "I5-compaction-merge-resurrection", trace, di, ctx, + reg, compacted, merged + )) + + +def explore_b(max_depth=4, tie_policy=CLEAR, device_cls=DeviceB): + """BFS over all reachable global states up to max_depth. + + Returns (states_explored, violations). + Accepts device_cls for mutation testing via subclassing. + """ + def make_devices(): + return [ + device_cls("d0", is_legacy=False), + device_cls("d1", is_legacy=False), + device_cls("d2", is_legacy=True), + ] + + violations = [] + seen = set() + states_explored = 0 + queue = [(make_devices(), [])] + + while queue: + devices, trace = queue.pop(0) + sig = state_sig(devices, tie_policy) + if sig in seen: + continue + seen.add(sig) + states_explored += 1 + + for di, d in enumerate(devices): + for ctx in CONTEXTS: + prev_front = d.effective_frontier(ctx) + if prev_front < 0: + violations.append(("I3-frontier-negative", trace, di, ctx)) + + if len(trace) >= max_depth: + check_convergence(devices, tie_policy, trace, violations) + check_compaction_safety(devices, tie_policy, trace, violations) + continue + + for t in enabled_transitions(devices, tie_policy): + new_devs = deepcopy(devices) + fronts_before = { + (di, ctx): d.effective_frontier(ctx) + for di, d in enumerate(new_devs) for ctx in CONTEXTS + } + apply_transition(new_devs, t, tie_policy) + + # Reinstall intentionally wipes local state; frontier regression + # is only invalid during merge/delivery/compaction/advance. + if t[0] != "reinstall": + for (di, ctx), fb in fronts_before.items(): + fa = new_devs[di].effective_frontier(ctx) + if fa < fb: + violations.append(("I3-frontier-regression", trace + [t], di, ctx, fb, fa)) + + queue.append((new_devs, trace + [t])) + + return states_explored, violations + + +# --------------------------------------------------------------------------- +# I4: concurrent set/clear winner stable +# --------------------------------------------------------------------------- + +def test_concurrent_stability(device_cls=DeviceB): + """Two devices concurrently set and clear from every possible ancestor state. + The winner must be the same regardless of delivery order AND ancestor state.""" + violations = [] + for tie_policy in [CLEAR, SET]: + for pre_s, pre_c in [(0, 0), (1, 0), (0, 1), (2, 1), (1, 2), (1, 1)]: + for front in [0, 10]: + for ctx in CONTEXTS: + ancestor = RegB(s=pre_s, c=pre_c, b=front) + + d0 = device_cls("d0") + d0.frontier[ctx] = front + d0.overrides[ctx] = deepcopy(ancestor) + d1 = device_cls("d1") + d1.frontier[ctx] = front + d1.overrides[ctx] = deepcopy(ancestor) + + d0.do_mark_unread(ctx) + d1.do_mark_read(ctx, front + 10) + + blob0 = d0.publish_blob(tie_policy) + blob1 = d1.publish_blob(tie_policy) + + verdicts = set() + for first, second in [(blob0, blob1), (blob1, blob0)]: + r = device_cls("recv") + r.frontier[ctx] = front + r.overrides[ctx] = deepcopy(ancestor) + r.receive_merge(first) + r.receive_merge(second) + verdicts.add(r.override_is_set(ctx, tie_policy)) + + if len(verdicts) > 1: + violations.append(( + "I4-unstable", tie_policy, ctx, + pre_s, pre_c, front + )) + return violations + + +# --------------------------------------------------------------------------- +# I5: direct compaction register-level check (all register values x policies) +# --------------------------------------------------------------------------- + +def test_compaction_register_exhaustive(): + """Exhaustive check over bounded register cube and frontier values. + Tests override_is_set directly — no latest_ts masking.""" + violations = [] + vals = [0, 1, 2, 3] + frontiers = [0, 10, 20] + for tie_policy in [CLEAR, SET]: + for s in vals: + for c in vals: + for b in frontiers: + for fv in frontiers: + reg = RegB(s=s, c=c, b=b) + ov_before = override_set_b(reg, fv, tie_policy) + compacted = compact_b(reg, fv, tie_policy) + ov_after = (override_set_b(compacted, fv, tie_policy) + if compacted else False) + + if ov_before and not ov_after: + violations.append(( + "loss", tie_policy, reg, fv, compacted + )) + if not ov_before and ov_after: + violations.append(( + "resurrection", tie_policy, reg, fv, compacted + )) + + if compacted is not None: + merged_fwd = merge_reg_b(compacted, reg) + merged_rev = merge_reg_b(reg, compacted) + for label, merged in [("fwd", merged_fwd), ("rev", merged_rev)]: + ov_merged = override_set_b(merged, fv, tie_policy) + if not ov_before and ov_merged: + violations.append(( + f"merge-resurrection-{label}", + tie_policy, reg, fv, compacted, merged + )) + return violations + + +# --------------------------------------------------------------------------- +# I5c: directed deep-history — compact -> new actions (counter reuse) -> +# delayed stale delivery (including split across two slots) +# --------------------------------------------------------------------------- + +def _apply_action_seq(dev, ctx, seq, ts): + for a in seq: + if a == "set": + dev.do_mark_unread(ctx) + else: + dev.do_mark_read(ctx, ts) + + +def _ancestor_ctx_dict(ctx, reg): + return {f"ov_s:{ctx}": reg.s, f"ov_c:{ctx}": reg.c, f"ov_b:{ctx}": reg.b} + + +_DEEP_HISTORY_ACTION_SEQS = [ + (), ("set",), ("clear",), ("set", "clear"), ("clear", "set"), + ("set", "set"), ("clear", "clear"), +] +# One delivery shape: single unsplit blob. The prior "split_fwd"/"split_rev" +# shapes are no longer distinct — with the atomic-grouping rule a single- +# context blob's compliant split puts the whole group in one slot and the +# other empty, making split_fwd and split_rev semantically identical to +# single. Keeping only one shape avoids 2/3 duplicate executions (2,016 → +# 672 meaningful points) while losing zero register-level coverage. +_DEEP_HISTORY_DELIVERY_SHAPES = ("single",) + + +def test_deep_history_compaction(device_cls=DeviceB): + """Directed check over the exact shape a depth-4 BFS structurally + cannot reach (~9 transitions): compact -> new local set/clear actions + (counter reuse against the tombstone floor) -> delayed delivery of + the pre-compaction stale ancestor, including split across 2 slots. + + Oracle: compaction is a storage optimization and must never change + the semantic outcome. A reference device that never compacts, given + the identical ancestor / frontier advance / action sequence / late + ancestor delivery, must reach the same override_is_set verdict as + the compacting device. This directly targets Thufir's counterexample + (RegB(3,0,10) -> None under delete-on-dominance -> counter reuse -> + RegB(3,2,20) resurrection) and requires the tombstone floor from + compact_b to hold under it. + + Returns (cube_size, violations). + """ + violations = [] + cube_size = 0 + ctx = "c0" + stale_vals = (0, 1, 2, 3) + baselines = (0, 10) + post_frontiers = (10, 20) + + for s0 in stale_vals: + for c0 in stale_vals: + for b0 in baselines: + for f1 in post_frontiers: + if f1 <= b0: + continue # not a dominance/compaction scenario + ancestor = RegB(s=s0, c=c0, b=b0) + ancestor_blob = _ancestor_ctx_dict(ctx, ancestor) + for seq in _DEEP_HISTORY_ACTION_SEQS: + for tie_policy in (CLEAR, SET): + for shape in _DEEP_HISTORY_DELIVERY_SHAPES: + cube_size += 1 + + dev = device_cls("d0") + dev.frontier[ctx] = b0 + dev.overrides[ctx] = ancestor + dev.do_advance_frontier(ctx, f1) + dev.do_compact(ctx, tie_policy) + _apply_action_seq(dev, ctx, seq, f1) + + if shape == "single": + dev.receive_merge({"contexts": dict(ancestor_blob)}) + ov_after = dev.override_is_set(ctx, tie_policy) + + ref = device_cls("ref") + ref.frontier[ctx] = b0 + ref.overrides[ctx] = ancestor + ref.do_advance_frontier(ctx, f1) + _apply_action_seq(ref, ctx, seq, f1) + ref.receive_merge({"contexts": dict(ancestor_blob)}) + ov_ref = ref.override_is_set(ctx, tie_policy) + + if ov_after != ov_ref: + violations.append(( + "I5c-deep-history-divergence", tie_policy, shape, + ancestor, f1, seq, + f"compacted_path={ov_after}", f"reference={ov_ref}", + )) + return cube_size, violations + + +def test_tombstone_stale_merge_direct(): + """Tombstone floor merged directly with its own pre-compaction stale + ancestor (no intervening local actions) must not resurrect and must + not exceed the ancestor's own verdict.""" + violations = [] + vals = (0, 1, 2, 3) + frontiers = (0, 10, 20) + for tie_policy in (CLEAR, SET): + for s in vals: + for c in vals: + for b in frontiers: + for fv in frontiers: + if fv <= b: + continue + reg = RegB(s=s, c=c, b=b) + compacted = compact_b(reg, fv, tie_policy) + if compacted is None: + continue # virgin register: nothing to tombstone + ov_before = override_set_b(reg, fv, tie_policy) + for merged in (merge_reg_b(compacted, reg), merge_reg_b(reg, compacted)): + ov_merged = override_set_b(merged, fv, tie_policy) + if not ov_before and ov_merged: + violations.append(( + "tombstone-stale-merge-resurrection", + tie_policy, reg, fv, compacted, merged, + )) + return violations + + +# --------------------------------------------------------------------------- +# I5d: cross-device compaction transparency (requalified — suppress-only, +# NOT zero-divergence) + re-mark recovery +# --------------------------------------------------------------------------- + +def test_tombstone_merge_monotonic(): + """Structural lemma: merging in ANY tombstone RegB(0, k, 0) is a + monotonically non-increasing function of the ceiling k in + override_set_b's boolean output, for a fixed receiving register and + frontier. A tombstone only ever adds to C (its S and B are both 0, + so max() with any receiving register leaves that register's own S + and B untouched) — raising C can only weaken S's relative standing, + never strengthen it. This is what makes resurrection structurally + impossible and suppression the only possible direction, independent + of any bounded cube. + """ + violations = [] + vals = (0, 1, 2, 3) + baselines = (0, 10, 20) + ceilings = (0, 1, 2, 3, 4, 5) + for tie_policy in (CLEAR, SET): + for s in vals: + for c in vals: + for b in baselines: + for fv in baselines: + x_reg = RegB(s=s, c=c, b=b) + prev = None + for k in ceilings: + merged = merge_reg_b(x_reg, RegB(s=0, c=k, b=0)) + cur = override_set_b(merged, fv, tie_policy) + if prev is not None and cur and not prev: + violations.append(( + "I5d-non-monotonic-ceiling", tie_policy, + x_reg, fv, k, merged, + )) + prev = cur + return violations + + +def test_cross_device_compaction_suppression(device_cls=DeviceB): + """Compaction is NOT semantically transparent cross-device (I5c only + covers the same-device replay shape). A tombstone re-encodes + baseline-dominated death — frontier-relative, doesn't transfer + across devices — as a clear-counter ceiling — globally comparable — + so it can one-shot suppress an unrelated device's concurrent fresh + set whose own counters don't exceed that ceiling. + + Witness (Paul's report, illustrative — the cube below tests nearby + parameter values `f_x` in `(5, 15, 25, 35)`, not the literal + `f_x=30` used in the original report; the shape is the same): + Y: mark_unread -> RegB(1,0,0); frontier->10 (dead) -> compact -> + tombstone RegB(0,1,0) + X: offline, fresh mark_unread at frontier 30 -> RegB(1,0,30), LIVE + X merges Y's tombstone -> RegB(1,1,30) -> suppressed (clear-wins) + Control (Y publishes the uncompacted RegB(1,0,0) instead): X stays + RegB(1,0,30), LIVE — the divergence is caused by compaction, not + by the merge itself. + + Proves over a bounded cube, both tie policies: every divergence + between "X merges Y's tombstone" and "X merges Y's uncompacted + ancestor" is a suppression (never a resurrection — that would + contradict test_tombstone_merge_monotonic), and every suppression + recovers with one more local mark-unread, stable under tombstone + replay. + + Returns (cube_size, suppress_count, violations). + """ + violations = [] + cube_size = 0 + suppress_count = 0 + dead_vals = (0, 1, 2, 3) + dead_baselines = (0, 10) + dead_post_frontiers = (10, 20) + fresh_frontiers = (5, 15, 25, 35) + + for tie_policy in (CLEAR, SET): + for s_y in dead_vals: + for c_y in dead_vals: + for b_y in dead_baselines: + for f_y in dead_post_frontiers: + if f_y <= b_y: + continue + ancestor = RegB(s=s_y, c=c_y, b=b_y) + tomb = compact_b(ancestor, f_y, tie_policy) + if tomb is None or tomb == ancestor: + continue # virgin, or was live (not compacted) + + for f_x in fresh_frontiers: + cube_size += 1 + x_reg = RegB(s=1, c=0, b=f_x) + x_before = override_set_b(x_reg, f_x, tie_policy) + if not x_before: + violations.append(( + "I5d-setup-not-live", tie_policy, x_reg, f_x, + )) + continue + + ov_tomb = override_set_b( + merge_reg_b(x_reg, tomb), f_x, tie_policy + ) + ov_ancestor = override_set_b( + merge_reg_b(x_reg, ancestor), f_x, tie_policy + ) + + if ov_tomb == ov_ancestor: + continue + if ov_tomb and not ov_ancestor: + violations.append(( + "I5d-resurrection-vs-ancestor", tie_policy, + ancestor, tomb, x_reg, f_x, + )) + continue + + suppress_count += 1 + dev = device_cls("x") + dev.frontier["c0"] = f_x + dev.overrides["c0"] = merge_reg_b(x_reg, tomb) + dev.do_mark_unread("c0") + if not dev.override_is_set("c0", tie_policy): + violations.append(( + "I5d-recovery-failed", tie_policy, + ancestor, tomb, x_reg, f_x, dev.overrides["c0"], + )) + continue + dev.receive_merge({"contexts": { + "ov_s:c0": tomb.s, "ov_c:c0": tomb.c, "ov_b:c0": tomb.b, + }}) + if not dev.override_is_set("c0", tie_policy): + violations.append(( + "I5d-recovery-not-replay-stable", tie_policy, + ancestor, tomb, x_reg, f_x, dev.overrides["c0"], + )) + + return cube_size, suppress_count, violations + + +# --------------------------------------------------------------------------- +# New invariant: published-state merge closure (Paul's fix-scope item 2, +# generalizing Thufir's pass-1/2 CRITICAL — dead+dead merge resurrection) +# --------------------------------------------------------------------------- + +def _dead_register_points(tie_policy): + """Bounded cube of (label, reg, frontier) points independently + verified DEAD (inactive) under `tie_policy` by the real + `override_set_b` predicate — the death cause (baseline dominance, + clear-count dominance, or clear-wins tie) is whatever the predicate + actually computes for that point, not asserted by construction. + """ + vals = (0, 1, 2, 3) + baselines = (0, 10, 50) + frontiers = (0, 20, 60, 100) + points = [] + for s in vals: + for c in vals: + if s == 0 and c == 0: + continue # virgin: not a "dead override" case + for b in baselines: + for fv in frontiers: + reg = RegB(s=s, c=c, b=b) + if override_set_b(reg, fv, tie_policy): + continue # live: out of scope for this invariant + points.append((f"s={s}c={c}b={b}fv={fv}", reg, fv)) + return points + + +def test_published_merge_closure(device_cls=DeviceB): + """Over reachable *published* states: joining any two individually- + inactive published states must remain inactive. + + This targets Thufir's pass-1/2 CRITICAL directly: a dead register's + death cause is frontier-relative (baseline dominance) or + device-local-history-relative (clear-count dominance), but the + componentwise-max join recombines each register's `S`/`C`/`B` + independent of the history that produced them, so two individually- + dead registers could — before canonical publication — recombine + into a live join. Canonicalizing every override to `RegB(0, + max(S,C), 0)` before serialization (this round's CRITICAL fix) + folds every dead cause into a single globally-comparable `C` + ceiling with `S=0`, which per `test_tombstone_merge_monotonic` can + only ever raise a receiver's `C` — never resurrect. + + Checked two ways: + - Directed case: Thufir's exact witness pair — `RegB(3,2,0)` + inactive via baseline dominance at frontier 50, `RegB(1,2,100)` + inactive via clear dominance at frontier 100 — whose raw + componentwise join is `RegB(3,2,100)`, live (`S=3>C=2`, + `frontier(100) not> B(100)`). Both tie policies. + - General search: every pairwise join of a bounded cube of + independently-dead `(reg, frontier)` points (see + `_dead_register_points`), delivered to a fresh receiver in both + direct orders and via a one-hop relay that itself republishes + (re-canonicalizes) what it received before forwarding — covering + delayed/multi-hop delivery, not just direct pairwise merge. + + Returns (cube_size, violations). + """ + violations = [] + cube_size = 0 + + def _check_pair(tie_policy, label_a, blob_a, label_b, blob_b, tag): + nonlocal cube_size + cube_size += 1 + for first, second in [(blob_a, blob_b), (blob_b, blob_a)]: + recv = device_cls("recv") + recv.receive_merge(first) + recv.receive_merge(second) + if recv.override_is_set("c0", tie_policy): + violations.append(( + tag, tie_policy, label_a, label_b, recv.overrides.get("c0"), + )) + # Multi-hop: a relay receives blob_a alone, republishes + # (re-canonicalizes) before forwarding, then the receiver gets + # the relayed form plus blob_b directly, in both orders. + relay = device_cls("relay") + relay.receive_merge(blob_a) + relayed = relay.publish_blob(tie_policy) + for first, second in [(relayed, blob_b), (blob_b, relayed)]: + recv = device_cls("recv_hop") + recv.receive_merge(first) + recv.receive_merge(second) + if recv.override_is_set("c0", tie_policy): + violations.append(( + tag + "-multihop", tie_policy, label_a, label_b, + recv.overrides.get("c0"), + )) + + # --- Directed case: Thufir's exact witness pair. --- + for tie_policy in (CLEAR, SET): + reg_a, front_a = RegB(s=3, c=2, b=0), 50 + reg_b, front_b = RegB(s=1, c=2, b=100), 100 + assert not override_set_b(reg_a, front_a, tie_policy) + assert not override_set_b(reg_b, front_b, tie_policy) + + dev_a = device_cls("a") + dev_a.frontier["c0"] = front_a + dev_a.overrides["c0"] = reg_a + dev_b = device_cls("b") + dev_b.frontier["c0"] = front_b + dev_b.overrides["c0"] = reg_b + + _check_pair( + tie_policy, f"thufir-witness-A={reg_a}@{front_a}", + dev_a.publish_blob(tie_policy), + f"thufir-witness-B={reg_b}@{front_b}", + dev_b.publish_blob(tie_policy), + "merge-closure-thufir-witness", + ) + + # --- General search over a bounded cube of dead published states. --- + for tie_policy in (CLEAR, SET): + points = _dead_register_points(tie_policy) + for label_a, reg_a, front_a in points: + dev_a = device_cls("a") + dev_a.frontier["c0"] = front_a + dev_a.overrides["c0"] = reg_a + blob_a = dev_a.publish_blob(tie_policy) + for label_b, reg_b, front_b in points: + dev_b = device_cls("b") + dev_b.frontier["c0"] = front_b + dev_b.overrides["c0"] = reg_b + blob_b = dev_b.publish_blob(tie_policy) + _check_pair( + tie_policy, label_a, blob_a, label_b, blob_b, + "merge-closure-cube", + ) + + return cube_size, violations + + +# --------------------------------------------------------------------------- +# I6: replay harmless +# --------------------------------------------------------------------------- + +def test_replay_harmless(device_cls=DeviceB): + violations = [] + for tie_policy in [CLEAR, SET]: + for ctx in CONTEXTS: + d = device_cls("d0") + d.frontier[ctx] = 10 + d.do_mark_unread(ctx) + blob = d.publish_blob(tie_policy) + state_before = ( + dict(d.frontier), + {k: v for k, v in d.overrides.items()}, + ) + d.receive_merge(blob) + d.receive_merge(blob) + d.receive_merge(blob) + state_after = ( + dict(d.frontier), + {k: v for k, v in d.overrides.items()}, + ) + if state_before != state_after: + violations.append(("I6-replay", tie_policy, ctx)) + return violations + + +# --------------------------------------------------------------------------- +# I7: legacy rewrite +# --------------------------------------------------------------------------- + +def test_legacy_rewrite_b(): + """B's sibling keys survive legacy sanitization (round-trip).""" + violations = [] + for ctx in CONTEXTS: + d = DeviceB("d0") + d.frontier[ctx] = 10 + d.do_mark_unread(ctx) + blob = d.publish_blob() + sanitized = legacy_sanitize_blob(blob) + + recv_orig = DeviceB("recv1") + recv_orig.receive_merge(blob) + recv_san = DeviceB("recv2") + recv_san.receive_merge(sanitized) + + for c in CONTEXTS: + if recv_orig.overrides.get(c) != recv_san.overrides.get(c): + violations.append(("I7-B-sanitize-mutated", c, + recv_orig.overrides.get(c), + recv_san.overrides.get(c))) + return violations + + +def test_legacy_erasure_a(): + """A's top-level overrides field is erased by legacy rewrite. Expected witness.""" + d = DeviceA("d0") + d.frontier["c0"] = 10 + d.do_mark_unread("c0") + blob = d.publish_blob() + assert "overrides" in blob + legacy_blob = {"v": 1, "client_id": "legacy", "contexts": dict(blob["contexts"])} + return "overrides" not in legacy_blob + + +# --------------------------------------------------------------------------- +# I8: bounded key growth +# --------------------------------------------------------------------------- + +def test_bounded_growth(): + """I8: bounded key growth, canonical wire shape. A live override + (last action = mark_unread, still within baseline) publishes + exactly 3 keys/ctx; a dead override (mark_read past baseline, or + C > S under clear-wins) canonicalizes to exactly 1 key/ctx + (`ov_c:` tombstone) at publish time — never 0 (virgin-only) or 3 + (dead-but-uncompacted, which the pre-fix serializer allowed). + """ + violations = [] + for ctx in CONTEXTS: + # Live: 100 set/clear round-trips, ending on a fresh mark_unread + # so S > C (live under both tie policies) at publish time. + d = DeviceB("d0") + d.frontier[ctx] = 10 + for _ in range(100): + d.do_mark_unread(ctx) + d.do_mark_read(ctx, d.effective_frontier(ctx) + 1) + d.do_mark_unread(ctx) + blob = d.publish_blob(CLEAR) + ov_keys = [k for k in blob["contexts"] if k.startswith("ov_")] + if ov_keys != [f"ov_s:{ctx}", f"ov_c:{ctx}", f"ov_b:{ctx}"]: + violations.append(("I8-growth-live", ctx, ov_keys)) + + # Dead: advance the frontier past baseline B — override_set_b's + # baseline-dominance clause forces S dead regardless of S vs C. + d.do_advance_frontier(ctx, d.effective_frontier(ctx) + 100) + tomb_blob = d.publish_blob(CLEAR) + tomb_keys = [k for k in tomb_blob["contexts"] if k.startswith("ov_")] + if tomb_keys != [f"ov_c:{ctx}"]: + violations.append(("I8-growth-tombstone", ctx, tomb_keys)) + return violations + + +def test_wire_shape_exact(): + """Exact wire-shape regression (Paul's fix-scope item 4): a live + override serializes to exactly 3 `ov_*` keys, a dead override to + exactly 1 (`ov_c:` only, zero-valued `ov_s`/`ov_b` omitted), and a + virgin override to exactly 0. Checked directly against + `publish_blob`'s output, independent of `do_compact`. + """ + violations = [] + for tie_policy in (CLEAR, SET): + # Live. + d_live = DeviceB("d0") + d_live.frontier["c0"] = 10 + d_live.do_mark_unread("c0") + live_blob = d_live.publish_blob(tie_policy) + live_keys = sorted(k for k in live_blob["contexts"] if k.startswith("ov_")) + if live_keys != ["ov_b:c0", "ov_c:c0", "ov_s:c0"]: + violations.append(("wire-shape-live", tie_policy, live_keys)) + + # Dead (clear-wins only: S==C>0 is dead under CLEAR, live under + # SET — use baseline dominance instead so it's dead under both). + d_dead = DeviceB("d0") + d_dead.frontier["c0"] = 10 + d_dead.do_mark_unread("c0") + d_dead.do_advance_frontier("c0", 100) + dead_blob = d_dead.publish_blob(tie_policy) + dead_keys = sorted(k for k in dead_blob["contexts"] if k.startswith("ov_")) + if dead_keys != ["ov_c:c0"]: + violations.append(("wire-shape-tombstone", tie_policy, dead_keys)) + if dead_blob["contexts"]["ov_c:c0"] != 1: + violations.append(( + "wire-shape-tombstone-ceiling", tie_policy, + dead_blob["contexts"]["ov_c:c0"], + )) + + # Virgin: no override ever set for this context. + d_virgin = DeviceB("d0") + d_virgin.frontier["c0"] = 10 + d_virgin.overrides["c0"] = RegB(s=0, c=0, b=0) + virgin_blob = d_virgin.publish_blob(tie_policy) + virgin_keys = [k for k in virgin_blob["contexts"] if k.startswith("ov_")] + if virgin_keys: + violations.append(("wire-shape-virgin", tie_policy, virgin_keys)) + + return violations + + +# --------------------------------------------------------------------------- +# I9: DeviceA counter absorption +# --------------------------------------------------------------------------- + +def test_a_counter_absorption(): + """After receiving a blob with counter=10, a local action must use counter>10.""" + d0 = DeviceA("d0") + d0.frontier["c0"] = 10 + d0.counter = 10 + d0.do_mark_unread("c0") + blob0 = d0.publish_blob() + + d1 = DeviceA("d1") + d1.frontier["c0"] = 10 + d1.receive_merge(blob0) + assert d1.counter >= 10, f"counter not absorbed: {d1.counter}" + + d1.do_mark_read("c0", 20) + reg = d1.overrides.get("c0") + assert reg is not None and reg.counter > 10, \ + f"post-receive clear at counter {reg.counter} would lose to set at 10" + return True + + +# --------------------------------------------------------------------------- +# Identity-free (B): reinstall convergence +# --------------------------------------------------------------------------- + +def test_b_identity_free(device_cls=DeviceB): + violations = [] + for tie_policy in [CLEAR, SET]: + for ctx in CONTEXTS: + d = device_cls("d0") + d.frontier[ctx] = 10 + d.do_mark_unread(ctx) + blob1 = d.publish_blob(tie_policy) + + d_re = device_cls("d0_reinstalled") + d_re.receive_merge(blob1) + d_re.do_mark_read(ctx, 20) + blob2 = d_re.publish_blob(tie_policy) + + verdicts = set() + for first, second in [(blob1, blob2), (blob2, blob1)]: + recv = device_cls("recv") + recv.receive_merge(first) + recv.receive_merge(second) + verdicts.add(recv.override_is_set(ctx, tie_policy)) + if len(verdicts) > 1: + violations.append(("identity-free", tie_policy, ctx)) + return violations + + +# --------------------------------------------------------------------------- +# Legacy prune/trim interaction +# --------------------------------------------------------------------------- + +def test_legacy_prune_interaction(): + """ov_ keys survive prune; msg:ov_ nested keys would be pruned (state loss).""" + base = {"c0": 50, "msg:m1": 30, "thread:t1": 40} + ov = {"ov_s:c0": 1, "ov_c:c0": 0, "ov_b:c0": 10} + all_keys = {**base, **ov} + pruned = legacy_prune(all_keys, horizon=35) + ov_survived = all(k in pruned for k in ov) + msg_pruned = "msg:m1" not in pruned + + nested = {"msg:ov_s:c0": 1, "msg:ov_c:c0": 0, "msg:ov_b:c0": 10} + pruned_nested = legacy_prune({**base, **nested}, horizon=35) + nested_lost = any(k not in pruned_nested for k in nested) + return ov_survived, msg_pruned, nested_lost + + +def test_legacy_trim_interaction(): + """Excess override keys block legacy publish when budget exceeded.""" + contexts = {"c0": 50} + for i in range(1000): + contexts[f"ov_s:c{i}"] = 1 + contexts[f"ov_c:c{i}"] = 0 + contexts[f"ov_b:c{i}"] = 10 + _, fits = legacy_trim(contexts, "client1", max_bytes=32768) + return not fits + + +# --------------------------------------------------------------------------- +# Multi-slot union +# --------------------------------------------------------------------------- + +def test_multi_slot_union(device_cls=DeviceB): + """Split a published blob across 2 slots using the atomic-grouping rule, + deliver each slot separately, verify convergence with delivering the full blob. + + Production: mergeReadStateEvents merges per-slot blobs with per-context + max(). Override sibling keys are individual context entries, so they + follow the same merge path. The atomic-grouping rule requires that all + `ov_*` sibling keys for a context travel with that context's frontier + key in the same slot — `split_blob_into_slots` enforces this. + """ + violations = [] + for tie_policy in [CLEAR, SET]: + dev = device_cls("d0") + dev.frontier["c0"] = 10 + dev.frontier["c1"] = 20 + dev.do_mark_unread("c0") + dev.do_mark_read("c1", 30) + + full_blob = dev.publish_blob(tie_policy) + slots = dev.split_blob_into_slots(tie_policy, n_slots=2) + slot0, slot1 = slots[0], slots[1] + + recv_full = device_cls("recv_full") + recv_full.receive_merge(full_blob) + + for first, second in [(slot0, slot1), (slot1, slot0)]: + recv_split = device_cls("recv_split") + recv_split.receive_merge(first) + recv_split.receive_merge(second) + + for ctx in CONTEXTS: + ov_full = recv_full.override_is_set(ctx, tie_policy) + ov_split = recv_split.override_is_set(ctx, tie_policy) + f_full = recv_full.effective_frontier(ctx) + f_split = recv_split.effective_frontier(ctx) + if ov_full != ov_split: + violations.append(("multi-slot-override", tie_policy, ctx)) + if f_full != f_split: + violations.append(("multi-slot-frontier", tie_policy, ctx)) + return violations + + +# --------------------------------------------------------------------------- +# Interleaved-delivery grouping: Thufir's CRITICAL transport counterexample +# +# Without the atomic-grouping rule a compliant publisher would still split +# ov_s:/ov_c:/ov_b: across slots as independent entries. M8's per-entry +# split places frontier+ov_s: in slot 0 and ov_b:+ov_c: in slot 1. An +# observer holding only slot 0 reconstructs RegB(1,0,0), judges it +# baseline-dead (B=0 ≤ frontier=10), and canonically publishes tombstone +# RegB(0,1,0). After all slots and that transient tombstone are eventually +# merged the result is RegB(1,1,10) — dead under clear-wins — +# permanently suppressing a live override. +# +# The atomic-grouping rule closes this: every ov_* entry for a context +# travels with the context's frontier entry, so a receiver always sees +# either the complete register or nothing. This test exercises both: +# - The PASS path: grouped slots → no false tombstone possible. +# - The FAIL path (M8): per-entry split → Thufir's exact witness reproduced. +# --------------------------------------------------------------------------- + +def test_interleaved_delivery_grouping(device_cls=DeviceB): + """Exercise receive-one-slot → canonical re-publish → receive-rest → + re-publish permutations, both slot orders, including delayed delivery + of both transient re-publications to a third observer. + + Protocol sequence (exact, per Paul's brief): + 1. partial_obs receives first_slot → publishes transient_1. + 2. partial_obs receives second_slot → publishes transient_2. + 3. Third-party finals receive BOTH source slots AND both transient + publications in relevant interleaving orders. + Oracle: after eventual delivery of ALL blobs (both source slots + + both transients), every observer's override matches source liveness. + + With the atomic-grouping rule (default DeviceB): + - The compliant split puts the full register in one slot, the other + is empty. partial_obs after step 1 holds either the complete + register (live → transient_1 is live) or nothing (transient_1 is + empty/virgin). Either way, step 2 delivers the remaining (possibly + empty) slot. Final merge of all blobs = source liveness. PASS. + With per-entry splitting (M8): + - slot 0 carries frontier + ov_s: (partial → RegB(1,0,0), dead). + transient_1 is tombstone RegB(0,1,0). After step 2 partial_obs + holds full register but transient_1 tombstone is already in + circulation. Finals that receive transient_1 get + RegB(1,1,10) — dead under clear-wins. FAIL (Thufir's witness). + """ + violations = [] + + # Source: live override RegB(1,0,10) at frontier=10 — Thufir's witness. + src_s, src_c, src_b, src_front = 1, 0, 10, 10 + + for tie_policy in (CLEAR, SET): + src = device_cls("src") + src.frontier["c0"] = src_front + src.overrides["c0"] = RegB(s=src_s, c=src_c, b=src_b) + + # Confirm source is actually live. + assert src.override_is_set("c0", tie_policy), ( + f"test precondition: source must be live under {tie_policy}" + ) + + # Produce the source's two slots via the (possibly mutated) split. + slots = src.split_blob_into_slots(tie_policy, n_slots=2) + slot0, slot1 = slots[0], slots[1] + src_live = src.override_is_set("c0", tie_policy) + + for first_slot, second_slot in [(slot0, slot1), (slot1, slot0)]: + # Step 1: partial_obs receives first slot, canonically re-publishes. + partial_obs = device_cls("partial_obs") + partial_obs.receive_merge(first_slot) + transient_1 = partial_obs.publish_blob(tie_policy) + + # Step 2: partial_obs receives second slot, publishes again. + partial_obs.receive_merge(second_slot) + transient_2 = partial_obs.publish_blob(tie_policy) + + # Step 3: third-party finals receive BOTH source slots AND both + # transient publications, in several representative interleaving + # orders. All must agree with source liveness. + # Representative orders: transient_1 before both slots (most + # dangerous under M8), transient_1 after both slots, and + # interleaved. We check 3 explicit orders rather than all 4! + # permutations (24) for speed; M8's canonical false-clear path + # (transient_1 first, then second_slot only) is order 1. + check_orders = [ + # Most dangerous: transient_1 arrives first, before any source + [transient_1, slot0, slot1, transient_2], + # Normal: both source slots first, then both transients + [slot0, slot1, transient_1, transient_2], + # Interleaved: first source, transient_1, second source, transient_2 + [first_slot, transient_1, second_slot, transient_2], + ] + + for order in check_orders: + final = device_cls("final") + for blob in order: + final.receive_merge(blob) + final_live = final.override_is_set("c0", tie_policy) + if final_live != src_live: + t1_reg = partial_obs.overrides.get("c0") + violations.append(( + "interleaved-delivery-false-clear", + tie_policy, + f"slot_order=(first={list(first_slot['contexts'].keys())[:2]}...)", + f"delivery_order={[list(b['contexts'].keys())[:2] for b in order]}", + f"transient_1_reg={t1_reg}", + f"final_reg={final.overrides.get('c0')}", + f"expected_live={src_live} got_live={final_live}", + )) + + return violations + + +# --------------------------------------------------------------------------- +# Escaped-context slot-grouping regression +# +# Thufir's CRITICAL (round 2): a raw context ID that starts with a +# reserved prefix (e.g. "ov_s:evil") escapes to "esc:ov_s:evil" as its +# frontier wire key. Before the fix, split_blob_into_slots grouped the +# frontier by its wire key ("esc:ov_s:evil") but the ov_* siblings by +# the raw suffix ("ov_s:evil") — two identities for one logical context. +# The frontier and its siblings landed in different slots. +# +# Across publication cycles the replaceable slot d-tag coordinates +# update slot-by-slot. A relay can therefore serve: new frontier slot +# (just published, carries esc:ov_s:evil=10) + stale override slot +# (old coordinate, carries ov_s/ov_c/ov_b at b=0 from the old pub). +# The reconstructed register is RegB(s=1, c=0, b=0) at frontier=10 — +# baseline-dead. Canonical re-publication emits tombstone RegB(0,1,0). +# Eventually both current slots + the transient merge to RegB(1,1,10) — +# dead under clear-wins — permanently suppressing a live override. +# +# The fix: derive the frontier's group identity via unescape_context_key +# so it joins the same group as its ov_* siblings. This test exercises +# both directions: M9 (reverts to escaped-key grouping) must reproduce +# the witness, and the correct model must pass. +# --------------------------------------------------------------------------- + +def test_escaped_context_slot_grouping(device_cls=DeviceB): + """Regression for escaped-context identity mismatch in split_blob_into_slots. + + Scenario: + 1. Source has a live override on raw context "ov_s:evil" (escapes to + "esc:ov_s:evil" as frontier wire key) — Thufir's exact escaped witness. + 2. Source publishes twice: first at frontier=0/b=0, then after advancing + frontier to 10 and re-marking unread (b=10). Each publication produces + 2 slots. Simulates a relay retaining a stale old-cycle slot under its + old replaceable coordinate while only the new-cycle slot for the OTHER + half has been updated — the old/new slot-coordinate mixture. + 3. An observer receives: new-cycle frontier-bearing slot (frontier=10, + esc:ov_s:evil=10) + stale old-cycle override slot (ov_s/ov_c/ov_b from + first pub where b=0). + 4. Observer canonically re-publishes (mandatory, per NIP-RS spec). + 5. A third-party final observer receives both current-cycle source slots + plus the transient re-publication. + 6. Oracle: final observer must see the override as live. + + With the unescape-before-group fix: frontier + ov_* siblings always land + in the same slot → no partial register → no false tombstone. PASS. + With M9 (escaped-key grouping): frontier in one slot, siblings in another + → partial reconstruction → false tombstone → final merge dead. FAIL. + """ + violations = [] + raw_ctx = "ov_s:evil" + + for tie_policy in (CLEAR, SET): + # --- Publication cycle 1: initial state, frontier=0 --- + src_old = device_cls("src") + src_old.frontier[raw_ctx] = 0 + src_old.do_mark_unread(raw_ctx) # RegB(s=1, c=0, b=0) + old_slots = src_old.split_blob_into_slots(tie_policy, n_slots=2) + # old_slots[0] is the "stale old-coordinate slot" a relay may retain. + + # --- Publication cycle 2: frontier advances, re-mark-unread --- + src_new = device_cls("src") + src_new.frontier[raw_ctx] = 10 + src_new.do_mark_unread(raw_ctx) # RegB(s=1, c=0, b=10) — live at frontier=10 + + assert src_new.override_is_set(raw_ctx, tie_policy), ( + f"test precondition: source must be live under {tie_policy}" + ) + + new_slots = src_new.split_blob_into_slots(tie_policy, n_slots=2) + + # --- Full delivery: both new slots → both current-cycle slot arrive --- + recv_full = device_cls("recv_full") + recv_full.receive_merge(new_slots[0]) + recv_full.receive_merge(new_slots[1]) + if not recv_full.override_is_set(raw_ctx, tie_policy): + violations.append(( + "escaped-ctx-full-delivery-dead", tie_policy, + f"full={recv_full.overrides.get(raw_ctx)}", + )) + + # --- Mixture: new frontier-bearing slot + stale old override slot --- + # Identify which new slot carries the frontier and which carries ov_*, + # then pair the frontier slot with the old-cycle override slot. + wire_frontier = escape_context_key(raw_ctx) + + new_frontier_slot_idx = 0 if wire_frontier in new_slots[0]["contexts"] else 1 + new_frontier_slot = new_slots[new_frontier_slot_idx] + old_override_slot = old_slots[1 - new_frontier_slot_idx] # opposite slot + + # Check whether the frontier and ov_* siblings are co-located in new_slots. + ov_s_key = f"ov_s:{raw_ctx}" + frontier_and_ov_same_slot = ( + wire_frontier in new_slots[new_frontier_slot_idx]["contexts"] and + ov_s_key in new_slots[new_frontier_slot_idx]["contexts"] + ) + + if frontier_and_ov_same_slot: + # Correct grouping: old override slot has nothing relevant, mixture + # is safe by construction — the stale slot is just an empty dict. + # Verify anyway for defense-in-depth. + obs = device_cls("obs") + obs.receive_merge(new_frontier_slot) + obs.receive_merge(old_override_slot) + transient = obs.publish_blob(tie_policy) + + final = device_cls("final") + final.receive_merge(new_slots[0]) + final.receive_merge(new_slots[1]) + final.receive_merge(transient) + if not final.override_is_set(raw_ctx, tie_policy): + violations.append(( + "escaped-ctx-grouped-mixture-dead", tie_policy, + f"transient={obs.overrides.get(raw_ctx)}", + f"final={final.overrides.get(raw_ctx)}", + )) + else: + # Mismatched grouping (M9 path): frontier and siblings split. + # The mixture produces a partial register → false tombstone. + obs = device_cls("obs") + obs.receive_merge(new_frontier_slot) # gets frontier=10, no ov_* + obs.receive_merge(old_override_slot) # gets ov_s/ov_c/ov_b at b=0 + transient = obs.publish_blob(tie_policy) + + # Final observer gets everything: both new slots + transient. + for order in [(new_slots[0], new_slots[1]), (new_slots[1], new_slots[0])]: + final = device_cls("final") + final.receive_merge(order[0]) + final.receive_merge(order[1]) + final.receive_merge(transient) + if not final.override_is_set(raw_ctx, tie_policy): + violations.append(( + "escaped-ctx-mixture-false-clear", tie_policy, + f"obs_reg={obs.overrides.get(raw_ctx)}", + f"transient_reg={transient['contexts']}", + f"final_reg={final.overrides.get(raw_ctx)}", + )) + + return violations + + +# --------------------------------------------------------------------------- +# Reserved key namespace: adversarial prefix collision +# --------------------------------------------------------------------------- + +def test_reserved_namespace_collision(): + """A genuine user context whose raw ID begins with the reserved `ov_` + stem (e.g. a pre-existing legacy context literally named `ov_s:evil`) + must round-trip as frontier state, not be misparsed as a control key + for a different context, and must not collide with a real override's + sibling keys in the same flattened contexts map. + + Exercises: escape on publish, unescape on receive, and a live + override on a DIFFERENT context in the same blob to prove no + control-key collision occurs. + """ + violations = [] + adversarial_raw = "ov_s:evil" # would misparse as ov_s: control for ctx "evil" + real_ctx = "c0" + + # Escaping must be a no-op for every context ID Buzz actually + # generates, and must trigger for the adversarial one. + for benign in ("b68cd7cb-6f8d-4641-b743-a7349eb4114b", + "msg:" + "a" * 64, "thread:" + "b" * 64): + if escape_context_key(benign) != benign: + violations.append(("namespace-benign-escaped", benign)) + if escape_context_key(adversarial_raw) == adversarial_raw: + violations.append(("namespace-adversarial-not-escaped", adversarial_raw)) + if not escape_context_key(adversarial_raw).startswith(ESCAPE_PREFIX): + violations.append(("namespace-adversarial-missing-marker", adversarial_raw)) + + dev = DeviceB("d0") + dev.frontier[adversarial_raw] = 42 + dev.frontier[real_ctx] = 5 + dev.do_mark_unread(real_ctx) + blob = dev.publish_blob() + + wire_key = escape_context_key(adversarial_raw) + if wire_key not in blob["contexts"]: + violations.append(("namespace-wire-key-missing", wire_key, blob["contexts"])) + if blob["contexts"].get(wire_key) != 42: + violations.append(("namespace-value-corrupted", wire_key, blob["contexts"].get(wire_key))) + + recv = DeviceB("recv") + recv.receive_merge(blob) + if recv.effective_frontier(adversarial_raw) != 42: + violations.append(( + "namespace-roundtrip-failed", adversarial_raw, + recv.effective_frontier(adversarial_raw), + )) + if adversarial_raw in recv.overrides: + violations.append(("namespace-misparsed-as-override", adversarial_raw)) + if recv.overrides.get(real_ctx) is None or recv.overrides[real_ctx].s == 0: + violations.append(("namespace-real-override-corrupted", real_ctx, recv.overrides.get(real_ctx))) + + return violations + + +# --------------------------------------------------------------------------- +# Run all +# --------------------------------------------------------------------------- + +def run_all(): + print("=" * 60) + print("NIP-RS manual-unread exhaustive model") + print("=" * 60) + total_violations = 0 + + def report(name, violations): + nonlocal total_violations + n = len(violations) if isinstance(violations, list) else 0 + total_violations += n + status = "PASS" if n == 0 else f"FAIL ({n})" + print(f" {name}: {status}") + if n > 0: + for v in violations[:3]: + print(f" {v}") + + print("\n--- I1: merge algebra (B) ---") + report("assoc/commut/idempot", test_merge_algebra_b()) + + print("\n--- I1: merge algebra (A) ---") + report("assoc/commut/idempot", test_merge_algebra_a()) + + print("\n--- I2+I3+I5: BFS explorer (B, clear-wins) ---") + n, v = explore_b(max_depth=4, tie_policy=CLEAR) + print(f" states explored: {n}") + report("convergence+frontier+compaction", v) + + print("\n--- I2+I3+I5: BFS explorer (B, set-wins) ---") + n, v = explore_b(max_depth=4, tie_policy=SET) + print(f" states explored: {n}") + report("convergence+frontier+compaction", v) + + print("\n--- I4: concurrent set/clear stability ---") + report("stable winner", test_concurrent_stability()) + + print("\n--- I5: compaction register-level exhaustive ---") + report("all register values x policies", test_compaction_register_exhaustive()) + + print("\n--- I5c: directed deep-history (compact -> reuse -> stale delivery) ---") + cube_size, deep_v = test_deep_history_compaction() + print(f" parameter cube size: {cube_size}") + report("no divergence from never-compact reference", deep_v) + + print("\n--- I5c: tombstone + stale-ancestor merge (direct) ---") + report("no resurrection", test_tombstone_stale_merge_direct()) + + print("\n--- I5d: tombstone-merge monotonicity (structural lemma) ---") + report("ceiling never strengthens S", test_tombstone_merge_monotonic()) + + print("\n--- I5d: cross-device compaction transparency (suppress-only) ---") + cd_cube, cd_suppress, cd_v = test_cross_device_compaction_suppression() + print(f" parameter cube size: {cd_cube} suppressions observed: {cd_suppress}") + report("suppress-only + recoverable", cd_v) + + print("\n--- Published-state merge closure (canonical publication guarantee) ---") + mc_cube, mc_v = test_published_merge_closure() + print(f" pairs checked: {mc_cube}") + report("no dead+dead resurrection", mc_v) + + print("\n--- I6: replay harmless ---") + report("replay", test_replay_harmless()) + + print("\n--- I7: legacy rewrite (B) ---") + report("sibling keys survive", test_legacy_rewrite_b()) + + print("\n--- I7: legacy erasure (A) — expected witness ---") + erased = test_legacy_erasure_a() + print(f" overrides erased by legacy: {'CONFIRMED' if erased else 'NOT FOUND'}") + if not erased: + total_violations += 1 + + print("\n--- I8: bounded growth ---") + report("canonical wire shape (3 live / 1 tombstone)", test_bounded_growth()) + + print("\n--- I8: exact wire-shape regression ---") + report("live=3 keys, tombstone=1 key, virgin=0 keys", test_wire_shape_exact()) + + print("\n--- I9: DeviceA counter absorption ---") + absorbed = test_a_counter_absorption() + print(f" post-receive counter > received: {'CONFIRMED' if absorbed else 'FAIL'}") + if not absorbed: + total_violations += 1 + + print("\n--- Identity-free (B) ---") + report("reinstall convergence", test_b_identity_free()) + + print("\n--- Legacy prune interaction ---") + ov_ok, msg_ok, nested_lost = test_legacy_prune_interaction() + print(f" ov_ keys survive: {'PASS' if ov_ok else 'FAIL'}") + print(f" msg: pruned at horizon: {'PASS' if msg_ok else 'FAIL'}") + print(f" nested msg:ov_ lost: {'CONFIRMED (hazard)' if nested_lost else 'NOT FOUND'}") + if not ov_ok: + total_violations += 1 + + print("\n--- Legacy trim interaction ---") + blocked = test_legacy_trim_interaction() + print(f" excess overrides block publish: {'CONFIRMED (hazard)' if blocked else 'NOT FOUND'}") + + print("\n--- Multi-slot union ---") + report("split+merge convergence", test_multi_slot_union()) + + print("\n--- Interleaved delivery + atomic grouping rule ---") + report("no false clear under slot interleaving", test_interleaved_delivery_grouping()) + + print("\n--- Escaped-context slot-grouping regression ---") + report("escaped ctx: frontier + ov_* siblings same slot", test_escaped_context_slot_grouping()) + + print("\n--- Reserved key namespace: adversarial prefix collision ---") + report("escape/unescape + no misparse", test_reserved_namespace_collision()) + + print("\n" + "=" * 60) + if total_violations == 0: + print("ALL INVARIANTS HOLD — 0 violations") + else: + print(f"VIOLATIONS: {total_violations}") + print("=" * 60) + return total_violations + + +if __name__ == "__main__": + import sys + sys.exit(0 if run_all() == 0 else 1) diff --git a/docs/formal/nip-rs-unread/model.py b/docs/formal/nip-rs-unread/model.py new file mode 100644 index 0000000000..4400280571 --- /dev/null +++ b/docs/formal/nip-rs-unread/model.py @@ -0,0 +1,492 @@ +"""Bounded exhaustive model comparing two NIP-RS manual-unread encodings. + +Candidate A: lexicographic operation register + Per context: {counter, client_tiebreak, op in {SET,CLEAR}, baseline} + in a NEW top-level field beside `contexts`. + Merge = max tuple (counter, tiebreak, op-rule on full tie). + +Candidate B: two grow-only counters + baseline + Per context: S (set counter), C (clear counter), B (frontier-at-set-time) + as sibling keys under `contexts` (ov_s:, ov_c:, ov_b: prefixes). + Action: own counter := max(S,C)+1; set also writes B := effective frontier. + Merge = componentwise max. Tie policy on S == C is a parameter. + +Both share: + - Frontier: grow-only max() per NIP-RS v1 (unchanged). + - Verdict: unread(ctx) = latest > effective_frontier(ctx) OR override_set(ctx). + - Mark-read = advance frontier + clear override. + - Mark-unread = set override with baseline B = current effective frontier. + - Natural frontier advance strictly past B dominates a stale set. + +Device simulators use overridable methods (_override_set, _compact, _merge_reg, +_bump, _sanitize_value) so the mutation harness can inject weakened rules via +subclassing without monkeypatching. +""" +from dataclasses import dataclass +from typing import Optional + + +SET = "SET" +CLEAR = "CLEAR" + + +# --------------------------------------------------------------------------- +# Reserved key namespace + escaping +# +# NIP-RS v1 context IDs are arbitrary UTF-8 (spec :89, :113-114), so a +# pre-existing opaque context could legitimately begin with `ov_s:`, +# `ov_c:`, or `ov_b:` and collide with a control key for a DIFFERENT +# context in the same flattened `contexts` map. `ov_` (the shared +# 3-byte stem) and the escape marker itself are reserved; any raw +# context ID that would collide is escaped before being used as a +# plain frontier key. Escaping is a no-op for every context ID Buzz +# actually generates (channel UUID, `msg:`, `thread:` +# — none start with `ov_` or `esc:`), so the common case pays zero +# bytes. Only a pathological ID pays the 4-byte `esc:` cost. +# +# This protects context IDs generated by amendment-aware clients. +# It does NOT retroactively protect a context that a PRE-EXISTING +# legacy client already published unescaped before the amendment +# shipped — that residual hazard is documented, not solved (see +# NOTE.md "Reserved key namespace"). +# --------------------------------------------------------------------------- + +ESCAPE_PREFIX = "esc:" +_RESERVED_STEM = "ov_" + + +def _needs_escape(raw_key: str) -> bool: + return raw_key.startswith(_RESERVED_STEM) or raw_key.startswith(ESCAPE_PREFIX) + + +def escape_context_key(raw_key: str) -> str: + return ESCAPE_PREFIX + raw_key if _needs_escape(raw_key) else raw_key + + +def unescape_context_key(wire_key: str) -> str: + if wire_key.startswith(ESCAPE_PREFIX): + return wire_key[len(ESCAPE_PREFIX):] + return wire_key + + +# --------------------------------------------------------------------------- +# Candidate B — two grow-only counters + baseline +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class RegB: + s: int = 0 + c: int = 0 + b: int = 0 + + +def merge_reg_b(a: Optional[RegB], b: Optional[RegB]) -> Optional[RegB]: + if a is None: + return b + if b is None: + return a + return RegB(s=max(a.s, b.s), c=max(a.c, b.c), b=max(a.b, b.b)) + + +def override_set_b(reg: Optional[RegB], frontier_val: int, tie_policy=CLEAR) -> bool: + if reg is None: + return False + if frontier_val > reg.b and reg.s > 0: + return False + if reg.s > reg.c: + return True + if reg.s == reg.c and reg.s > 0: + return tie_policy == SET + return False + + +def compact_b(reg: RegB, frontier_val: int, tie_policy=CLEAR) -> Optional[RegB]: + """Compact override state. + + Tombstone-floor design: a register with any recorded counter + activity (S>0 or C>0) is never fully deleted. Its counter + high-water-mark is exactly what prevents a stale replica — + any (S,C) pair below that ceiling — from dominating a freshly + created register after compaction (delete-on-dominance made + counters reusable: a dead register dropped entirely, then a new + local set/clear pair restarted from S=0/C=0, so a delayed stale + peer snapshot with S>0 could out-rank the new state on replay). + Only a virgin register (S==0, C==0, no activity ever recorded) + has no ceiling to protect and compacts to None. + + A live override (per `override_set_b`, which is already + policy-aware) is returned unchanged — compaction only touches dead + state. Dead overrides — whether dominated by C>S, tied under + clear-wins, or baseline-dominated by frontier advance — compact to + the clear-tombstone floor `RegB(s=0, c=max(S,C), b=0)`: S is + zeroed (no longer overriding), but C retains the ceiling so both a + future local bump (`max(S,C)+1`) and a componentwise-max merge with + any pre-compaction stale snapshot start strictly above the + historical maximum, never below it. + """ + if reg.s == 0 and reg.c == 0: + return None + if override_set_b(reg, frontier_val, tie_policy): + return reg + return RegB(s=0, c=max(reg.s, reg.c), b=0) + + +# --------------------------------------------------------------------------- +# Candidate A — lexicographic operation register +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class RegA: + counter: int = 0 + tiebreak: str = "" + op: str = CLEAR + baseline: int = 0 + + def as_tuple(self, op_wins): + op_val = 1 if self.op == op_wins else 0 + return (self.counter, self.tiebreak, op_val) + + +def merge_reg_a(a: Optional[RegA], b: Optional[RegA], tie_op=CLEAR) -> Optional[RegA]: + if a is None: + return b + if b is None: + return a + at = a.as_tuple(tie_op) + bt = b.as_tuple(tie_op) + if at == bt: + return RegA( + counter=a.counter, tiebreak=a.tiebreak, op=a.op, + baseline=max(a.baseline, b.baseline), + ) + return a if at > bt else b + + +# --------------------------------------------------------------------------- +# Device simulation — Candidate B +# --------------------------------------------------------------------------- + +class DeviceB: + """Simulates one device's NIP-RS read-state blob with manual-unread + override layer (candidate B encoding). + + All model operations go through overridable _methods so the mutation + harness can inject weakened rules via subclassing. + """ + + def __init__(self, client_id, is_legacy=False): + self.client_id = client_id + self.is_legacy = is_legacy + self.frontier = {} + self.overrides = {} + + def effective_frontier(self, ctx): + return self.frontier.get(ctx, 0) + + def _override_set(self, reg, frontier_val, tie_policy): + return override_set_b(reg, frontier_val, tie_policy) + + def _compact(self, reg, frontier_val, tie_policy): + return compact_b(reg, frontier_val, tie_policy) + + def _merge_reg(self, a, b): + return merge_reg_b(a, b) + + def _bump(self, s, c): + return max(s, c) + 1 + + def _sanitize_value(self, v): + return isinstance(v, int) and 0 <= v <= 4294967295 + + def override_is_set(self, ctx, tie_policy=CLEAR): + return self._override_set( + self.overrides.get(ctx), self.effective_frontier(ctx), tie_policy + ) + + def verdict(self, ctx, latest_ts, tie_policy=CLEAR): + return (latest_ts > self.effective_frontier(ctx) + or self.override_is_set(ctx, tie_policy)) + + def do_mark_unread(self, ctx): + if self.is_legacy: + return + cur = self.overrides.get(ctx, RegB()) + new_s = self._bump(cur.s, cur.c) + self.overrides[ctx] = RegB(s=new_s, c=cur.c, b=self.effective_frontier(ctx)) + + def do_mark_read(self, ctx, frontier_ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), frontier_ts) + if not self.is_legacy: + cur = self.overrides.get(ctx, RegB()) + new_c = self._bump(cur.s, cur.c) + self.overrides[ctx] = RegB(s=cur.s, c=new_c, b=cur.b) + + def do_advance_frontier(self, ctx, ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts) + + def do_compact(self, ctx, tie_policy=CLEAR): + reg = self.overrides.get(ctx) + if reg is None: + return + result = self._compact(reg, self.effective_frontier(ctx), tie_policy) + if result is None: + if ctx in self.overrides: + del self.overrides[ctx] + else: + self.overrides[ctx] = result + + def do_reinstall(self): + self.client_id = self.client_id + "_r" + self.frontier = {} + self.overrides = {} + + def _canonicalize_for_publish(self, ctx, tie_policy): + """Canonical published form of `ctx`'s override register, + computed fresh against the current effective frontier — + independent of whether `do_compact` was ever called locally. + Returns `(is_live, canonical_reg)`; `canonical_reg is None` + means virgin (omit from the wire entirely). Reuses the same + overridable `_compact`/`_override_set` hooks `do_compact` uses, + so a mutation-harness subclass that weakens one weakens both + the storage-GC path and the publish path identically. + """ + reg = self.overrides.get(ctx) + if reg is None: + return False, None + front = self.effective_frontier(ctx) + canonical = self._compact(reg, front, tie_policy) + if canonical is None: + return False, None + return self._override_set(canonical, front, tie_policy), canonical + + def publish_blob(self, tie_policy=CLEAR): + """Serialize this device's read-state blob. + + Every override is canonicalized at serialization time: live -> + unchanged (3 keys), dead -> tombstone floor (1 key, `ov_c:` + only), virgin -> omitted (0 keys). Canonical publication is a + protocol requirement, not an optimization — noncanonical wire + output is structurally impossible here, not merely avoided by + convention. `do_compact` remains a separate storage-GC + transition that mutates `self.overrides`; publication no + longer depends on it having been called first. + + **Atomic slot-grouping rule (spec-amendment requirement):** + A context's frontier entry and ALL of its `ov_*` sibling entries + MUST travel in the same slot. `split_blob_into_slots` below + enforces this by round-robining per-context groups, never + per-entry. A receiving client that only holds part of a context + group and attempts to reconstruct a `RegB` from it would see + partial zeroes and might canonically re-publish a false + tombstone. Group atomicity makes partial reconstruction + structurally impossible from a compliant publisher's output. + """ + blob_ctx = {escape_context_key(k): v for k, v in self.frontier.items()} + if not self.is_legacy: + for k in self.overrides: + is_live, canonical = self._canonicalize_for_publish(k, tie_policy) + if canonical is None: + continue # virgin: omitted from the wire entirely + if is_live: + blob_ctx[f"ov_s:{k}"] = canonical.s + blob_ctx[f"ov_c:{k}"] = canonical.c + blob_ctx[f"ov_b:{k}"] = canonical.b + else: + blob_ctx[f"ov_c:{k}"] = canonical.c # tombstone: ceiling only + return {"v": 1, "client_id": self.client_id, "contexts": blob_ctx} + + def split_blob_into_slots(self, tie_policy=CLEAR, n_slots=2): + """Split this device's blob into `n_slots` compliant slots. + + **Atomic grouping rule:** a context's frontier entry and ALL of + its `ov_*` sibling entries travel together in the same slot. + Round-robin assignment is per-context group, never per-entry. + This matches production `splitContextsIntoBudgetedSlots` when + it is amended to group by context instead of by individual entry. + + Returns a list of `n_slots` blobs, each with the same `v` and + `client_id` but a disjoint subset of context groups. + """ + blob = self.publish_blob(tie_policy) + contexts = blob["contexts"] + + # Gather per-context groups: each group is a list of (key, value) pairs. + # A "group" is: the frontier key (escaped ctx) + any ov_* siblings. + # Contexts that appear only as ov_* keys (no frontier entry) are + # also grouped together. + groups = {} # logical_ctx -> list of (wire_key, value) + for wire_key, value in contexts.items(): + if wire_key.startswith("ov_s:"): + ctx = wire_key[5:] + elif wire_key.startswith("ov_c:"): + ctx = wire_key[5:] + elif wire_key.startswith("ov_b:"): + ctx = wire_key[5:] + else: + # Frontier key: may be escaped (e.g. "esc:ov_s:evil"). + # Derive the logical context ID by unescaping so this + # entry joins the same group as its ov_* siblings, which + # are keyed by the RAW context ID (e.g. "ov_s:evil" -> + # ctx = "evil", but "esc:ov_s:evil" frontier -> ctx = + # "ov_s:evil" after unescape). Without this step an + # escaped frontier key and its ov_* siblings would be + # treated as two different groups, splitting the register + # across slots — reproducing the round-1 partial- + # reconstruction poison for escaped context IDs. + ctx = unescape_context_key(wire_key) + groups.setdefault(ctx, []).append((wire_key, value)) + + slots = [{"v": blob["v"], "client_id": blob["client_id"], "contexts": {}} + for _ in range(n_slots)] + for i, (_ctx, pairs) in enumerate(sorted(groups.items())): + slot = slots[i % n_slots] + for wire_key, value in pairs: + slot["contexts"][wire_key] = value + return slots + + def receive_merge(self, blob): + incoming_overrides = {} + for k, v in blob.get("contexts", {}).items(): + if k.startswith("ov_s:"): + ctx = k[5:] + incoming_overrides.setdefault(ctx, [0, 0, 0])[0] = v + elif k.startswith("ov_c:"): + ctx = k[5:] + incoming_overrides.setdefault(ctx, [0, 0, 0])[1] = v + elif k.startswith("ov_b:"): + ctx = k[5:] + incoming_overrides.setdefault(ctx, [0, 0, 0])[2] = v + else: + ctx = unescape_context_key(k) + self.frontier[ctx] = max(self.frontier.get(ctx, 0), v) + + if not self.is_legacy: + for ctx, (s, c, b) in incoming_overrides.items(): + incoming_reg = RegB(s=s, c=c, b=b) + self.overrides[ctx] = self._merge_reg( + self.overrides.get(ctx), incoming_reg + ) + + def legacy_sanitize_and_publish(self, tie_policy=CLEAR): + blob = self.publish_blob(tie_policy) + sanitized = {} + for k, v in blob["contexts"].items(): + if len(k.encode("utf-8")) <= 256 and self._sanitize_value(v): + sanitized[k] = v + return {"v": 1, "client_id": self.client_id, "contexts": sanitized} + + def state_key(self, contexts, tie_policy=CLEAR): + parts = [] + for ctx in sorted(contexts): + f = self.effective_frontier(ctx) + reg = self.overrides.get(ctx, RegB()) + ov = self.override_is_set(ctx, tie_policy) + parts.append((ctx, f, reg.s, reg.c, reg.b, ov)) + return (self.client_id, self.is_legacy, tuple(parts)) + + +# --------------------------------------------------------------------------- +# Device simulation — Candidate A +# --------------------------------------------------------------------------- + +class DeviceA: + def __init__(self, client_id, is_legacy=False): + self.client_id = client_id + self.is_legacy = is_legacy + self.frontier = {} + self.overrides = {} + self.counter = 0 + + def effective_frontier(self, ctx): + return self.frontier.get(ctx, 0) + + def override_is_set(self, ctx): + reg = self.overrides.get(ctx) + if reg is None or reg.op == CLEAR: + return False + if self.effective_frontier(ctx) > reg.baseline: + return False + return True + + def verdict(self, ctx, latest_ts): + return latest_ts > self.effective_frontier(ctx) or self.override_is_set(ctx) + + def do_mark_unread(self, ctx): + if self.is_legacy: + return + self.counter += 1 + self.overrides[ctx] = RegA( + counter=self.counter, tiebreak=self.client_id, + op=SET, baseline=self.effective_frontier(ctx), + ) + + def do_mark_read(self, ctx, frontier_ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), frontier_ts) + if not self.is_legacy: + self.counter += 1 + self.overrides[ctx] = RegA( + counter=self.counter, tiebreak=self.client_id, + op=CLEAR, baseline=0, + ) + + def do_advance_frontier(self, ctx, ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts) + + def receive_merge(self, blob, tie_op=CLEAR): + for ctx, ts in blob.get("contexts", {}).items(): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts) + if not self.is_legacy: + for ctx, reg in blob.get("overrides", {}).items(): + self.overrides[ctx] = merge_reg_a( + self.overrides.get(ctx), reg, tie_op + ) + if reg.counter > self.counter: + self.counter = reg.counter + + def publish_blob(self): + blob = {"v": 1, "client_id": self.client_id, "contexts": dict(self.frontier)} + if not self.is_legacy: + blob["overrides"] = dict(self.overrides) + return blob + + def legacy_rewrite_and_publish(self): + return {"v": 1, "client_id": self.client_id, "contexts": dict(self.frontier)} + + +# --------------------------------------------------------------------------- +# Legacy pruning/trim model +# --------------------------------------------------------------------------- + +def legacy_prune(contexts, horizon): + return {k: v for k, v in contexts.items() + if not (k.startswith("msg:") or k.startswith("thread:")) or v >= horizon} + + +def legacy_trim(contexts, client_id, max_bytes=32768): + import json + + def size(ctx): + return len(json.dumps({"v": 1, "client_id": client_id, "contexts": ctx}).encode()) + + if size(contexts) <= max_bytes: + return contexts, True + evictable = sorted( + ((k, v) for k, v in contexts.items() + if k.startswith("msg:") or k.startswith("thread:")), + key=lambda kv: kv[1], + ) + out = dict(contexts) + for k, _ in evictable: + del out[k] + if size(out) <= max_bytes: + return out, True + return out, size(out) <= max_bytes + + +def legacy_sanitize_blob(blob): + sanitized = {} + for k, v in blob.get("contexts", {}).items(): + if (len(k.encode("utf-8")) <= 256 + and isinstance(v, int) and 0 <= v <= 4294967295): + sanitized[k] = v + return {"v": 1, "client_id": blob.get("client_id", ""), "contexts": sanitized} diff --git a/docs/formal/nip-rs-unread/mutation.py b/docs/formal/nip-rs-unread/mutation.py new file mode 100644 index 0000000000..4450a99c8f --- /dev/null +++ b/docs/formal/nip-rs-unread/mutation.py @@ -0,0 +1,519 @@ +"""Mutation harness for candidate B (two-counter) model. + +Each mutant: subclass DeviceB with a weakened rule, run the BFS explorer, +require a recorded counterexample. A model that stays green under a real +weakening is worthless. + +Mutants: + M1: drop baseline dominance (frontier > B no longer clears stale set) + M2: drop max(S,C)+1 bump (use S+1 or C+1 — counter can regress) + M3: flip tie policy (verify the model distinguishes them) + M4: revert to delete-on-dominance compaction (drops the tombstone floor + entirely instead of zeroing S and keeping max(S,C) as C) — reproduces + Thufir's pass-3 CRITICAL: stale-replay resurrection after counter reuse + M5: uint32 overflow bypass (legacy sanitization disabled) + M6: componentwise-max -> last-write-wins merge (convergence breaks) + M7: publish without canonicalization (serialize raw registers instead + of the compact-at-publish canonical form) — reproduces Thufir's + pass-1/2 CRITICAL: dead+dead merge resurrection + M8: revert split_blob_into_slots to per-entry splitting (violates the + atomic-grouping rule) — reproduces Thufir's pass-2/2 CRITICAL: + partial-slot reconstruction of a live RegB creates a false tombstone + that permanently suppresses the override after eventual full delivery + M9: revert split_blob_into_slots to escaped-key grouping (groups frontier + by wire key instead of unescaped logical ID) — reproduces Thufir's + round-2 CRITICAL: for a context whose raw ID starts with a reserved + prefix (e.g. "ov_s:evil"), the frontier's escaped wire key + ("esc:ov_s:evil") and the ov_* siblings (keyed by raw suffix "ov_s:evil") + resolve to different groups → register split across slots → + old/new slot-coordinate mixture produces partial reconstruction → + false tombstone → permanent false clear across publication cycles + +Each mutant is injected into the model via DeviceB subclass, then the +explorer or invariant suite is rerun. The counterexample (first violation) +is recorded and printed. +""" +from copy import deepcopy +from model import ( + RegB, merge_reg_b, override_set_b, compact_b, + DeviceB, legacy_sanitize_blob, + escape_context_key, + SET, CLEAR, +) +from exhaustive import ( + explore_b, test_concurrent_stability, + test_compaction_register_exhaustive, test_deep_history_compaction, + test_published_merge_closure, test_interleaved_delivery_grouping, + test_escaped_context_slot_grouping, + CONTEXTS, +) + + +# --------------------------------------------------------------------------- +# M1: drop baseline dominance +# --------------------------------------------------------------------------- + +class M1_NoBaselineDominance(DeviceB): + def _override_set(self, reg, frontier_val, tie_policy): + if reg is None: + return False + if reg.s > reg.c: + return True + if reg.s == reg.c and reg.s > 0: + return tie_policy == SET + return False + + def _compact(self, reg, frontier_val, tie_policy): + if reg.s == 0 and reg.c == 0: + return None + if self._override_set(reg, frontier_val, tie_policy): + return reg + if reg.c > reg.s: + return RegB(s=0, c=reg.c, b=0) + if reg.c == reg.s and tie_policy == CLEAR: + return RegB(s=0, c=reg.c, b=0) + return reg + + +def mutant_m1(): + """M1: without baseline dominance, a stale set persists after frontier + advance past baseline. Verify by constructing the scenario directly: + mark-unread at frontier=10, then advance frontier to 100. The correct + model clears the override; the mutant keeps it live.""" + violations = [] + for ctx in CONTEXTS: + dev = M1_NoBaselineDominance("d0") + dev.frontier[ctx] = 10 + dev.do_mark_unread(ctx) + dev.do_advance_frontier(ctx, 100) + + correct = override_set_b(dev.overrides[ctx], 100, CLEAR) + mutant_result = dev.override_is_set(ctx, CLEAR) + + if correct != mutant_result: + violations.append(( + "baseline-dominance-missing", ctx, + dev.overrides[ctx], 100, + f"correct={correct}", f"mutant={mutant_result}", + )) + + if not violations: + _, violations = explore_b(max_depth=3, tie_policy=CLEAR, + device_cls=M1_NoBaselineDominance) + return violations + + +# --------------------------------------------------------------------------- +# M2: drop max(S,C)+1 bump +# --------------------------------------------------------------------------- + +class M2_NoBump(DeviceB): + """Each counter bumps only itself: mark_unread does S := S+1, + mark_read does C := C+1. When S > C from a prior set, a clear + at C+1 can produce C < S even though the clear is causally later.""" + def do_mark_unread(self, ctx): + if self.is_legacy: + return + cur = self.overrides.get(ctx, RegB()) + self.overrides[ctx] = RegB(s=cur.s + 1, c=cur.c, + b=self.effective_frontier(ctx)) + + def do_mark_read(self, ctx, frontier_ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), frontier_ts) + if not self.is_legacy: + cur = self.overrides.get(ctx, RegB()) + self.overrides[ctx] = RegB(s=cur.s, c=cur.c + 1, b=cur.b) + + +def mutant_m2(): + """M2: each counter bumps independently. After set→set→clear at + the SAME frontier (no advance past baseline): correct clear has + C=3 > S=2, mutant clear has C=1 < S=2 — a causally later clear + fails to dominate. + + Use mark_read at the current frontier (not advancing past baseline) + so baseline dominance doesn't mask the counter discrepancy. + """ + violations = [] + for ctx in CONTEXTS: + front = 10 + dev_correct = DeviceB("d0") + dev_correct.frontier[ctx] = front + dev_correct.do_mark_unread(ctx) + dev_correct.do_mark_unread(ctx) + dev_correct.do_mark_read(ctx, front) + + dev_mutant = M2_NoBump("d0") + dev_mutant.frontier[ctx] = front + dev_mutant.do_mark_unread(ctx) + dev_mutant.do_mark_unread(ctx) + dev_mutant.do_mark_read(ctx, front) + + correct_set = dev_correct.override_is_set(ctx, CLEAR) + mutant_set = dev_mutant.override_is_set(ctx, CLEAR) + + if correct_set != mutant_set: + violations.append(( + "bump-independent", ctx, + f"correct={dev_correct.overrides[ctx]}", + f"mutant={dev_mutant.overrides[ctx]}", + f"correct_set={correct_set}", f"mutant_set={mutant_set}", + )) + + if not violations: + _, violations = explore_b(max_depth=4, tie_policy=CLEAR, + device_cls=M2_NoBump) + return violations + + +# --------------------------------------------------------------------------- +# M3: tie policy distinguishable +# --------------------------------------------------------------------------- + +def mutant_m3(): + """M3: tie policy is load-bearing — S==C must produce different verdicts. + Not a DeviceB mutation; tests the model function directly.""" + reg = RegB(s=1, c=1, b=10) + frontier = 10 + v_clear = override_set_b(reg, frontier, CLEAR) + v_set = override_set_b(reg, frontier, SET) + if v_clear == v_set: + return [] + return [("tie-distinguishable", v_clear, v_set, reg, frontier)] + + +# --------------------------------------------------------------------------- +# M4: revert to delete-on-dominance compaction (drops the tombstone floor) +# --------------------------------------------------------------------------- + +class M4_DeleteOnDominance(DeviceB): + """The pre-fix compaction rule: any dead/dominated register is deleted + entirely rather than reduced to the tombstone floor RegB(0, max(S,C), 0). + This makes counters reusable — a later local set/clear pair restarts + from S=0/C=0, so a delayed stale peer snapshot can dominate it on + replay. This is exactly the rule Thufir's pass-3 CRITICAL found live + at e453b3945.""" + def _compact(self, reg, frontier_val, tie_policy): + if reg.s == 0 and reg.c == 0: + return None + if self._override_set(reg, frontier_val, tie_policy): + return reg + if frontier_val > reg.b: + return None + if reg.c > reg.s: + return RegB(s=0, c=reg.c, b=0) + if reg.c == reg.s and tie_policy == CLEAR: + return RegB(s=0, c=reg.c, b=0) + return reg + + +def mutant_m4(): + """M4: without the tombstone floor, compaction deletes the counter + ceiling instead of preserving it. Reproduce Thufir's exact witness + directly: RegB(3,0,10) at frontier=20 compacts to None under the old + rule (vs. RegB(0,3,0) under the fix); a subsequent local set+clear + reuses counters from zero; the stale ancestor then replays and + resurrects (S>C) under both tie policies. + + Then confirm the explorer/deep-history suite also catches it (defense + in depth — a mutant that only fails a hand-built scenario would still + be a real bug, but the directed check is what's supposed to catch this + class per T2/T3).""" + violations = [] + stale = RegB(s=3, c=0, b=10) + frontier_after = 20 + + for tie_policy in (CLEAR, SET): + dev = M4_DeleteOnDominance("d0") + dev.frontier["c0"] = 10 + dev.overrides["c0"] = stale + dev.do_advance_frontier("c0", frontier_after) + dev.do_compact("c0", tie_policy) + if "c0" in dev.overrides: + continue # old rule didn't drop it here; not the witness shape + + dev.do_mark_unread("c0") # S := 1, B := 20 + dev.do_mark_read("c0", frontier_after) # C := 2 + + stale_blob = {"contexts": {"ov_s:c0": stale.s, "ov_c:c0": stale.c, "ov_b:c0": stale.b}} + dev.receive_merge(stale_blob) + resurrected = dev.override_is_set("c0", tie_policy) + + if resurrected: + violations.append(( + "M4-delete-on-dominance-resurrection", tie_policy, + f"stale_ancestor={stale}", f"post_compact_reuse=(set,clear)", + f"final_reg={dev.overrides['c0']}", f"override_is_set={resurrected}", + )) + + if not violations: + _, violations = test_deep_history_compaction(device_cls=M4_DeleteOnDominance) + return violations + + +# --------------------------------------------------------------------------- +# M5: uint32 overflow bypass +# --------------------------------------------------------------------------- + +def mutant_m5(): + """M5: values outside uint32 range must fail legacy sanitization.""" + blob = {"v": 1, "client_id": "x", "contexts": { + "ov_s:c0": 4294967296, + "ov_c:c0": 0, + "ov_b:c0": 10, + }} + sanitized = legacy_sanitize_blob(blob) + if "ov_s:c0" in sanitized["contexts"]: + return [] + return [("overflow-rejected", blob["contexts"]["ov_s:c0"], + sanitized["contexts"])] + + +# --------------------------------------------------------------------------- +# M6: last-write-wins merge (breaks convergence) +# --------------------------------------------------------------------------- + +class M6_LastWriteWins(DeviceB): + def _merge_reg(self, a, b): + if a is None: + return b + if b is None: + return a + return b + + +def mutant_m6(): + """M6: replace componentwise max with last-write-wins. Convergence must + break — different delivery orders produce different final states.""" + _, violations = explore_b(max_depth=3, tie_policy=CLEAR, + device_cls=M6_LastWriteWins) + return violations + + +# --------------------------------------------------------------------------- +# M7: publish without canonicalization (reproduces Thufir's pass-1/2 +# CRITICAL — dead+dead merge resurrection) +# --------------------------------------------------------------------------- + +class M7_PublishWithoutCanonicalization(DeviceB): + """Reverts `publish_blob` to serialize raw, uncompacted registers — + the exact pre-fix behavior Thufir's pass-1/2 CRITICAL exploited: + a dead register's baseline-relative death (or clear-count-relative + death) never gets folded into a globally-comparable ceiling before + hitting the wire, so two individually-dead registers can + componentwise-max-merge into a live join.""" + def publish_blob(self, tie_policy=CLEAR): + blob_ctx = {escape_context_key(k): v for k, v in self.frontier.items()} + if not self.is_legacy: + for k, reg in self.overrides.items(): + blob_ctx[f"ov_s:{k}"] = reg.s + blob_ctx[f"ov_c:{k}"] = reg.c + blob_ctx[f"ov_b:{k}"] = reg.b + return {"v": 1, "client_id": self.client_id, "contexts": blob_ctx} + + +def mutant_m7(): + """M7: publish-without-canonicalization must be caught by the + published-state merge-closure invariant — proving that invariant + has teeth. Reproduce Thufir's exact witness directly first (fast, + deterministic); fall back to the full search if the hand-built + scenario doesn't trigger under a given tie policy.""" + violations = [] + for tie_policy in (CLEAR, SET): + dev_a = M7_PublishWithoutCanonicalization("a") + dev_a.frontier["c0"] = 50 + dev_a.overrides["c0"] = RegB(s=3, c=2, b=0) + dev_b = M7_PublishWithoutCanonicalization("b") + dev_b.frontier["c0"] = 100 + dev_b.overrides["c0"] = RegB(s=1, c=2, b=100) + + blob_a = dev_a.publish_blob(tie_policy) + blob_b = dev_b.publish_blob(tie_policy) + + for first, second in [(blob_a, blob_b), (blob_b, blob_a)]: + recv = M7_PublishWithoutCanonicalization("recv") + recv.receive_merge(first) + recv.receive_merge(second) + if recv.override_is_set("c0", tie_policy): + violations.append(( + "M7-publish-without-canonicalization-resurrection", + tie_policy, blob_a, blob_b, recv.overrides["c0"], + )) + + if not violations: + _, violations = test_published_merge_closure( + device_cls=M7_PublishWithoutCanonicalization + ) + return violations + + +# --------------------------------------------------------------------------- +# M8: revert split_blob_into_slots to per-entry splitting +# (violates the atomic-grouping rule — reproduces Thufir's pass-2/2 CRITICAL) +# --------------------------------------------------------------------------- + +class M8_PerEntrySplit(DeviceB): + """Reverts `split_blob_into_slots` to a per-entry split that violates the + atomic-grouping rule by separating `ov_s:` + frontier from `ov_b:` + `ov_c:`. + + This reproduces Thufir's exact transport witness: + - Slot 0: frontier key + `ov_s:` entry (the "partial set" slot) + - Slot 1: `ov_c:` + `ov_b:` entries + + An observer receiving only slot 0 reconstructs `RegB(s=1, c=0, b=0)` at + `frontier=10`. Because `frontier(10) > b(0)`, the override is baseline-dead. + Canonical re-publication emits tombstone `RegB(0, 1, 0)`. After full + eventual delivery (both original slots + transient tombstone), the merged + result is `RegB(s=1, c=1, b=10)` — dead under clear-wins — permanently + suppressing a live override. + """ + + def split_blob_into_slots(self, tie_policy=CLEAR, n_slots=2): + """Split by key type: frontier + ov_s: in slot 0, ov_b: + ov_c: in slot 1. + Violates the atomic-grouping rule by separating ov_s: from ov_b:.""" + blob = self.publish_blob(tie_policy) + slots = [{"v": blob["v"], "client_id": blob["client_id"], "contexts": {}} + for _ in range(n_slots)] + for wire_key, value in blob["contexts"].items(): + if wire_key.startswith("ov_b:") or wire_key.startswith("ov_c:"): + # ov_b and ov_c go to slot 1 — separated from their ov_s: sibling + slots[1]["contexts"][wire_key] = value + else: + # frontier keys and ov_s: go to slot 0 + slots[0]["contexts"][wire_key] = value + return slots + + +def mutant_m8(): + """M8: per-entry splitting must be caught by test_interleaved_delivery_grouping — + proving that the new interleaved-delivery test has teeth. + + Reproduce Thufir's exact transport witness directly: source live + `RegB(1,0,10)` at frontier=10. Per-entry split puts frontier+`ov_s:c0` + in slot 0 and `ov_c:c0`+`ov_b:c0` in slot 1. An observer receiving only + slot 0 reconstructs `RegB(1,0,0)`, re-publishes tombstone `RegB(0,1,0)`. + Full merge including the transient: `RegB(1,1,10)` → inactive. + + Confirmed by running test_interleaved_delivery_grouping with M8_PerEntrySplit; + the witness must be caught before resorting to the full suite.""" + return test_interleaved_delivery_grouping(device_cls=M8_PerEntrySplit) + + +# --------------------------------------------------------------------------- +# M9: revert split_blob_into_slots to escaped-key grouping +# (groups frontier by wire key instead of unescaped logical ID — +# reproduces Thufir's round-2 CRITICAL) +# --------------------------------------------------------------------------- + +class M9_EscapedKeyGrouping(DeviceB): + """Reverts `split_blob_into_slots` to group the frontier key by its + ESCAPED wire key rather than the unescaped logical context ID. + + For a normal context like "c0", this is a no-op (escape_context_key("c0") + == "c0"), so M9 is identical to the correct model on normal contexts. + The defect only manifests when the raw context ID starts with a reserved + prefix — e.g. raw "ov_s:evil" escapes to frontier wire key "esc:ov_s:evil". + The ov_* sibling keys are keyed by the RAW suffix ("ov_s:evil"), while + the frontier is keyed by the escaped wire key ("esc:ov_s:evil") — two + identities for one logical context, so they land in different slots. + + This reproduces Thufir's round-2 CRITICAL: across publication cycles an + observer can receive the new frontier slot (esc:ov_s:evil=10) plus the + stale old-cycle override slot (ov_s/ov_c/ov_b at b=0), reconstructing + RegB(s=1,c=0,b=0) at frontier=10 — baseline-dead — and emitting tombstone + RegB(0,1,0). Full eventual delivery merges to RegB(1,1,10) — dead under + clear-wins — permanently suppressing a live override. + """ + + def split_blob_into_slots(self, tie_policy=CLEAR, n_slots=2): + """Split by original (escaped) wire key identity — does not unescape + frontier keys before grouping, so escaped contexts split incorrectly.""" + blob = self.publish_blob(tie_policy) + contexts = blob["contexts"] + + groups = {} # wire_key -> list of (wire_key, value) + for wire_key, value in contexts.items(): + if wire_key.startswith("ov_s:"): + ctx = wire_key[5:] + elif wire_key.startswith("ov_c:"): + ctx = wire_key[5:] + elif wire_key.startswith("ov_b:"): + ctx = wire_key[5:] + else: + ctx = wire_key # frontier: use escaped wire key as group ID (BUG) + groups.setdefault(ctx, []).append((wire_key, value)) + + slots = [{"v": blob["v"], "client_id": blob["client_id"], "contexts": {}} + for _ in range(n_slots)] + for i, (_ctx, pairs) in enumerate(sorted(groups.items())): + slot = slots[i % n_slots] + for wire_key, value in pairs: + slot["contexts"][wire_key] = value + return slots + + +def mutant_m9(): + """M9: escaped-key grouping must be caught by test_escaped_context_slot_grouping — + proving that the escaped-context regression test has teeth. + + For a context whose raw ID starts with a reserved prefix ("ov_s:evil"), + the frontier wire key is "esc:ov_s:evil" and the ov_* sibling keys are + "ov_s:ov_s:evil", "ov_c:ov_s:evil", "ov_b:ov_s:evil". The escaped-key + grouping treats "esc:ov_s:evil" (frontier) and "ov_s:evil" (ov_* suffix) + as different groups, splitting the register across slots. + + Old/new slot-coordinate mixture across publication cycles then reproduces + the round-1 transport poison: partial reconstruction → false tombstone → + permanent false clear of a live override. + + The test is parameterized to route through the "mismatched grouping" path + (else branch) when the M9 split puts frontier and siblings in different slots, + and the witness must be caught.""" + return test_escaped_context_slot_grouping(device_cls=M9_EscapedKeyGrouping) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +def run_mutations(): + mutants = [ + ("M1: drop baseline dominance", mutant_m1), + ("M2: drop max(S,C)+1 bump", mutant_m2), + ("M3: tie policy distinguishable", mutant_m3), + ("M4: revert to delete-on-dominance compaction (reproduces pass-3 CRITICAL)", mutant_m4), + ("M5: uint32 overflow bypass", mutant_m5), + ("M6: last-write-wins merge", mutant_m6), + ("M7: publish without canonicalization (reproduces pass-1/2 CRITICAL)", mutant_m7), + ("M8: per-entry split violates atomic-grouping rule (reproduces pass-2/2 CRITICAL)", mutant_m8), + ("M9: escaped-key grouping splits escaped-ctx register across slots (reproduces round-2 CRITICAL)", mutant_m9), + ] + + print("=" * 60) + print("Mutation harness — candidate B") + print("=" * 60) + + caught = [] + missed = [] + for name, fn in mutants: + violations = fn() + if violations: + caught.append(name) + v = violations[0] + detail = str(v)[:200] + print(f" CAUGHT: {name}") + print(f" counterexample: {detail}") + else: + missed.append(name) + print(f" MISSED: {name}") + + print(f"\nCaught {len(caught)}/{len(mutants)} mutants") + if missed: + print(f"MISSED: {missed}") + print("=" * 60) + return len(missed) == 0 + + +if __name__ == "__main__": + import sys + sys.exit(0 if run_mutations() else 1) diff --git a/docs/nips/NIP-RS.md b/docs/nips/NIP-RS.md index 1ca30ea08f..6a095df60a 100644 --- a/docs/nips/NIP-RS.md +++ b/docs/nips/NIP-RS.md @@ -20,15 +20,14 @@ read. A user running Nostr clients on multiple devices (phone, desktop, web) has no way to share read position across those clients. Each instance independently tracks what has been read, causing already-read content to appear unread on other devices. -This NIP defines a minimal, privacy-preserving protocol for propagating read state across client instances without requiring relay-side logic or coordination between different client implementations. +This NIP defines a minimal, privacy-preserving protocol for propagating read state across client instances without requiring a new event kind, a new wire message, relay-stored read-state logic, or coordination between different client implementations. It is not free of relay obligations: a relay serving the manual-unread override layer's full-state load must satisfy the ordering, capacity, floor, push, and barrier contract that section enumerates. ## Non-Goals -This NIP does not define a durable log of all read messages — blobs are best-effort recent activity hints bounded by a time horizon. +This NIP does not define a durable log of all read messages — frontier blobs are best-effort recent activity hints bounded by a time horizon. Exception: `ov_*` override entries, including tombstone floors, are durable state — they are exempt from age pruning, budget eviction, and horizon-bounded fetching, they live in a single coordinate per installation, and they MUST be carried forward before that coordinate is deleted or abandoned (see Manual-Unread Override Layer — Override State Durability). This NIP does not define cross-client interoperability on context ID format — context identifiers are opaque by default and meaningful only within a single client family, except for OPTIONAL well-known schemes defined in this NIP (`thread:` and `msg:`, defined under Read Context Schemes), which are provided for cross-client thread/message-read interoperability. -This NIP does not define mark-as-unread — the merge rule is monotonic by design. This NIP does not guarantee ordering of read events across devices. -This NIP does not require relay-side logic. +This NIP does not require relay-stored read-state logic: no new event kind, no new wire message, and nothing a relay must interpret about read state. Clients implementing the manual-unread override layer do depend on relay behaviour their full-state load cannot verify (see Full-State Load). This NIP does not define read receipts, seen-by lists, or any mechanism for tracking what other users have read. @@ -53,18 +52,26 @@ Clients publish a `kind:30078` addressable event (per [NIP-78](78.md)) with the #### `d` Tag -The `d` tag MUST be `read-state:`, where `` is a random opaque string (e.g., 32 random hex characters) generated by the client on first launch and persisted locally. The `` has no relationship to the `client_id` — it is solely a unique key for NIP-33 addressable event semantics. Each client instance MUST use a stable, unique `` for the lifetime of that installation. +The `d` tag MUST be `read-state:`, where `` is exactly 32 lowercase hexadecimal characters (`[0-9a-f]{32}`), generated randomly by the client on first launch and persisted locally. The `` has no relationship to the `client_id` — it is solely a unique key for NIP-33 addressable event semantics. The shape is fixed rather than opaque so that a relay can recognize a read-state coordinate structurally, from the `d` tag alone and without decrypting anything, and apply per-coordinate protections to it; a client that picks some other shape is not merely stylistically different, it forfeits those protections silently. + +**Primary coordinate:** a client MUST designate one coordinate as its **primary** and MUST use a single stable, unique `` for it for the lifetime of that installation. The primary `` changes only on a `client_id` conflict (below) or rotation (see Client-ID Rotation). + +**Additional frontier-only coordinates:** a client MAY publish additional coordinates under distinct `` values when its primary blob would otherwise exceed the size budget. Additional coordinates MUST NOT contain `ov_*` entries — they carry frontier entries only, and are therefore freely rewritable and freely deletable (see Orphaned Blob Deletion). A client MUST persist the `` values of its additional coordinates locally so that it can rewrite and delete them. + +**All `ov_*` entries, and the frontier entries of the contexts they belong to, MUST live in the primary coordinate.** A client implementing the manual-unread override layer MUST NOT distribute `ov_*` entries across coordinates and MUST NOT move them between coordinates: there is exactly one override-bearing coordinate per installation. If a client fetches its own `d` tag coordinate and the decrypted `client_id` does not match its local `client_id`, the coordinate is conflicted. The client MUST NOT publish to that coordinate and MUST generate a new random `` before the next publish. Events with zero `d` tags MUST be ignored. Events whose `d` tag value does not begin with `read-state:` MUST be ignored. Events with more than one `d` tag MUST be ignored. -The `` MUST be a non-empty ASCII string of 1–64 characters. +Events whose `` is not exactly 32 lowercase hexadecimal characters MUST be ignored. + +Recognizable coordinates also serve the accumulation discipline this NIP depends on: a relay that can identify a read-state coordinate structurally can replace superseded versions outright instead of retaining a tombstone row per publish, which keeps the coordinate count a full-state load must enumerate near one per live installation (see Full-State Load). #### `t` Tag -Events MUST include exactly one `["t", "read-state"]` tag. This enables relay-side filtering without fetching all `kind:30078` events for the user. +Events MUST include exactly one `["t", "read-state"]` tag. The tag is a discoverability marker: it lets a client express "read-state events only" in a single filter. It is not a guarantee of relay-side selectivity — a relay MAY apply tag constraints after its result cap, and `kind:30078` is shared with unrelated application data — so clients MUST apply the tag as a correctness filter locally on everything they receive, and MUST NOT infer from a short result that no further coordinates exist. A client performing a full-state load MUST omit the tag from its filter entirely (see Full-State Load). Events with zero `t` tags with value `read-state`, or more than one `t` tag with value `read-state`, MUST be ignored. @@ -104,6 +111,7 @@ After decryption, clients MUST apply the following validation rules: - Events whose `contexts` field is not a JSON object MUST be discarded. - Individual context entries whose timestamp is not an integer in the range 0–4294967295 MUST be discarded (the entry is dropped; the rest of the blob is still processed). - Individual context entries whose context ID exceeds 256 bytes MUST be discarded. +- Override counter entries (keys beginning with `ov_s:`, `ov_c:`, or `ov_b:`) MUST be validated as a complete logical group BEFORE any decoding, zero-filling, merging, or canonicalizing. Clients MUST collect all `ov_s:`, `ov_c:`, and `ov_b:` entries for the same `` suffix together before processing them. The only accepted wire shapes for an override group are: (a) a complete live group containing exactly the three keys `ov_s:`, `ov_c:`, and `ov_b:` with valid uint32 values, or (b) a tombstone floor containing only `ov_c:` with a valid uint32 value. Any other shape (partial group, extra keys, or invalid value in any sibling) MUST cause the entire override group to be rejected; the corresponding frontier entry for `` MUST be retained. Applying the generic per-entry discard rule before group collection is prohibited for override entries. - Blobs containing more than 10,000 context entries MUST be rejected. - If a blob contains duplicate context keys, clients SHOULD use the last value encountered (consistent with RFC 8259 §4). - Clients SHOULD ensure the total serialized event does not exceed the relay's maximum event size (commonly 64 KB per NIP-01). Clients receiving events that exceed their configured size limit SHOULD discard them. @@ -112,6 +120,15 @@ After decryption, clients MUST apply the following validation rules: Context identifier format is not prescribed by this NIP. Clients choose identifiers appropriate to their context type (e.g., a NIP-28 channel event ID, a NIP-29 group address, a pubkey for DMs). Interoperability between different client implementations on context ID conventions is outside the scope of this NIP. +#### Reserved Namespace + +The key prefix stem `ov_` (3 bytes) and the escape marker `esc:` (4 bytes) are reserved for the manual-unread override layer defined below. Clients MUST escape any raw context ID that begins with `ov_` or `esc:` when using it as a frontier key in the `contexts` map: + +- **On publish:** prepend `esc:` to any raw context ID beginning with `ov_` or `esc:` before writing it as a frontier key (e.g., raw `ov_s:evil` → wire key `esc:ov_s:evil`; raw `esc:foo` → wire key `esc:esc:foo`). +- **On receive:** strip exactly one leading `esc:` from any frontier wire key beginning with `esc:` to recover the raw context ID (e.g., wire key `esc:ov_s:evil` → raw `ov_s:evil`; wire key `esc:esc:foo` → raw `esc:foo`). This is a bijection — applying escape then unescape is the identity function. Clients MUST NOT strip more than one `esc:` prefix per receive. + +**Backward-compatibility limitation:** a context published *unescaped* by a client predating this amendment, whose raw ID happens to start with `ov_` or `esc:`, is not safely migrated. The scheme protects contexts generated by amendment-aware clients going forward; it does not retroactively rewrite history. This residual hazard is documented as a known limitation. Buzz's own context ID shapes (channel UUID, `msg:hex64`, `thread:hex64`) cannot trigger it. + #### Read Context Schemes (Optional) This subsection defines OPTIONAL well-known context schemes for tracking read @@ -281,13 +298,13 @@ Because context timestamps are derived from message `created_at` values — whic ### Fetching -To load read state, a client MUST fetch all `kind:30078` events for the user within the time horizon using the `#t` filter: +To load read state, a client MUST fetch all `kind:30078` events for the user. Unless it is performing a full-state load (below), it SHOULD narrow the fetch with the `#t` filter: ```json -{"kinds": [30078], "authors": [""], "#t": ["read-state"], "since": } +{"kinds": [30078], "authors": [""], "#t": ["read-state"]} ``` -Clients SHOULD limit the fetch to events with `created_at` within a configurable time horizon (default: 7 days). +Clients that neither read nor write `ov_*` override state SHOULD limit the fetch to events with `created_at` within a configurable time horizon (default: 7 days) by adding `"since": `, accepting that frontiers older than the horizon become unknown. Clients that implement the manual-unread override layer MUST NOT filter the fetch by age or by tag, and MUST establish completeness (see Full-State Load below). After fetching, clients MUST: @@ -295,11 +312,71 @@ After fetching, clients MUST: 2. Discard blobs that fail validation (see Content Validation). 3. Identify the blob whose decrypted `client_id` matches the client's own `client_id` — this is the client's own blob. -If multiple blobs decrypt to the same `client_id` (e.g., due to a prior rotation that left an orphaned blob, or a backup/restore that duplicated identifiers), the client MUST treat the blob with the highest `created_at` as its own and merge all others into the read state as if they were from other instances. The client SHOULD delete the stale duplicate(s) via NIP-09 deletion. +If multiple blobs decrypt to the same `client_id` (e.g., due to a prior rotation that left an orphaned blob, or a backup/restore that duplicated identifiers), the client MUST treat the blob at its own primary coordinate as its own and merge all others into the read state as if they were from other instances. If none of them is at the client's own primary coordinate, the blob with the highest `created_at` is its current reference. Deletion of such a stale duplicate is governed by Orphaned Blob Deletion — a duplicate carrying `ov_*` entries MUST NOT be deleted until its override state has been carried forward. 4. Merge all valid blobs (including the client's own) using the merge rule. -Absence of a context in all fetched blobs means the read state for that context is **unknown** — clients SHOULD treat unknown contexts as unread (conservative default). The horizon is a storage and fetch optimization, not a semantic claim about read status. Contexts that were read but have aged out of the time horizon are indistinguishable from never-read contexts. Clients MAY extend the horizon or maintain a local cache to mitigate this. +Absence of a context in all fetched blobs means the read state for that context is **unknown** — clients SHOULD treat unknown contexts as unread (conservative default). For clients using a finite horizon, the horizon is a storage and fetch optimization, not a semantic claim about read status: contexts that were read but have aged out of the time horizon are indistinguishable from never-read contexts. Clients MAY extend the horizon or maintain a local cache to mitigate this. Clients implementing the override layer do not filter the fetch by age at all (see above), so for them this ambiguity arises only from write-time frontier pruning. + +#### Full-State Load + +Clients that implement the manual-unread override layer MUST perform a **full-state load**: they MUST NOT apply a finite `since` filter, and they MUST establish that every one of the user's `read-state` coordinates has been retrieved. Because the payload is encrypted, a relay filter cannot select for override-bearing events: any event-level window can exclude the only coordinate carrying a tombstone floor, which reopens the resurrection witness in Override State Durability regardless of any per-entry exemption. For these clients the time horizon is a *write-time* frontier pruning policy only (see Debounce and Pruning), never a fetch filter. + +Removing `since` does not by itself make the result complete. Relays MAY cap the number of events returned for a historical query, MAY cap below the client's requested `limit`, and emit end-of-stored-events after the capped query — so **a single query proves nothing.** End-of-stored-events marks the end of the capped result, not the end of the matching set, and a short result does not establish that no further coordinates exist. Caps typically retain the newest events and drop the oldest, which are precisely the rotation-predecessor and orphaned coordinates whose tombstone floors this layer depends on. A silently truncated load that omits the sole carrier of a floor merges a stale live register unopposed and reports a manually-unread context as read, permanently. + +A full-state load MUST therefore be enumerated with no tag constraint in the filter: + +```json +{"kinds": [30078], "authors": [""], "limit": } +``` + +A relay MAY deliver fewer events than its result cap selected — for example, by applying tag constraints only after the cap and withholding the events that fail them. Under a tag-constrained filter the number of events the client receives is therefore not the number the cap selected: a delivered page can be short, or empty, while older matching coordinates still exist below it, and no observation the client can make distinguishes the two. `kind:30078` is arbitrary application data whose `d` tag namespace is open to every application that has ever written under the user's key, so this is not a hypothetical — a page can be filled entirely by coordinates unrelated to read state. With the tag constraint omitted, the client asks for exactly what it will accept, and the events the cap selects are the events it receives. Selection moves client-side, which is where the validation rules already place it: collect coordinates only from `d` tags of the form `read-state:` and ignore every other event. The cost is that the client fetches its own application data at that kind rather than a relay-selected subset of it. + +A client MUST NOT test completeness by comparing the number of events returned against the `limit` it requested: the effective cap is the relay's, a relay MAY cap below the requested value, and a relay's advertised maximum limit is not necessarily the limit it enforces — so no comparison against the requested `limit` is a valid truncation test. What the client MAY compare is one delivery against another. Let `C` be the largest number of events the relay delivered for any single **preceding** query in this load. The relay demonstrably delivered `C` events at once, so its cap is at least `C`, and a query that delivers fewer than `C` events was not cut short by that cap. Completeness is established by continuation on a strictly decreasing cursor, with each band discharged by that comparison. + +`C` yields nothing at the start of a load, and yields nothing for the whole of a load whose entire history at this kind is a single event: one delivery of one event bounds the cap below by one, and no delivery can be smaller than that. The procedure therefore also fixes a floor, `L = 2`, required of relays below. A delivery is bounded by the requested `limit` as well as by the relay's cap, so the floor licenses a conclusion about a delivery only together with step 1's requirement that the requested `limit` be at least `L`: what the client may conclude is that a query it issued for at least `L` events, whose matching set holds at least `L`, delivers at least `L`. A threshold above the requested `limit` would be unreachable by construction, and a test that can never be met declares a truncated page exhausted. It is stated at the smallest value that admits a second event, because a larger floor is a stronger claim about relays that buys nothing further: a relay that will deliver only one event per query cannot express `limit` semantics and cannot serve a user who has two coordinates at all, whereas any floor above two would begin excluding relays this procedure does not need to exclude. + +Enumeration descends, so it cannot see a coordinate that moves *up* while it runs. Because these are addressable events, a republish replaces the previous version rather than appending: a coordinate the client already collected at a low `created_at` can be replaced, during the load, by a version above the cursor the client has already passed — and the old version stops existing, so no continuation and no pinned window will ever return either one. If that new version carries a tombstone floor the old one lacked, a load that reported *complete* merged without it. + +A full-state load therefore MUST be fenced by a live subscription on the same tag-free filter, established **before** the first enumeration query and held unbroken for the duration of the load. The fence is *established* when the client has received end-of-stored-events for that subscription, not when it sent the request: sending a request is not an observation, and the relay's answer to it is the first point at which the client knows the subscription is registered and that what the relay accepts from then on will be pushed to it. The fence and every enumeration query MUST be issued on the same connection. + +Every event the fence delivers is collected exactly as an enumerated event is (step 2), which is what repairs the moved coordinate: the replacing event is itself what the relay pushes. Delivery is necessary but not sufficient — it must be delivery *before the verdict*, and those are different properties. Under push delivery alone, a relay that accepts a replacement, removes the version sitting below the cursor, and pushes the replacement some time later has violated nothing: the enumeration in between finds neither version, the pinned window and the continuation both come back empty, and the load reports *complete* moments before the fence delivers the floor it was missing. Ordering that push ahead of the verdict is what the delivery barrier below requires of the relay. On the client side, the verdict MUST NOT be rendered until end-of-stored-events for the final continuation has been received and every fence delivery received before it has been collected. + +If the client did not hold such a subscription for the whole load, or it lapsed or reconnected at any point during it, the load is potentially incomplete regardless of what the enumeration returned. A client MUST NOT publish to its own coordinates while its own load is in progress; a self-inflicted replacement is the same defect with the client on both ends of it. + +1. Every query MUST carry the same explicit `limit` `n`, `n` MUST be at least `L`, and no query MUST constrain tags. `C` and the floor are only meaningful across queries that differ solely in their time bounds. `n` SHOULD be substantially larger than `L`: `n` bounds how many events a single band can retrieve, so a small `n` costs round trips without making any verdict safer. +2. From each delivered event, collect the read-state coordinates — those whose `d` tag has the form `read-state:` — deduplicating by `d` tag value and retaining, of the entries sharing a `d` tag value, the one with the greatest `created_at`, and on equal `created_at` the one with the lexicographically lowest event id. That is the addressable ordering NIP-01 defines, and both halves of it are load-bearing here: a replacement published in the same second as the version it replaces is legal and is the one the relay retains, so a retention rule that only compares `created_at` may keep the superseded version even when the fence delivered its successor perfectly. Ignore the other events, but count them: they are part of what the cap returned. Events the fence delivers are collected the same way, but MUST NOT contribute to `T` or to `C`: they are not a query result, and an event arriving below the cursor would otherwise move it down and skip the band between. Collection is what recovers a moved coordinate; the cursor descends on query results alone. +3. Let `T` be the lowest `created_at` across **all** events delivered by the queries in this load, not only the read-state ones. The cursor therefore advances on every non-empty page, including one that yielded no coordinate. +4. Before advancing past `T`, the client MUST query the pinned window `{"since": T, "until": T}` and merge the result. A cap can cut mid-second, leaving events at `T` that a continuation at `"until": T - 1` would skip forever; pinning both bounds to one second removes every event outside that second from contention for the cap. +5. Second `T` is exhausted only if that pinned query delivered fewer than `max(C, L)` events. If it delivered `max(C, L)` or more, the cap may have bound inside the second, no finer cursor exists on the standard filter surface, and the load is **potentially incomplete** and MUST be reported as such. This verdict is terminal for the load: the client MUST NOT continue to step 6, and no later observation upgrades it. A continuation past an undischarged second can deliver nothing simply because that second was the oldest, so an empty continuation is not evidence that the second above it was exhausted. +6. Otherwise continue with `"until": T - 1`. Because a bare `until` is inclusive, decrementing guarantees each continuation covers a strictly older band, so the loop makes progress regardless of how the relay caps. +7. The load is **complete** when a continuation delivers no events at all, every preceding second having been discharged by step 5, the fence having been established before the first query and held unbroken since, and every fence delivery received up to that continuation's end-of-stored-events having been collected. Because the filter constrains nothing the relay applies after its cap, an empty delivery is an empty result — a cap that returns nothing is not a cap. +8. A load that failed, or whose fence lapsed, on any relay the client publishes to is potentially incomplete (see Read-Before-Write). + +This layer places five requirements on every relay a client performs a full-state load against. They are stated normatively, not as background assumptions, because a *complete* verdict rests on them and none of them is verifiable from the responses a client receives: + +- **Newest-first prefix delivery.** A capped result MUST consist of the newest events by `created_at` for the filter, ties broken by lowest id — the delivery NIP-01 already specifies for `limit`. A relay that caps by returning some other subset can omit an event lying *above* the cursor the client derives from that same delivery, so the omitted event is never queried at all. Repeating a query cannot recover it, because the same filter with the same bounds is the same request. +- **Non-decreasing effective cap within a load.** A relay MUST NOT reduce, within a single load, the number of events it will deliver for queries that differ solely in their time bounds. A cap that shrinks between the query establishing `C` and a later pinned window makes that window's short delivery indistinguishable from exhaustion, which converts a truncated second into a discharged one. +- **The floor `L`.** A relay MUST deliver at least `L` events for a query whose matching set holds at least `L` and whose requested `limit` is at least `L`. `L = 2`, fixed by this NIP; a client MUST NOT derive it from relay-advertised discovery, because an advertised maximum is not necessarily the limit a relay enforces. +- **Push delivery on an open subscription.** A relay MUST deliver every event it accepts that matches an open subscription's filter to that subscription. The mutation fence in step 2 is exactly this delivery; a relay that accepts a replacement without pushing it gives the client no way to observe a coordinate that moved above the cursor. +- **The delivery barrier.** Before a relay sends end-of-stored-events for a query, every event it accepted before that query read its stored events, and which matches an open subscription on the same connection, MUST already have been delivered to that subscription. Push delivery alone promises only that the replacement arrives eventually; the barrier is what places it before the verdict that depends on it. Without it, a relay whose accept path and query path proceed independently can answer a query from storage the replacement has already changed while the corresponding push is still pending, and the client discharges the load in the interval between the two. + +A client cannot distinguish a relay that violates any of these from one that simply had fewer events to return, so these are conformance preconditions of this layer rather than properties a load establishes. A client MUST NOT perform a full-state load against a relay it knows, or has evidence, to violate them, and MUST treat any load against such a relay as potentially incomplete. Conditioning *complete* on positive proof of these properties instead would be equivalent to never issuing it — no such proof exists on the standard filter surface — which would withdraw the override layer from every client rather than from the non-conforming relays. + +The comparison in step 5 fails safe: a pinned window is reported potentially incomplete unless the relay has already shown it will deliver at least that many at once, so an inconclusive result is never mistaken for an exhaustive one. A plateau of more events at a single `created_at` than the relay will deliver for a window pinned to that second is therefore unenumerable, because this NIP defines no finer cursor, and it resolves to *cannot prove complete* rather than to a false *complete*. The comparison is a lower bound on the cap rather than the cap itself, so it is also conservative in the other direction: a load whose oldest second holds as many events as the largest delivery observed so far resolves to *cannot prove complete* even where the relay would have delivered more. Where more than one coordinate exists this is transient, because any later publish moves that coordinate to a different second and separates the two. + +The floor is the narrowest of the five requirements and the one that makes the ordinary case reachable at all. A coordinate at a replaceable kind contributes exactly one event no matter how many times it is republished, because a republish replaces the previous version rather than appending to it; a client's event count at this kind therefore does not grow over time, and a single-installation client publishing under one coordinate has one event at one second permanently. Without a floor, `C` for such a client is one, its pinned window delivers one, and step 5 can never be discharged — mark-unread would be permanently unavailable to the most common conforming deployment, and no amount of waiting or republishing would change the observation. `L = 2` discharges it: the pinned window delivers one event, `max(C, L)` is two, `1 < 2`, the second is exhausted, and the continuation below it is empty. That same replacement behaviour is what the fence exists for: the one event a coordinate contributes can move, and it moves by being replaced. + +A **potentially incomplete** load MUST NOT be the basis for any of the following, each of which either destroys override state or asserts authority over it: + +- canonical compaction of an override register (see Mandatory Canonical Publication), +- publishing a canonicalized override blob, +- deleting or abandoning any coordinate (see Orphaned Blob Deletion), +- reporting an explicit mark-read as successful (see Actions). + +Until a complete load succeeds, the client MUST evaluate unread state from its own locally persisted state and MUST report override actions as failed rather than acting on a partial view. The honest terminal states are *complete* and *cannot prove complete*; a client MUST NOT treat the second as the first. + +The number of coordinates a full-state load must retrieve is bounded by the number of installations that have ever used the override layer, plus their not-yet-deleted rotation predecessors. It grows with the user's device history, not with elapsed time, and — because a coordinate carrying `ov_*` entries may not be deleted until it has been carried forward (see Client-ID Rotation) — it does not shrink on its own. Clients SHOULD carry forward and delete rotation predecessors promptly so the count stays near one coordinate per live installation. ### Merge Rule @@ -311,6 +388,8 @@ effective[context] = max(timestamp) across all blobs This is a grow-only max-register state-based CvRDT with an associative, commutative, idempotent join. Clients MUST NOT lower a read timestamp — only advance it. +The manual-unread override layer (see Manual-Unread Override Layer below) adds per-context set/clear counters merged by the same componentwise `max()` rule. The frontier merge rule is unchanged. + ### Writing Clients MAY publish read state automatically when read-position sync is part of @@ -320,28 +399,32 @@ to other users MUST require explicit user consent. Clients SHOULD publish read state blobs to the same relays they use for general event storage. Clients that implement NIP-65 (relay list metadata) SHOULD publish to their write relays and fetch from their read relays. -Each client instance maintains its own blob (one `kind:30078` event per ``). Writing replaces the previous blob via parameterized replaceable event semantics ([NIP-33](33.md)). +Each client instance maintains its own primary blob (one `kind:30078` event at its primary coordinate), plus one event per additional frontier-only coordinate if it uses any. Writing replaces the previous blob at each coordinate via parameterized replaceable event semantics ([NIP-33](33.md)). -Clients MUST only update the blob whose decrypted `client_id` matches their own `client_id`. Clients MUST NOT overwrite another instance's blob. +Clients MUST only update blobs whose decrypted `client_id` matches their own `client_id`. Clients MUST NOT overwrite another instance's blob. -If the client discovers multiple blobs with its own `client_id` during a fetch, it MUST select the one with the highest `created_at` as its active blob and SHOULD delete the others. +If the client discovers same-`client_id` blobs at coordinates that are neither its primary nor one of its known additional coordinates (e.g., rotation orphans or backup/restore duplicates), it MUST merge them into its own state and MUST NOT delete them until their override state has been carried forward (see Orphaned Blob Deletion). It MUST NOT publish to them: its own writes go to its primary and its known additional coordinates only. #### Read-Before-Write Before publishing, a client MUST: -1. Fetch its own current blob from each relay it intends to publish to, and merge all fetched versions. +1. Fetch its own current blob(s) from each relay it intends to publish to, and merge all fetched versions. -The client fetches its own blob using its known `d` tag value: +A client fetches its own coordinates using their known `d` tag values — its primary, plus its additional frontier-only coordinates if any — and unions them componentwise: ```json -{"kinds": [30078], "authors": [""], "#d": ["read-state:"]} +{"kinds": [30078], "authors": [""], "#d": ["read-state:", "read-state:", ...]} ``` -2. Decrypt and merge the fetched blob with local state using `max()` per context. +A read-before-write fetch of the client's own coordinates is not a full-state load: it cannot discover rotation orphans or duplicates. Before canonicalizing override state or publishing a canonicalized override blob, the client MUST have a complete full-state load (see Full-State Load). + +2. Decrypt and merge the fetched blob(s) with local state using `max()` per context. 3. Publish the merged result. -If a relay is unreachable during the fetch step, the client SHOULD proceed with the data available from reachable relays. The merge rule ensures that data from the unreachable relay will be incorporated on the next successful fetch, provided the relay retains the event. Permanent relay loss or event expiry may result in state loss — this is an accepted property of the best-effort model (see Non-Goals). +If a relay is unreachable during the fetch step, the client SHOULD proceed with the data available from reachable relays. The merge rule ensures that data from the unreachable relay will be incorporated on the next successful fetch, provided the relay retains the event. Permanent relay loss or event expiry may result in loss of frontier state — this is an accepted property of the best-effort model (see Non-Goals). + +That accepted loss does not extend to override state. A client MUST NOT treat a fetch that failed on any relay it publishes to as a complete view of its own override state, and MUST NOT canonicalize, publish canonicalized override state, or delete or abandon any of its own coordinates on the basis of such a partial fetch (see Full-State Load). Clients implementing the override layer SHOULD publish override state to more than one relay so that the loss of a single relay does not erase a tombstone floor. This read-before-write requirement also applies to re-publishes triggered by incoming blobs from other instances (see Live Subscription and Convergence). @@ -358,12 +441,14 @@ Clients SHOULD subscribe to `kind:30078` events for their own pubkey with `#t: [ When a blob from another client instance arrives (i.e., its decrypted `client_id` does not match the client's own `client_id`): 1. Merge it into local state using `max()` per context. -2. If any context timestamp in the incoming blob is greater than the corresponding timestamp in the client's last-published blob (or the context is absent from the last-published blob), perform a read-before-write and re-publish the client's own blob after a debounce delay. -3. Clients MUST suppress the re-publish if the merged result is identical to the client's last-published blob. A client that has never published treats its last-published blob as empty. +2. Canonicalize the merged override state against the client's own effective frontier (applying the tombstone floor and live/dead/virgin rules from Mandatory Canonical Publication). If any context entry in the canonical merged result differs from the corresponding entry in the client's last-published canonical blob (or the context is absent from the last-published blob), perform a read-before-write and re-publish the client's own blob after a debounce delay. +3. Clients MUST suppress the re-publish if the canonical merged result is identical to the canonical form of the client's last-published blob. Comparing canonical-to-canonical prevents a retained live peer blob (which the client has already tombstoned) from triggering an identical write on every replay. A client that has never published treats its last-published blob as empty. 4. Clients SHOULD limit re-publishes triggered by incoming blobs to at most one per debounce window, regardless of how many blobs arrive during that window. This drives convergence without a coordination round-trip, assuming eventual relay reachability and event retention. +A live subscription is not a full-state load. A relay MAY return a capped set of stored events before end-of-stored-events on this filter, so a client implementing the override layer MUST NOT treat what the subscription delivers as a complete view of its coordinates (see Full-State Load). Merging an incoming blob into local state (step 1) is always safe, because merge is componentwise `max()`; the canonicalize-and-re-publish in steps 2–3 is a canonical publication and therefore requires a complete full-state load. A client that does not have one MUST defer the re-publish rather than publish a canonical blob derived from a partial view. A subscription is nonetheless a required *component* of a full-state load, serving as its mutation fence, and the fence MUST use the tag-free filter rather than the `#t`-narrowed one above — a replacement it fails to deliver is a replacement the descending enumeration cannot recover. + #### Clock Skew When publishing, if the client's local clock produces a `created_at` value less than or equal to the maximum `created_at` seen across all fetched blobs for the same `d` tag, the client MUST use `max_fetched_created_at + 1` instead. @@ -372,17 +457,158 @@ When publishing, if the client's local clock produces a `created_at` value less Clients SHOULD debounce writes to avoid excessive relay traffic (e.g., flush 5–10 seconds after the last local read-state change, or on app close/background transition). Clients MUST NOT write on every individual read action. -The blob SHOULD contain only contexts the client has explicitly interacted with. Clients SHOULD prune aggressively, prioritizing recently-active contexts, and MAY drop entries older than the time horizon before writing. Clients MUST ensure the published event does not exceed relay event size limits (typically 64 KB content). +The blob SHOULD contain only contexts the client has explicitly interacted with. Clients SHOULD prune aggressively, prioritizing recently-active contexts, and MAY drop frontier entries older than the time horizon before writing. Clients MUST NOT drop `ov_*` override entries (including tombstone floors) based on age or budget pressure; see Override State Durability in the Manual-Unread Override Layer section. Clients MUST ensure the published event does not exceed relay event size limits (typically 64 KB content). #### Client-ID Rotation -Clients MAY rotate their `client_id` by generating a new one, generating a new random ``, and publishing a new blob. The old blob becomes orphaned and ages out of the time horizon naturally. Rotation adds one extra blob temporarily. Clients SHOULD keep their `client_id` stable for as long as possible to minimize blob proliferation. +Clients MAY rotate their `client_id` by generating a new one, generating a new random `` for the primary coordinate, and publishing a new blob. Rotation adds one extra blob temporarily. Clients SHOULD keep their `client_id` stable for as long as possible to minimize blob proliferation. + +Rotation is the only event that changes a client's override-bearing coordinate, and it carries the layer's single durability obligation: + +**Carry-forward rule.** Before deleting or abandoning its previous primary, a rotating client MUST publish the componentwise `max()` of every override register the old primary holds — every tombstone ceiling included — under its new primary, and MUST confirm acceptance **on every relay from which the old primary will be deleted or allowed to lapse**. If any such relay rejects the publish or is unreachable, the client MUST retain the old primary on that relay and MUST NOT delete it there. Acceptance on one relay does not authorize deletion on another: a relay that never received the replacement would otherwise be left with no local carrier of the floor. The old primary MUST NOT be left to age out while it is the only carrier of an override floor on any relay. -If a device backup or clone results in two installations sharing the same `client_id` and `slot-id`, both will write to the same blob. This is operationally equivalent to a single client and does not corrupt state, but the two installations will overwrite each other's context entries. Clients that detect this condition (e.g., by observing unexpected context changes in their own blob) SHOULD generate a new `client_id` and `slot-id`. +Additional frontier-only coordinates carry no override state, so rotation may abandon or delete them freely. + +If a device backup or clone results in two installations sharing the same `client_id` and primary ``, both will write to the same coordinate. This is operationally equivalent to a single client and does not corrupt state, but the two installations will overwrite each other's context entries. Clients that detect this condition (e.g., by observing unexpected context changes in their own blob) SHOULD generate a new `client_id` and a fresh primary ``, again carrying override state forward per the carry-forward rule. #### Orphaned Blob Deletion -Clients MAY delete blobs from decommissioned client instances by publishing a `kind:5` deletion event per [NIP-09](09.md) targeting the orphaned event's `a` tag coordinate (`30078::`). This is optional — orphaned blobs are harmless and age out naturally. +Clients MAY delete blobs from decommissioned client instances by publishing a `kind:5` deletion event per [NIP-09](09.md) targeting the orphaned event's `a` tag coordinate (`30078::`). For blobs carrying no `ov_*` entries — including a client's own additional frontier-only coordinates — this is optional and unconditional: such blobs are harmless and age out naturally. + +A blob carrying `ov_*` entries MUST NOT be deleted or abandoned until its override state has been carried forward per the carry-forward rule in Client-ID Rotation. This applies to the client's own previous primary and to same-`client_id` orphans discovered from a prior rotation or a backup/restore. A client's record of its own coordinates MAY be stale — for example restored from a backup taken before a rotation — so an unknown same-`client_id` coordinate MUST be treated as a live carrier of override state, not as a deletable duplicate, until it has been merged and carried forward. + +### Manual-Unread Override Layer + +This section defines a manual mark-as-unread mechanism as a CRDT override layer within the existing `contexts` map. It does not change the frontier merge rule, event structure, or encryption scheme. Fetching follows the override-specific full-state procedure (see Full-State Load) rather than the horizon-bounded fetch used by clients that do not implement this section. Clients that do not implement this section remain fully interoperable (see Backwards Compatibility). + +#### Wire Encoding + +For each manually-unread context ``, a client publishes up to three sibling keys alongside the existing frontier entry in the `contexts` map: + +| Key | Type | Description | +|-----|------|-------------| +| `ov_s:` | uint32 | Set counter S — incremented on each mark-unread | +| `ov_c:` | uint32 | Clear counter C — incremented on each mark-read | +| `ov_b:` | uint32 | Baseline B — the effective frontier value at the time of the most recent mark-unread | + +Values MUST be integers in the range 0–4294967295 (same validation range as context timestamps). The `` suffix is the raw context ID without any escaping (escaping applies only to the frontier wire key; see Reserved Namespace). + +#### Merge Rule (Override Registers) + +Override counters are merged by componentwise `max()`, identical to the frontier merge rule: + +``` +merged_S[ctx] = max(S) across all blobs +merged_C[ctx] = max(C) across all blobs +merged_B[ctx] = max(B) across all blobs +``` + +No new wire-level merge logic is required. The same `mergeReadStateEvents` path that joins frontier timestamps joins the counter entries as integer max. + +#### Liveness Predicate + +A context `ctx` has an active manual-unread override if and only if ALL of the following hold, evaluated against the merged register `(S, C, B)` and the merged effective frontier `F`: + +1. `S > 0` — at least one mark-unread action has been recorded. +2. `F <= B` — the effective frontier has not advanced past the baseline captured at mark-unread time. (A natural frontier advance strictly past `B` dominates a stale set, clearing the override without any explicit clear action.) +3. `S > C` — set counter exceeds clear counter. (`S == C` is treated as inactive: clear wins on ties — see Tie Policy.) + +Formally (clear-wins is the only conforming tie policy — see Tie Policy): + +``` +override_active(S, C, B, F) = + S > 0 + AND F <= B + AND S > C +``` + +The **unread verdict** for a context is: + +``` +unread(ctx) = (latest_message_ts > F) OR override_active(S, C, B, F) +``` + +where `latest_message_ts` is the `created_at` of the newest message in the context. + +#### Actions + +Every action below requires a complete full-state load (see Full-State Load); on a potentially incomplete load the client MUST report the action as failed rather than act on a partial view of its own override state. + +**Mark-unread:** increment S to `max(S, C) + 1`; set B to the current effective frontier value for the context. C is unchanged. If `max(S, C) == 4294967295` (uint32 maximum), the client MUST refuse the mark-unread action and leave the register unchanged; wrapping or resetting to zero is prohibited. + +**Mark-read (explicit):** advance the frontier to cover the context as normal; increment C to `max(S, C) + 1`. S and B are unchanged. If `max(S, C) == 4294967295`, no representable counter increment exists; wrapping or resetting to zero is prohibited. The client MUST then complete the action only if the resulting state satisfies `override_active == false` — i.e. the frontier advance alone deactivates the override, or the override was already inactive. Otherwise the counters MUST be left unchanged and the client MUST report the mark-read as failed; the monotone frontier advance itself is still permitted, but a client MUST NOT report an explicit mark-read as successful while `override_active` remains true. + +**Natural read (frontier advance):** advance the frontier past B. No counter update is needed — the liveness predicate's `F <= B` condition automatically deactivates the override when the frontier dominates the baseline. + +#### Tombstone Floor + +A register where `S > 0` or `C > 0` (ever-active) that evaluates as inactive MUST be compacted to the tombstone floor before publication: + +``` +tombstone = RegB(S=0, C=max(S, C), B=0) +``` + +This preserves the counter ceiling as a reuse-blocking floor. A register where `S == 0` and `C == 0` (virgin, never activated) MUST be omitted from the wire entirely (0 keys). + +#### Mandatory Canonical Publication + +Publishers MUST canonicalize every override against their own effective frontier at serialization time before writing to the wire: + +- **Live override** (`override_active` is true): publish all three keys (`ov_s:`, `ov_c:`, `ov_b:`) with their current values unchanged. +- **Dead override** (`override_active` is false, `S > 0` or `C > 0`): publish only the tombstone floor — a single `ov_c:` key with value `max(S, C)`. +- **Virgin register** (`S == 0` and `C == 0`): omit all three keys from the wire. + +This is a protocol requirement, not an optimization. A client that publishes raw (non-canonical) dead registers can cause two independently-dead registers from different devices to produce a live join on merge. See `docs/formal/nip-rs-unread/` for the exhaustive proof and mutation harness. + +#### Override Group Co-Location Rule + +A context's frontier entry and ALL of its `ov_*` sibling entries MUST travel in the same event, and that event MUST be the primary coordinate. Because all `ov_*` entries live in the primary (see `d` Tag), an override-bearing context has exactly one legal destination for its whole group: a client that splits frontier entries into additional coordinates MUST NOT move the frontier entry of an override-bearing context out of the primary, and MUST NOT place `ov_*` entries anywhere else. Only frontier-only groups — contexts with no `ov_*` entries — may be distributed across additional coordinates. + +Implementations that split blobs across coordinates MUST group context entries per logical context — not per individual key — and assign the entire group atomically to one coordinate. Round-robin or other assignment strategies MUST operate on groups, not on individual entries. + +**Unescape-before-group rule (corollary):** when grouping, a frontier wire key MUST be unescaped to its raw logical context ID (stripping one leading `esc:` if present) before being used as the group identity. Without this step, a frontier key `esc:ov_s:evil` and its `ov_*` siblings (keyed by the raw suffix `ov_s:evil`) resolve to different groups and the register splits across coordinates, reproducing the partial-reconstruction poison across publication cycles. + +**Rationale:** a receiver holding only a partial group (e.g., `ov_s:ctx` without `ov_b:ctx`) reconstructs a register with incorrect baseline and may canonically publish a false tombstone. With atomic grouping, a compliant publisher's output never permits partial reconstruction. + +#### Tie Policy + +**Clients MUST use clear-wins.** When `S == C` and `S > 0`, the override MUST be treated as inactive, and the register MUST be compacted to the tombstone floor on publication (see Tombstone Floor). + +Clear-wins is normative rather than a local implementation choice because the tie verdict is not encoded on the wire. Two conforming clients holding the same merged register `(S, C, B, F) = (1, 1, 10, 10)` would otherwise disagree permanently: a clear-wins client reports read and publishes the single-key tombstone floor, a set-wins client reports unread and publishes all three keys. Further deliveries converge the counters but can never converge either the verdict or the canonical wire form, which defeats cross-device synchronization. Supporting a selectable tie policy would require encoding the policy in the blob plus a separate interoperability design; neither is in scope here. + +Clear-wins also matches the product semantics this layer is designed for: a false negative (a missed badge) is recoverable by re-marking unread, while a false positive (a badge that will not clear) is more disruptive. See `docs/formal/nip-rs-unread/NOTE.md` for the policy comparison — both policies satisfy the merge-correctness invariants in isolation, so this is an interoperability requirement, not a merge-safety one. + +#### Override State Durability + +`ov_*` override entries — especially tombstone floors (`ov_c:` keys) — carry a reuse-blocking counter ceiling that prevents stale override components from resurrecting a dead register. Specifically, if a tombstone floor `(S=0, C=k, B=0)` is dropped and a stale snapshot `(S=k, C=0, B=b)` is later replayed, the merged result `(S=k, C=0, B=b)` would evaluate as live — a resurrection. + +Because legacy clients can carry and republish old `ov_*` keys indefinitely (they pass through `sanitizeContexts` as unknown opaque entries), there is no finite time after which all stale override components are guaranteed absent. Therefore: + +**Clients MUST NOT drop `ov_*` override entries (including tombstone floors) based on age pruning or budget eviction.** This exemption applies permanently. Age-based pruning applies to frontier entries only. Eviction strategies that respect byte/key budgets MUST apply to frontier and `msg:`/`thread:` entries first and MUST NOT touch `ov_*` entries. + +**Durability is a property of retrievable logical state, not of keys within one blob.** An override register survives only if a client that loads its full state can still reach every component. Therefore, in addition to the per-entry rule above: + +- Full-state loads by clients implementing this layer MUST NOT be restricted by a finite event-level `since` window, and MUST establish completeness rather than assume it; the containing event must remain reachable, not merely retain its keys (see Full-State Load). +- No coordinate carrying `ov_*` entries may be deleted or abandoned until the componentwise `max()` of every override register it holds — especially every tombstone ceiling — has been republished under the client's current primary coordinate and accepted on every relay from which the old coordinate will be deleted or allowed to lapse (see Client-ID Rotation, Orphaned Blob Deletion). + +**There is no safe finite GC horizon for override state.** Any protocol that proposes to delete tombstone floors after a bounded period requires a separately proved guarantee that no stale override component can re-enter the merge — this amendment does not provide such a guarantee. + +#### Bounds and Budget + +- **Key growth:** a live override adds 3 entries per context; a tombstoned override adds 1 entry per context. At 100 overridden channel contexts: ~300 live entries or ~100 tombstone entries. +- **Byte cost (small-counter example, common case):** channel UUID context (36 chars), counters S=1/C=0/B=10 — live override ~138 bytes; tombstone ~45 bytes. **Byte cost at uint32 maximum** (S=4294967295, worst case): live override ~164 bytes; tombstone ~54 bytes. +- **Hard ceiling on ever-overridden contexts.** Because all `ov_*` entries live in one coordinate (see `d` Tag) and tombstones can never be pruned (see Override State Durability — there is no safe finite GC horizon), the primary blob's plaintext budget is a hard ceiling on the number of contexts a single installation can ever have manually marked unread. Against a 32 KiB plaintext budget: roughly **600** tombstoned contexts at the worst-case ~54 bytes, ~730 at the common ~45 bytes, or ~199 simultaneously live overrides at ~164 bytes — and that is before frontier entries get any room at all. +- **Terminal behaviour at the ceiling.** When the primary blob cannot accommodate a new override group after all prunable frontier entries have been evicted, the client MUST refuse the mark-unread action and report it as failed. It MUST NOT split override state across coordinates and MUST NOT drop tombstone floors to make room. Likewise, a client whose merged override state — including tombstones merged in from peer installations — no longer fits in its primary blob MUST leave its last-published primary in place, MUST NOT publish a primary that omits merged `ov_*` entries, and MUST report override actions as failed; publishing a truncated override set is budget-driven override loss under another name. No floor is lost in this state, because the installations that originated those floors still carry them; the constrained installation simply stops acting as a replica until it has room. This is the same policy shape as counter exhaustion (see Actions): visible failure, never silent degradation. +- **10,000-key limit:** override entries count toward the existing per-blob validation limit. Tombstones accumulate permanently with every distinct ever-overridden context; they cannot be pruned. Clients SHOULD compact dead overrides aggressively and MAY enforce an active-live-override cap. Note that a cap on live overrides does not bound the total `ov_*` entry count over an unbounded context lifetime — tombstones from all historical overrides remain. The 32 KiB and 10,000-entry limits are expressed per blob at write time; a client that has overridden many distinct contexts over its lifetime must account for all accumulated tombstones when evaluating budget headroom. +- **256-byte key limit:** override keys (`ov_s:`, `ov_c:`, `ov_b:` + context ID) count toward the per-entry 256-byte validation limit. Context IDs up to 251 bytes are safe. Buzz's own context ID shapes (UUID 36 bytes, `msg:hex64` 68 bytes, `thread:hex64` 71 bytes) are well within this limit. + +#### Verification Artifact + +The design was verified by bounded exhaustive model checking prior to this amendment. See `docs/formal/nip-rs-unread/` for the full model (`model.py`, `exhaustive.py`), 9-mutant harness (`mutation.py`), and design notes (`NOTE.md`). + +The harness verifies the three load-bearing safety requirements — tombstone floor, mandatory canonical publication, and atomic per-context grouping with unescape-before-group — are necessary: each mutant that drops one of these rules produces a detectable witness of permanent false-clear or resurrection. M3 validates that the clear-wins tie policy produces the intended product-semantics behavior; M5 and M6 witness value-range and convergence failures respectively. Clear-wins is normative for interoperability (see Tie Policy), not because set-wins violates merge safety — the model confirms both tie policies satisfy the merge-correctness invariants when applied uniformly. + +**Scope of formal verification:** the bounded model covers the CRDT register algebra, merge/compaction rules, per-context grouping atomicity, and escape/unescape bijection. The model is a broader predecessor of this NIP: its `split_blob_into_slots` permits override groups in any slot, whereas this NIP confines them to one primary coordinate, so the verified atomicity property holds for every arrangement this NIP permits but the converse does not follow. The model does **not** verify the single-primary rule, the full-state-load completeness procedure, the relay conformance requirements or the mutation fence it depends on, or the carry-forward rule; those are normative here and argued, not proved. The model also does NOT cover malformed-group wire validation (the accepted-shape rules in Content Validation). That rule is sound by the partial-group argument (rejecting a partial group leaves a virgin register — a merge no-op — which is strictly safer than zero-filling missing components), but its correctness under parser-level implementation is outside the model's verified scope. Implementation-level tests MUST cover the accepted wire shapes and rejection behavior. ## Example @@ -504,9 +730,9 @@ The conversation key is `nip44_conversation_key(private_key, public_key)` — EC ### Conflict Detection Vector -Device A has `slot-id` = `aaa111` and `client_id` = `client-A`. It fetches its own `d` tag coordinate `read-state:aaa111` and decrypts the blob. The decrypted `client_id` is `client-B` (not `client-A`). This is a slot-id conflict — another device has claimed this coordinate. +Device A has `slot-id` = `aaa111aaa111aaa111aaa111aaa111aa` and `client_id` = `client-A`. It fetches its own `d` tag coordinate `read-state:aaa111aaa111aaa111aaa111aaa111aa` and decrypts the blob. The decrypted `client_id` is `client-B` (not `client-A`). This is a slot-id conflict — another device has claimed this coordinate. -Device A MUST NOT publish to `read-state:aaa111`. Device A MUST generate a new random `slot-id` (e.g., `ccc333`) and publish its blob under `read-state:ccc333`. +Device A MUST NOT publish to `read-state:aaa111aaa111aaa111aaa111aaa111aa`. Device A MUST generate a new random `slot-id` (e.g., `ccc333ccc333ccc333ccc333ccc333cc`) and publish its blob under `read-state:ccc333ccc333ccc333ccc333ccc333cc`. ### Clock Skew Vector @@ -541,7 +767,7 @@ Ciphertext length reveals the approximate number of tracked contexts and may cor Because slot IDs are random and independent of `client_id` values, relay operators cannot directly link blobs to specific devices or client implementations. Timing correlation and write patterns may still allow probabilistic linkage. -Because the merge rule is monotonic, replaying an old event to a relay is harmless — it cannot lower a read timestamp. However, replaying many old events simultaneously could trigger convergence re-publishes from active clients. The debounce window (see Debounce and Pruning) limits this to at most one re-publish per window. +Because the frontier merge rule is monotonic, replaying an old frontier event to a relay is harmless — it cannot lower a read timestamp. The override layer's counter merge is also monotonic (componentwise max), so replaying an old override event cannot lower a counter; however, a stale override component replayed after a tombstone floor was published could suppress a fresh set for one reconciliation cycle (see Override State Durability). The debounce window (see Debounce and Pruning) limits convergence re-publishes to at most one per window. Clients supporting multiple Nostr identities SHOULD use distinct `client_id` values and distinct slot IDs per identity. Reusing identifiers across pubkeys allows relay operators to link those identities. @@ -557,10 +783,13 @@ that expose read activity to other users MUST require explicit user consent. ## Backwards Compatibility -This NIP introduces no changes to existing event kinds or relay behavior. It uses only standard NIP-01 event storage, NIP-33 addressable event semantics, NIP-44 encryption, and NIP-78 application data conventions. Clients that do not implement this NIP are unaffected. +This NIP introduces no changes to existing event kinds and adds no new kind, wire message, or relay-stored read-state logic. It uses only standard NIP-01 event storage, NIP-33 addressable event semantics, NIP-44 encryption, and NIP-78 application data conventions. Clients that do not implement this NIP are unaffected, as are clients that implement everything but the manual-unread override layer. + +The override layer is the exception, and it is a relay-compatibility one rather than a client one. Its full-state load carries the completeness guarantee only against a relay that satisfies the ordering, capacity, floor, push, and barrier requirements enumerated in Full-State Load. Against a relay known or evidenced not to conform, every load resolves to *cannot prove complete* and the actions that depend on a complete load report as failed; against an undetectably nonconforming relay, a load may still return *complete*, and the completeness guarantee does not apply to that verdict. In either case the layer still runs and still merges, and frontier sync is unaffected. ## References +- [NIP-01](01.md) — Basic Protocol Flow Description (defines filter `limit`, `since`, and `until`) - [NIP-09](09.md) — Event Deletion Request - [NIP-33](33.md) — Parameterized Replaceable Events - [NIP-44](44.md) — Versioned Encryption From bb34bc4d98fe4dabe847046103ac5e2859917ac5 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 11:38:42 -0600 Subject: [PATCH 99/99] Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) Reverts block/buzz#3944 --- .release/desktop-candidate.json | 8 ---- CHANGELOG.md | 63 ------------------------------- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 4 insertions(+), 75 deletions(-) delete mode 100644 .release/desktop-candidate.json diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json deleted file mode 100644 index 150f8023e7..0000000000 --- a/.release/desktop-candidate.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "schema": 1, - "version": "0.5.3", - "base_sha": "052174a148f9f6bcbb2b5a1d20ce0317645e49f8", - "previous_tag": "v0.5.2", - "tag": "desktop-v0.5.3", - "commit_count": 53 -} diff --git a/CHANGELOG.md b/CHANGELOG.md index 974f68c683..d83087fc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,68 +1,5 @@ # Changelog -## v0.5.3 - -### Desktop and shared changes - -- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1)) -- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663)) -- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc)) -- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67)) -- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59)) -- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501)) -- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51)) -- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) -- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39)) -- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b)) -- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140)) -- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b)) -- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef)) -- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0)) -- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3)) -- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) -- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832)) -- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966)) -- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad)) -- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76)) -- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) -- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb)) -- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a)) -- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898)) -- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c)) -- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859)) -- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4)) -- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c)) -- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) -- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f)) -- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19)) -- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53)) -- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c)) -- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10)) -- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed)) -- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b)) -- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8)) -- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d)) -- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) -- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) -- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d)) -- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7)) - -### Other repository changes - -- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8)) -- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089)) -- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) -- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) -- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a)) -- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a)) -- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170)) -- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0)) -- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61)) -- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5)) -- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) - -[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3) - ## v0.5.2 - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) diff --git a/desktop/package.json b/desktop/package.json index e8145f5468..2226a0cb12 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.3", + "version": "0.5.2", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 00d3fba3b5..254b7070ac 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.3" +version = "0.5.2" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b80684f955..39aaf0dead 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.5.3" +version = "0.5.2" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 1ff8bd20ef..2eba7815b2 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.3", + "version": "0.5.2", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": {