From 89bf03c05df795a3575b7abbe648be898ef13388 Mon Sep 17 00:00:00 2001 From: "mr-r0b0t.eth" Date: Sat, 1 Aug 2026 21:08:15 -0500 Subject: [PATCH 001/163] fix(nip-oa): accept raw Nostr tag form in parse_json_array (#4203) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What `BUZZ_AUTH_TAG` stored in the **raw Nostr tag form** `[auth,hex,,hex]` (unquoted, comma-delimited — how an `auth` tag serializes inside a Nostr event and how `.env` files commonly store it) was rejected by the CLI: ``` BUZZ_AUTH_TAG is malformed: invalid JSON: expected value at line 1 column 2 ``` …and even when the CLI *could* parse it, it forwarded the raw string as the `x-auth-tag` header, so the relay's `verify_auth_tag` (which expects JSON) rejected it with `403 relay_membership_required`. Two commits close both gaps. ## Commits ### 1. `fix(nip-oa): accept raw Nostr tag form in parse_json_array` `parse_json_array` (`crates/buzz-sdk/src/nip_oa.rs`) only accepted well-formed JSON arrays. Added a fallback: when strict JSON parsing fails *and* the trimmed input is bracket-delimited, split on `,` and treat each field as a string (empty field `,,` → empty string, matching `["auth","hex","","hex"]`). All consumers (`parse_auth_tag`, `verify_auth_tag`, the CLI, `buzz-acp`) benefit from one change at the lowest layer. ### 2. `fix(cli): canonicalize BUZZ_AUTH_TAG to JSON before sending x-auth-tag header` The CLI stored the raw input string and sent it verbatim as the `x-auth-tag` header (`client.rs:618`). Added `canonicalize_auth_tag` in `buzz-sdk`: parse either form, re-serialize to canonical JSON. The CLI now canonicalizes before storing as `auth_tag_json`, so the header is always valid JSON regardless of input form. Together: local parse + wire canonicalization means the raw form works end-to-end. ## Why The raw form `[auth,hex,,hex]` is exactly how an `auth` tag serializes inside a Nostr event. That shape leaks into `.env` files and shell variables because there's no canonical "stored form" outside an event. The SDK + CLI should accept it rather than push quoting/conversion logic onto every consumer (harnesses, agent shells, external tools). ## Security Both changes are purely syntactic — they only change how a 4-element string array is extracted and containerized. All downstream validation is unchanged: - `parse_auth_tag`: still checks exactly 4 elements, `"auth"` label, 64-char lowercase-hex pubkey, 128-char signature. - `verify_auth_tag`: still reconstructs the preimage and verifies the BIP-340 Schnorr signature against the owner pubkey. No new attack surface — a malformed or forged tag is still rejected at the same validation points. ## Tests 4 new tests in `nip_oa::tests`: - `test_parse_auth_tag_raw_nostr_form` — raw form with conditions + empty conditions - `test_parse_auth_tag_raw_form_with_whitespace` — raw form with surrounding whitespace - `test_canonicalize_auth_tag_raw_to_json` — raw→JSON and JSON→JSON normalization All 25 `nip_oa` tests pass (21 existing + 4 new). `cargo fmt --check` and `cargo clippy -p buzz-sdk -p buzz-cli` clean. ## Verification Confirmed end-to-end against a live community relay (`wss://hermesagent.communities.buzz.xyz`): - **Before:** raw `BUZZ_AUTH_TAG` → CLI parse error, or `403 relay_membership_required` if somehow parsed. - **After:** raw `BUZZ_AUTH_TAG` → CLI parses it, canonicalizes to JSON for the header, relay accepts via NIP-OA owner delegation, `buzz channels members` returns the full roster. ## Context Originated from a community investigation where agent-side relay access was failing because the harness-exported `BUZZ_AUTH_TAG` (raw Nostr form) was rejected by the CLI (expecting JSON). This removes the impedance mismatch at the source. --------- Signed-off-by: amanning3390 Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> --- crates/buzz-cli/src/lib.rs | 99 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 4 deletions(-) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0726406d299..0860f9dae6c 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -1768,6 +1768,41 @@ pub enum ModerationCmd { }, } +/// Normalize hand-authored `BUZZ_AUTH_TAG` input to strict JSON. +/// +/// `.env` files and shell exports sometimes carry the tag in the unquoted +/// shorthand `[auth,,,]` (quotes dropped by hand). +/// When the input is not valid JSON but is bracket-delimited, rewrite it as +/// a JSON array of the comma-separated fields (an empty field `,,` becomes +/// `""`, matching the canonical form `["auth","hex","","hex"]`). +/// +/// This is presentation-layer leniency at the configuration edge only: the +/// output is always fed through the SDK's strict `parse_auth_tag` / +/// `verify_auth_tag`, which enforce structure, hex, the conditions grammar, +/// and the BIP-340 signature. Inputs that are already valid JSON — or not +/// recognizable as the shorthand — are returned unchanged so the strict +/// parser reports the error on the original bytes. +fn normalize_auth_tag_input(input: &str) -> String { + let trimmed = input.trim(); + if serde_json::from_str::(trimmed).is_ok() { + return trimmed.to_owned(); + } + if trimmed.starts_with('[') && trimmed.ends_with(']') { + let fields: Vec<&str> = trimmed[1..trimmed.len() - 1] + .split(',') + .map(str::trim) + .collect(); + // Only a plausible 4-field auth tag is rewritten; anything else is + // passed through untouched for the strict parser to reject with an + // error that references the caller's original input. + if fields.len() == 4 && !fields.iter().any(|f| f.contains('"')) { + // serde_json cannot fail serializing a Vec<&str>. + return serde_json::to_string(&fields).expect("string array serializes"); + } + } + trimmed.to_owned() +} + async fn run(cli: Cli) -> Result<(), CliError> { let relay_url = client::normalize_relay_url(&cli.relay); @@ -1788,17 +1823,28 @@ async fn run(cli: Cli) -> Result<(), CliError> { .map_err(|e| CliError::Key(format!("invalid BUZZ_PRIVATE_KEY: {e}")))?; // NIP-OA: parse and verify the auth tag if provided. + // + // `BUZZ_AUTH_TAG` is hand-authored configuration, so the unquoted raw + // shorthand `[auth,hex,,hex]` is normalized to JSON here — at this input + // edge only. The SDK grammar and the `x-auth-tag` wire format stay strict + // JSON; all validation and signature verification happen on the strict + // path below, unchanged. let (auth_tag, auth_tag_json) = match cli.auth_tag { - Some(ref json) if !json.is_empty() => { - let tag = buzz_sdk::nip_oa::parse_auth_tag(json) + Some(ref input) if !input.is_empty() => { + let json = normalize_auth_tag_input(input); + let tag = buzz_sdk::nip_oa::parse_auth_tag(&json) .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG is malformed: {e}")))?; - buzz_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| { + buzz_sdk::nip_oa::verify_auth_tag(&json, &keys.public_key()).map_err(|e| { CliError::Auth(format!( "BUZZ_AUTH_TAG verification failed for pubkey {}: {e}", keys.public_key().to_hex() )) })?; - (Some(tag), Some(json.clone())) + // Canonical wire form derives from the parsed-and-verified tag + // (same shape as buzz-acp's RestClient), never from raw input. + let canonical = serde_json::to_string(tag.as_slice()) + .map_err(|e| CliError::Auth(format!("BUZZ_AUTH_TAG serialization failed: {e}")))?; + (Some(tag), Some(canonical)) } _ => (None, None), }; @@ -1835,6 +1881,51 @@ mod tests { use super::*; use clap::CommandFactory; + /// Raw shorthand `[auth,hex,,hex]` normalizes to strict JSON; the empty + /// conditions field becomes `""`. + #[test] + fn normalize_auth_tag_raw_shorthand() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + + let raw = format!("[auth,{owner},,{sig}]"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "", &sig]); + + // With conditions and surrounding whitespace (shell/.env artifacts). + let raw = format!(" [auth, {owner} , kind=9, {sig}] \n"); + let json = normalize_auth_tag_input(&raw); + let parsed: Vec = serde_json::from_str(&json).expect("output must be JSON"); + assert_eq!(parsed, vec!["auth", &owner, "kind=9", &sig]); + } + + /// Valid JSON input passes through byte-identical (modulo outer trim) — + /// the normalizer must never rewrite well-formed input. + #[test] + fn normalize_auth_tag_json_passthrough() { + let owner = "a".repeat(64); + let sig = "b".repeat(128); + let json_in = serde_json::json!(["auth", owner, "kind=9", sig]).to_string(); + assert_eq!(normalize_auth_tag_input(&json_in), json_in); + } + + /// Inputs that are neither JSON nor a plausible 4-field shorthand pass + /// through unchanged, so the strict parser rejects the original bytes. + #[test] + fn normalize_auth_tag_leaves_garbage_untouched() { + for garbage in [ + "not a tag", + "[auth,too,few]", + "[a,b,c,d,e]", + r#"[auth,"quoted",x,y]"#, // quote chars => not the shorthand + "[]", + "{\"auth\":1}", + ] { + assert_eq!(normalize_auth_tag_input(garbage), garbage.trim()); + } + } + /// Smoke test: CLI definition is valid and parseable. #[test] fn cli_definition_is_valid() { From 28ae6cd2174309529305724e455c7ca082f6fe4b Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Sat, 1 Aug 2026 22:51:53 -0400 Subject: [PATCH 002/163] docs: formal spec for remote agents and their management (#3748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What A formal specification for remote agents and their management — `docs/remote-agents.md` — in the style of `docs/git-on-object-storage.md`: stated system model, named invariants, explicit trust boundaries, provider conformance checklist, and an implementation-correspondence table. Requested by Tyler in the buzz-remote-agents design thread; co-designed with Dawn and Wren (review pending). ## Structure - **System model** — five principals (Desktop / Provider / Substrate / Agent / Relay) and the design axiom **M1: no management channel** — everything the desktop knows about a live remote agent flows through the relay. - **Five invariants** with enforcement mechanism and stated boundary: - I1 identity fail-closed, I2 no secrets in configuration, I3 presence-is-status, I4 at-most-one-live-instance, I5 bounded lifetime. - **Provider protocol** — discovery, `info`/`deploy` wire contract, untrusted-output rules, the reserved-key rule, and the **deploy state machine** (Running → no-op). - **Auto-stop** — `--exit-after-inactivity` / `BUZZ_ACP_EXIT_AFTER_INACTIVITY`, default off, definition of "inactive", and why it must not share a name with the three existing timeout concepts. - **The Kubernetes binding** — `buzz-backend-kubernetes`: kubeconfig-only auth, random-default namespace via schema `default`, the sprig image, pod shape (bare Pod, `terminationGracePeriodSeconds: 60`, 32-hex label / full-pubkey annotation), secrets, GC, config budget. - **Known defects** at `c1bca1b56` (Windows `.exe` id pollution; provider env inheritance vs kubeconfig exec plugins). - **Open decisions A–E** marked inline and consolidated, awaiting owner ruling. ## Notes for review Docs-only. Every code claim was verified against the tree (correspondence table maps each spec concept to its file/function). The spec deliberately documents two desktop bugs as Known Defects rather than fixing them here — fixes are follow-up PRs. --------- Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- docs/remote-agents.md | 1745 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1745 insertions(+) create mode 100644 docs/remote-agents.md diff --git a/docs/remote-agents.md b/docs/remote-agents.md new file mode 100644 index 00000000000..4664de21e36 --- /dev/null +++ b/docs/remote-agents.md @@ -0,0 +1,1745 @@ +# Remote Agents and Their Management: A Formal Specification + +`draft` + +## Abstract + +This document specifies the protocol by which Buzz Desktop delegates the +execution of a managed agent to a **remote substrate** — any compute +environment other than the local machine — through a **backend provider +binary**, and specifies the lifecycle contract every provider and every +remotely-run agent must satisfy. It covers three layers: + +1. **The provider protocol** — a zero-registration plugin contract between the + desktop and any executable named `buzz-backend-`: discovery, the `info` + and `deploy` operations, payload schema, and the security obligations on + both sides of that boundary. +2. **The remote lifecycle model** — how a remote agent is started, observed, + stopped, and reaped, given the deliberate design constraint that **the + desktop holds no management channel to the remote process**. Relay + presence is the sole status signal; shutdown is a relay message; liveness + bounds are enforced by the agent harness itself, not by the desktop. +3. **The Kubernetes binding** — the first conforming provider, + `buzz-backend-kubernetes`, which realizes the contract as a bare Pod + running the `sprig` image. + +We state five invariants — **identity fail-closed**, **no secrets in +configuration**, **presence-is-status**, **at-most-one-live-instance**, and +**intentional-termination-is-final** — and argue each from the protocol +rules. + +A scoping note that governs the whole document: the desktop is **one +launcher among many**. What makes a process a live Buzz agent is a keypair, +a NIP-OA auth tag, and a relay URL, handed as environment to the `buzz-acp` +harness; anything that can set that environment and exec the harness — a +bash script, a systemd unit, a CI job, or this document's provider protocol +— is a conforming launcher. §Launchers states which obligations bind whom. + +As with the git +specification (`git-on-object-storage.md`), naming the trust boundary is part +of the claim: a provider binary is arbitrary code that is handed an agent's +private key, and this document states exactly which properties hold *despite* +that, which hold only if the provider is honest, and which are explicitly the +user's acceptance. + +## Scope and Non-Goals + +This specification defines **management-plane behavior**: how agents get to a +substrate, how their state is observed, and how their lifetime is bounded. It +deliberately does **not** specify: + +- **Agent conversational behavior.** What the agent does with events is + governed by the ACP harness (`buzz-acp`) and the NIPs it implements + (NIP-OA, NIP-AE, NIP-AA, …), unchanged by where the harness runs. +- **Malicious-provider containment.** A provider binary receives the agent's + `nsec` by design — that is its job. The protocol *bounds the desktop's + exposure* (discovery-only resolution, output caps, secret redaction, + anti-secret config validation, an explicit UI trust warning) but cannot make + a hostile provider safe. Choosing to run a provider is a trust decision the + UI surfaces to the user; this document does not claim otherwise. +- **Substrate security.** Kubernetes RBAC, namespace isolation, and secret + encryption at rest are cluster-operator concerns. The Kubernetes binding + states its residual exposure (§K8s Secrets) rather than claiming isolation + it does not provide. +- **Liveness of the substrate.** That a pod schedules, that an image pulls, + that a cluster is reachable — empirical, not formal. The protocol specifies + only how such failures are *reported* (structured error, redacted, + fail-closed). + +## System Model + +Five principals: + +- **Desktop** `D` — the Buzz Desktop app. Holds the agent's identity (nsec in + the OS keyring), its configuration record, and the only UI. Trusted. +- **Provider** `P` — an executable `buzz-backend-` on `D`'s machine. + Invoked one process per operation: JSON request on stdin, JSON response on + stdout, exit code carrying one bit (zero = output trustworthy, nonzero = + failure regardless of stdout — §Invocation). **Untrusted by `D`** for everything except + the job it is explicitly given (deploying the agent, which requires the + key). All of `P`'s output is treated as hostile (§Provider Output). +- **Substrate** `S` — the remote compute environment `P` deploys into (a + Kubernetes cluster for the binding in this document). Opaque to `D`; + `D` never talks to `S`. +- **Agent** `A` — a `buzz-acp` harness process (plus the ACP agent under it) + running on `S`, holding the nsec it was given, connected to the relay. +- **Relay** `R` — the Buzz relay. The *only* channel that connects `D` to a + running `A`. Everything `D` knows about a live remote agent, it learns + from `R`. + +The defining constraint, stated as a design axiom: + +- **(M1) No management channel.** After a successful `deploy`, `D` holds no + **persistent management session** to `A` on `S`, and the desktop↔provider + protocol contains **no substrate API**: no status query, no exec, no log + fetch, no kill. All post-deploy observation and control flows through `R`: + status is relay presence (kind:20001), stop is a relay message + (`!shutdown`), and reconfiguration is a future re-deploy. The reduction M1 + buys is **protocol surface, not credential absence**: ambient substrate + credentials may well exist on `D`'s machine (the Kubernetes binding uses + the user's kubeconfig by design), and `D` can always re-invoke `P`. What + M1 guarantees is that nothing in *this protocol* — its persisted records, + its wire operations, its stored `backend_agent_id` — constitutes or + requires a channel to the substrate. The price is the staleness bounds in + §Presence. + +An agent's identity is a Nostr keypair. The **agent record** on `D` carries: +`name`, `relay_url`, the nsec (keyring-hydrated), the NIP-OA `auth` tag +attesting owner authorization, `agent_command`/`agent_args` (the ACP agent the +harness spawns — `goose`, `claude-agent-acp`, `codex-acp`, `buzz-agent`, or +any user-supplied command: this is the **configurable harness** requirement), +effective `system_prompt`/`model`/`provider`, timeout and parallelism knobs, +the `respond_to` gate, merged `env_vars`, and a `backend` discriminator: +`Local` or `Provider { id, config }`. + +### Launchers {#launchers} + +The five principals above describe the **provider-managed** launch path. +That path is not the definition of a remote agent, and this section states +the actual layering, because the obligations in this document do not all +bind at the same layer. Three contracts, nested: + +1. **The agent/harness contract — binds every launcher.** A live Buzz agent + is a `buzz-acp` process holding a keypair, a NIP-OA auth tag (or resolved + owner pubkey), and a relay URL, delivered as environment. The relay + authenticates the keypair and the auth tag — never the launcher. At this + layer live: fail-closed identity (I1's property, enforced wherever the + env is assembled), presence publication (I3), owner-verified `!shutdown`, + and **intentional clean exit is terminal to automatic supervisor + restart** (I5). A bash script that exports `BUZZ_PRIVATE_KEY`, + `BUZZ_RELAY_URL`, `BUZZ_AUTH_TAG` and execs the harness is a conforming + launcher at this layer — today, with no code change. +2. **The provider/deployer contract — binds provider-managed launches + only.** The two operations (`info`/`deploy`), the reconciliation loop, + and at-most-one-live-instance *per deploy scope* (I4). Hand-launched + agents sit outside it by construction: a launcher that bypasses the + provider protocol takes on the uniqueness discipline itself, exactly as + the cross-scope boundary in I4 already states. The protocol cannot and + does not promise a global singleton across unrelated launchers of the + same nsec. +3. **The binding policy — per substrate.** Fingerprints, fenced deletes and + 409 discrimination, restart-policy selection, the default idle bound, + and the grace budget are Kubernetes-binding policy (§The Kubernetes + Binding). A different substrate (the systemd/SSH deployer of PR #3449 is + the live example) conforms to layers 1–2 and writes its own layer 3; it + is not "non-conforming" for lacking pods. + +The desktop is therefore one launcher among many, and the provider protocol +is the *desktop's* door to substrates, not the only door. §Conformance +carries one checklist per layer. + +## Invariants + +The protocol maintains five invariants. Each is stated with the mechanism +that enforces it and the boundary beyond which it does not hold. + +A design obligation governs the whole list: **the complexity budget is +spent in this document, not in the code**. Every guarantee here was chosen +because its enforcing mechanism is one small, boring thing — a refusal at +payload construction (I1), a key-shape validator (I2), an ephemeral event +the agent already publishes (I3), a deterministic name plus one annotation +compare (I4), a timer that fires an existing shutdown channel (I5). The +same rule holds below: the deploy state machine is one loop over seven +ordered rows; the Secret scheme is "unique name, write first, reference +exactly"; GC is one label-select with two filters (annotation, same-clock +age). Where +a richer property would have demanded machinery — Leases, controllers, +ownerReferences, a management channel — the spec either found a +name-and-timestamp argument that makes the machinery unnecessary or +dropped the property and said so (§Non-Goals, M1). A conforming +implementation that is not small is evidence of a spec bug; report it as +one. + +- **(I1) Identity fail-closed.** No agent is ever launched with an empty or + missing private key: whatever assembles the harness environment — desktop, + provider, bash script — MUST refuse rather than launch identityless + (§Launchers, layer 1). In the provider path this is enforced at payload + construction: if keyring hydration left the nsec empty, + `build_deploy_payload` refuses (mirroring local spawn's + `spawn_key_refusal`), so no deploy request is ever emitted with an empty + key. Boundary: a provider that *discards* the key and launches an + identityless pod is a broken provider; the payload rule governs what `D` + sends, not what `P` does with it — which is why the property also binds + at `P`'s env assembly and at every non-provider launcher. + +- **(I2) No secrets in configuration.** `provider_config` — the persisted, + schema-rendered, UI-visible settings object — MUST NOT carry secrets. + Enforced by validation: flat object, scalar values only, ≤20 fields, ≤64KB, + and any key whose word-split contains `secret|password|token|key|credential` + is rejected. The match is against key *names*, so it is a lint with false + positives: a field like `ssh_key_path` holds a path, not a credential, + and is refused anyway — a provider author hitting this MUST rename the + field (e.g. `identity_file`), not weaken the validator; the rule's job is + making "put the secret in config" fail closed, and cheap false positives + are the accepted price. Secrets flow exclusively inside the `deploy` payload + (`private_key_nsec`, `auth_tag`, `env_vars`), which is never persisted by + `D` and never rendered. Corollary for providers: cluster credentials MUST + come from ambient substrate config (e.g. kubeconfig resolution), never from + `provider_config`. + +- **(I3) Presence is the status.** `D` derives a remote agent's live state + exclusively from relay presence events self-signed by the agent key: + `online`/`away`/`offline` (kind:20001, ephemeral, WS-published). The + deployment axis (`deployed`/`not_deployed`, from the stored + `backend_agent_id`) is bookkeeping, not liveness. Staleness bound: presence + can be wrong for the window between an abnormal agent death (SIGKILL, node + loss) and the relay's presence expiry — **90 seconds** + (`PRESENCE_TTL_SECS`, `buzz-pubsub/src/presence.rs:16`; the vision's + "ninety seconds of a wrong dot, never an indefinite one"), the accepted + cost of M1. + The Kubernetes binding minimizes the *avoidable* part of that window by + sizing the termination grace period to the harness's full graceful-shutdown + path (§K8s Grace). Two consequences the bound imposes: (a) the harness's + presence-suppression knob, `BUZZ_ACP_NO_PRESENCE`, MUST join + `RESERVED_ENV_KEYS` — locally the knob is cosmetic (the process and UI + remain visible), but remotely M1 makes presence the *only* signal, so an + unreserved user env var would convert "wrong for ≤90s" into "wrong + indefinitely" and silently disarm the one bound in print; (b) presence is + scoped to a **community**: the relay derives community from its host, so + the deploy-time `relay_url` binds the body to one community for its whole + life, and a workspace observing through a different community sees the + agent offline while a deploy against it correctly no-ops — a known UX + boundary (the cross-scope boundary I4 admits, seen from the status side), + stated here so the two honest-but-conflicting readouts are diagnosable. + +- **(I4) At most one live instance per agent key per deployment scope.** + Within one provider's deployment scope (for Kubernetes: one namespace), + there is never more than one Running instance of a given agent pubkey. + Enforced by the deploy reconciliation loop (§Deploy State Machine): + deploy is keyed on the derived pubkey, a live instance maps to strict + no-op, and — because two deploys can race — create/delete conflicts MUST + converge (re-read and return the winner) rather than fail; deterministic + instance naming makes the substrate itself reject a second live instance. + Boundary: the protocol cannot prevent the same nsec being + deployed to two different scopes (two namespaces, two clusters, or remote + + local simultaneously) — the relay tolerates multiple connections per key, + and preventing this would require the global registry M1 forbids. Deploying + one key twice is user error with confusing-but-safe results (both instances + answer), not a safety violation. + +- **(I5) Intentional termination is final.** A remote agent **stops when + told, stays down when it stops, and is never silently resurrected**: an + instance whose harness is live terminates on owner `!shutdown` or when a + configured inactivity bound expires, and no supervisor restarts an + instance that exited *intentionally*. "Final" means **terminal to + automatic supervisor restart** — the owner may always issue a fresh + Start; that is resurrection working as designed, not a violation. + + **Lifetime is owner policy, not law.** The inactivity bound is the + harness's opt-in self-stop (§Auto-Stop, default disabled). An owner may + always choose **no inactivity bound** — declaring an indefinitely-lived + agent. How that choice is expressed is per-binding: the Kubernetes + binding opts in with a 2h schema default because a pod is metered + compute with nobody watching it, and spells "no bound" as its + `inactivity_seconds: 0` field (§Pod shape); a hand launcher simply never + sets the reaper env. Either way it is + a legitimate, explicit choice, not a conformance failure: the invariant + was never "every instance terminates" (a continuously active agent is + intentionally unbounded — that is the product); it is "termination, once + intended, sticks". + + **Restart policy follows lifetime policy.** The distinction that makes + indefinite agents safe is *intent vs accident*: dying on purpose + (`!shutdown`, inactivity reap) is final; dying by accident (node + eviction, OOM) may restart the body — same key, same agent, the + resurrection story working *for* the owner. Stated launcher-neutrally: + **if a supervisor exists, its restart policy MAY revive an abnormal + death and MUST NOT revive an intentional clean exit.** A launcher with + no supervisor at all — a hand-launched process on a VPS — satisfies + this vacuously: nothing restarts anything. How bounded vs indefinite + lifetime maps onto a concrete supervisor policy is binding policy + ([L3]), realized and documented by each binding — this binding's + mapping lives in §Pod shape; a systemd binding's in its unit + directives. Any revive-on-abnormal-death policy carries a universal + precondition: the supervisor can distinguish intent from accident only + if the harness formally promises *clean exit = exit code 0* on every + intentional path and nonzero otherwise, pinned by test. At `c1bca1b56` + that property is emergent, not defended (Known Defect 6); + restart-on-failure before the pinned contract is how a refactor + silently converts every clean stop into a restart loop with no failing + test. Ordering is normative: exit-code contract first, + restart-on-failure second — the identical seam in every supervisor that + offers the distinction. An always-restart policy remains non-conforming + at any layer: it resurrects after a *clean* exit, defeating + `!shutdown`. + + Enforcement: the self-stop lives *inside the harness* (the only place + that can see activity, per M1) (§Auto-Stop), and each binding makes it + effective on its substrate by requiring that harness exit terminates + the substrate's unit of execution (this binding's realization — the + harness as the container's signal-receiving process — is §Pod shape, + [L3]) and that any supervisor's restart policy respects intent as + above. + Boundaries: (a) the guarantee is conditional on a live harness event + loop — a wedged process that cannot run its reaper timer cannot reap + itself, and M1 means nothing else will (the mitigation is the substrate + operator's, e.g. a namespace-level TTL policy, out of scope per + §Non-Goals); (b) restart policy prevents resurrection, it does not prove + process exit; (c) I5 bounds *agent* lifetime, not substrate residue — + residue (in this binding, a Completed pod object) persists for + forensics until the next deploy's GC (§K8s GC). + +## Provider Protocol + +### Discovery + +`D` scans, in order: the directory containing the desktop executable, every +entry of `PATH`, and `~/.local/bin`, for executables named +`buzz-backend-`. The suffix after the prefix is the provider id and MUST +match `[a-z0-9][a-z0-9_-]*`. On Windows, an `.exe`/`.bat`/`.cmd` extension +MUST be stripped before the id is derived (see §Known Defects — as of +`c1bca1b56` it is not, so Windows providers probe but cannot deploy). First +hit per filename wins. Discovery executes nothing. + +**Shadowing and invalid candidates are diagnosable, not silent.** First-hit +wins is the right selection rule (it is kubectl's), but kubectl also warns +when a later-PATH plugin is shadowed, and Docker's CLI reports invalid +plugin candidates with reasons. Discovery MUST retain, and the UI and +deploy-time errors MUST be able to surface: the selected binary's full +path, any shadowed candidates for the same id (later-PATH duplicates), and +candidates rejected for malformed names. A deploy error that names which +binary ran answers the first question a user with two copies of +`buzz-backend-kubernetes` will ask. (At `c1bca1b56` discovery records only +the winning path — a desktop change alongside Known Defect 3's.) + +**Resolution rule.** Every subsequent operation resolves the provider id +against the *current* discovery set. A stored binary path on an agent record +is a cache, revalidated against both the current candidates and the recorded +id before every use. A record edit can therefore never redirect an operation +to a binary discovery would not have found. + +**Pre-secret negotiation gate (normative).** Declaring `protocol_version` +is worthless if nothing checks it before the nsec crosses the trust +boundary — and at `c1bca1b56` nothing does: `provider_deploy` invokes +`deploy` directly, so a stale UI-time probe (or a binary replaced on PATH +since that probe) can receive `private_key_nsec` unchecked (Known Defect +5). The deploy path MUST: resolve the provider id **once**; copy the +resolved candidate into a desktop-owned, private, non-writable **staging +file**, computing its digest during the copy; invoke `info` **on the +staged artifact**; validate an explicit, supported `protocol_version` +(§Info — absence is an error); invoke `deploy` on the **same staged +artifact**; delete it afterward. Staged bytes are what "same executable +identity" means here: the nsec goes to the exact bytes that answered +`info`. Path-plus-metadata comparison (dev/inode, size, mtime) is NOT an +acceptable substitute for this guarantee — unchanged metadata can miss an +in-place content rewrite, and a pathname can be swapped between the check +and the moment `Command` opens it, which is precisely the +check-then-exec race the gate exists to close. A UI-time probe result +MUST NOT satisfy this gate. If a platform makes staged execution +impossible for some provider (e.g. an executable that only runs from its +install location due to relative dependencies or signing constraints), +the implementation MUST NOT silently fall back to metadata and still +claim this gate: it degrades explicitly to *accidental-replacement +detection* (path + file-identity compare), surfaces that weaker level in +the deploy diagnostics, and the spec text for that platform carries the +narrower claim. Remembered digest-based approval (Terraform-lock style) +is a stronger follow-up, not a v1 requirement. + +### Invocation + +One process per operation. `D` spawns `P` with cwd = the agent workdir, +writes exactly one JSON object to stdin, closes stdin. `P` writes exactly one +JSON object to stdout and exits. Requirements on `D` (all implemented): + +- Bounded reads: stdout capped (1MB), stderr capped (64KB), no `read_to_end` + on pipes a daemonizing child could hold open; deadline polling with + `try_wait`. +- **Non-zero exit is failure even if stdout parsed.** Partial output from a + crashed operation is never trusted. +- `{"ok": false, "error": …}` is the in-band failure form. +- **Environment**: `P` inherits `D`'s environment. On macOS a GUI launch + means launchd's minimal PATH; providers whose substrate credentials invoke + helper binaries (kubeconfig `exec` plugins) MUST self-augment their PATH + (§K8s Auth) rather than assume a login shell. + +### Provider Output Is Untrusted + +Everything `P` emits — stderr, error strings, the response object — is +scrubbed before storage or display: every value from the request's +`env_vars` (longest-first, length ≥4) and every `nsec1…`/`sprt_tok_…` token +is redacted. Rationale: `P` legitimately holds secrets during deploy; `P` +echoing them (in a stack trace, a kubectl error, a debug line) must not +propagate them into `D`'s persisted `last_error` or logs. + +### `info` + +``` +request: {"op": "info", "request_id": ""} +response: {"ok": true, "name": str, "version": str, + "protocol_version": int, "description": str, + "config_schema": } +timeout: 10s +``` + +`version` is the provider's *software* version — useful in error reports, +useless for compatibility. `protocol_version` (this document: `1`) is the +wire-contract version, following the pattern Docker's CLI plugins +(`SchemaVersion`) and HashiCorp go-plugin (negotiated protocol version) both +converged on: the desktop rejects a provider whose `protocol_version` it +does not speak, with an error naming both versions and the binary path, +instead of failing later inside a half-understood `deploy`. A missing +`protocol_version` is an **error, not a presumed `1`**: there is no +deployed provider population to grandfather, and a gate that infers +compatibility for exactly the class of binary that never declared any +defeats its own pre-secret guarantee (§Discovery). Fail closed — it is +also simpler: no migration clock, no "major cycle" to define. + +`config_schema` drives the UI form: `properties[*].default` prefill, +string/number/boolean coercion, `required` gating. A provider MAY compute +defaults freshly per call (the Kubernetes binding generates a random +namespace default this way — §K8s Namespace). The schema's fields are +subject to I2 validation when the user's values come back in `deploy`. + +### `deploy` + +``` +request: {"op": "deploy", "request_id": "", + "agent": , "provider_config": {…}} +response: {"ok": true, "agent_id": str} +timeout: 600s +``` + +The agent payload (field list per +`commands/agents_deploy.rs: deploy_payload_json` at `c1bca1b56`; the +`launch` block is a normative addition not yet emitted — Known Defect 3): + +| field | meaning | +|---|---| +| `name` | display name | +| `relay_url` | concrete WS URL (workspace fallback materialized — the remote side has no workspace notion) | +| `private_key_nsec` | **the identity** (I1: never empty) | +| `auth_tag` | NIP-OA owner attestation | +| `agent_command`, `agent_args` | the ACP agent under the harness (configurable-harness support). At `c1bca1b56` these are raw record bytes — see Known Defect 3: the normative source is the resolved descriptor in `launch` | +| `system_prompt`, `model`, `provider` | effective values, live-persona-first resolution | +| `turn_timeout_seconds`, `idle_timeout_seconds`, `max_turn_duration_seconds` | harness timeout knobs | +| `parallelism` | concurrent-turn bound | +| `respond_to`, `respond_to_allowlist` | inbound author gate | +| `env_vars` | merged user env: global < persona < agent | +| `launch` | **normative addition** (§Launch data): the desktop-resolved launch contract — `command` (name, not path), normalized `args`, layered `env`, overridable `policy_env`, and `owner_pubkey` | + +**Reserved-key rule (normative for providers).** `D` strips +`BUZZ_PRIVATE_KEY`, `NOSTR_PRIVATE_KEY`, `BUZZ_AUTH_TAG`, `BUZZ_RELAY_URL`, +and the other reserved keys from `env_vars` before merge. A provider MUST +construct the agent environment's identity variables from the **top-level** +payload fields (`private_key_nsec` → `BUZZ_PRIVATE_KEY`/`NOSTR_PRIVATE_KEY`, +`auth_tag` → `BUZZ_AUTH_TAG`, `relay_url` → `BUZZ_RELAY_URL`); reading +`env_vars` for them yields an identityless agent. A related hardening `D` +performs is part of the contract's rationale: env keys are validated as +POSIX-shaped names before merge, because a key like `BUZZ_AUTH_TAG=x` +smuggled through `Command::env` would bypass the reserved-key strip +entirely. A provider materializing `env_vars` into a substrate object +(e.g. a Kubernetes Secret) MUST likewise never let a user-supplied key +collide with or reconstruct a reserved key. + +`agent_id` is `P`'s stable handle for the deployment (the Kubernetes binding +returns the pod name). `D` stores it as `backend_agent_id`; its presence is +the `deployed` axis of I3. + +**There is no `undeploy` op in v1.** Deletion of a remote agent from `D` +orphans the substrate objects; the UI therefore requires an explicit +`force_remote_delete` confirmation, and the binding's GC + I5 bound the +orphan's cost (the agent self-stops; the pod residue is reaped on the next +deploy of the same key, or manually). + +### Launch data (`launch`) {#launch-data} + +Reproducing the local spawn's launch semantics requires state only the +desktop can resolve: the runtime-metadata table (`model_env_var`, +`provider_env_var`, `provider_locked`, `default_env` — +`discovery.rs:74-193`), the six-layer env resolution, harness-definition +command/args fallback, team instructions, session title, the respond-to +gate's legacy owner fallback, and the mesh rewrite. A provider MUST NOT +reimplement that derivation — it would be a second copy of desktop runtime +discovery, drifting from the first. Instead the payload carries a typed +`launch` block that `D` resolves with **the same code paths as local +spawn**, and the provider applies it mechanically. + +``` +"launch": { + "command": str, // command NAME (e.g. "goose"), never a host path + "args": [str], // normalized args, definition fallback applied + "env": {str: str}, // layered env: baked → runtime metadata → + // definition → global → persona → agent + // (resolve_effective_harness_descriptor) + "policy_env": {str: str}, // overridable behavior defaults (tier 1, below): + // runtime default_env (e.g. GOOSE_MODE=auto), + // BUZZ_ACP_RELAY_OBSERVER, BUZZ_ACP_LAZY_POOL=true, + // BUZZ_ACP_SESSION_TITLE (resolved), + // BUZZ_ACP_TEAM_INSTRUCTIONS, BUZZ_ACP_MODEL, + // MCP_HOOK_SERVERS=* (mcp_hooks runtimes only) + "owner_pubkey": str | null // resolved workspace owner (hex) — legacy + // BUZZ_ACP_AGENT_OWNER fallback, non-secret +} +``` + +`launch.command`/`launch.args` come from +`resolve_effective_harness_descriptor` (`readiness.rs:125`) — the same +resolver local spawn uses — which fixes two silent divergences the raw +record fields carry: a persona-derived `agent_command` is a blank record +byte, and definition-provided `agent_args` are lost when the instance's own +args are empty. `launch.env` is that descriptor's layered env, which is +where per-runtime model/provider injection lives (`GOOSE_MODEL`/ +`GOOSE_PROVIDER` for goose; nothing for `provider_locked` runtimes like +Claude; `BUZZ_AGENT_MODEL`/`BUZZ_AGENT_PROVIDER` for buzz-agent). A fixed +`provider → BUZZ_AGENT_PROVIDER` mapping is wrong for three of the four +built-in runtimes and is why this block exists. + +**What `policy_env` carries — and deliberately does not.** Its irreducible +wire fields are exactly three scalars plus the metadata-derived defaults — +plus the four record-derived behavior knobs that would otherwise be +mis-tiered (below): + +- `BUZZ_ACP_TEAM_INSTRUCTIONS` — the only truly non-reconstructible policy + value: `effective_team_instructions` (`spawn_hash.rs:41-52`) needs the + desktop's `TeamRecord` store, which no pod can reach. +- `BUZZ_ACP_SESSION_TITLE` — sent **resolved** (`resolve_session_title`, + `runtime/metadata.rs:45`), not as its `display_name`/`name` inputs. The + resolution strips control characters, and that property transfers: an + interior NUL fails a local spawn at the env boundary, and would make the + Kubernetes apiserver reject the whole pod spec — a rename must degrade, + not turn into a deploy failure. +- `owner_pubkey` (block-level, not env) — the respond-to gate is otherwise + fully reconstructible from payload fields (`build_respond_to_env`, + `runtime.rs:380-421`); this is its one irreducible input. +- Runtime `default_env` (e.g. `GOOSE_MODE=auto`) — computed **from the + runtime metadata table only, unconditionally**. The local spawn applies + each default only `if std::env::var(key).is_err()` (`runtime.rs:733-737`) + — a test of the *desktop's own* ambient environment. That makes "the + resolved local env" not a pure function of the record; serializing it + verbatim would bake a host accident into the pod. Launch data MUST be + computed from record + config alone. +- `BUZZ_ACP_LAZY_POOL=true` — a **deliberate pick, not a transcription**: + the two local paths disagree (manual Start is eager, `runtime.rs:1006`; + launch restore is lazy, `restore.rs:333`, precisely to avoid "N idle + brains on every launch"). Remote pods take the lazy arm: an idle LLM pool + in a cluster is billable waste with no user watching it warm up. +- `MCP_HOOK_SERVERS=*` when the resolved runtime has `mcp_hooks` + (`runtime.rs:594-598`; buzz-agent only at `c1bca1b56`) — gates the + `_Stop`/`_PostCompact` hook tools. +- `BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, + `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS` — resolved by the desktop + from the record's `system_prompt` / `idle_timeout_seconds` / + `max_turn_duration_seconds` / `parallelism` (each omitted when null, + matching the local spawn's conditional emission). These are **tier-1 by + local fact, not by choice**: the local spawn writes them before the user + env layer (`runtime.rs:716-729,763` vs `:860`) and none is in + `RESERVED_ENV_KEYS`, so a power user's env override beats them today. A + provider that independently mapped the top-level payload copies after + `launch.env` would invert that — the structured field silently defeating + an override that works locally — which is why the provider MUST NOT remap + them (§Entrypoint mapping table). + +`BUZZ_ACP_DEDUP` and `BUZZ_ACP_MULTIPLE_EVENT_HANDLING` are **deliberately +unset**: the local spawn writes `queue`/`steer` (`runtime.rs:730-731`), and +those are exactly the harness's clap defaults (`config.rs:344,356`) — a pod +that omits both is behaviorally identical, and adding rows for them would +imply a divergence that does not exist. `BUZZ_MANAGED_AGENT` is likewise +deliberately absent remotely: it brands local harness processes so the +desktop's orphan sweep and instance reaper can prove ownership by scanning +process env (`orphan_sweep.rs`, `instance_reaper.rs`) — there is no local +process to sweep. + +**Environment precedence (normative) — three tiers, later wins:** + +1. **Overridable behavior defaults** — `launch.policy_env`. These keys are + deliberately non-reserved (`env_vars.rs:54-57` says so outright: power + users may bypass the dedicated UI fields), and locally the user env is + written after them (`runtime.rs:860` and its comment). A policy-wins + order here would make remote agents ignore overrides local agents honor. +2. **User/layered env** — `launch.env`. User `env_vars` need no separate + slot: the descriptor's layering already merged them (global < persona < + agent), so a provider applies `launch.env` and MUST NOT re-merge the + legacy `env_vars` field on top. +3. **Authoritative** — unoverridable at every layer, written last and + backed by the reserved-key strip: the identity variables from top-level + payload fields (§Reserved-key rule), the respond-to gate values, + `BUZZ_ACP_AGENT_OWNER`, the inactivity bound, `BUZZ_ACP_MCP_COMMAND`, + and `BUZZ_MANAGED_AGENT_START_NONCE`. For the nonce, the provider MUST + set it to the attempt's **generation token** (§K8s Secrets): the harness + stamps it into every observer lifecycle frame (`buzz-acp/lib.rs:1501`), + so the Secret generation and the lifecycle correlator become one + identity instead of an empty string. + +**Host-resolved values MUST NOT be forwarded and MUST be re-derived +in-image.** The local spawn sets several variables to absolute paths on the +desktop's filesystem; forwarding them into a container is a guaranteed +failure. The provider/image re-derives: + +- the harness and agent binaries: `launch.command` is a *name*, resolved + against the image's own `PATH` (`BUZZ_ACP_AGENT_COMMAND`), and + `BUZZ_ACP_MCP_COMMAND=buzz-dev-mcp` likewise; +- `CLAUDE_CODE_EXECUTABLE` — a `resolve_command()` host path + (`configure_runtime_cli`, `runtime.rs:424-446`), same class as the + command paths: image-local resolution or unset; +- `PATH` itself (the desktop's augmented PATH is meaningless in the image); +- git credential/signing helper locations — the relay-URL *scoping* of the + credential config is normative (never a global helper), the helper *path* + is image-local (§Image); +- `BUZZ_ACP_SETUP_PAYLOAD` is desktop-computed readiness state and MUST NOT + appear in a remote pod. + +**Owner resolution (normative):** the provider MUST have either a non-null +`auth_tag` (→ `BUZZ_AUTH_TAG`) or a non-null `launch.owner_pubkey` +(→ `BUZZ_ACP_AGENT_OWNER`) before any mutation; if both are null it MUST +refuse the deploy. Without an owner the harness cannot match `!shutdown` +(`buzz-acp/src/lib.rs: resolve_agent_owner`, main-loop owner check) and the +agent answers its own stop command conversationally — §Stop would be +describing a mechanism that does not work. `BUZZ_ACP_AGENT_OWNER` is a +reserved key, so this value can only arrive as authoritative launch data, +never through user env. + +**Buzz shared compute (relay-mesh) is non-deployable, and this is forced, +not chosen.** The mesh rewrite resolves to an OpenAI-compatible transport at +`http://127.0.0.1:9337/v1` (`relay_mesh.rs: RELAY_MESH_API_BASE_URL`) — a +loopback proxy on the desktop. Serializing that policy into a pod points the +agent at its own localhost, where nothing listens. `D` already rejects +mesh-configured creates on non-local backends +(`agents.rs: normalize_relay_mesh`); the deploy path MUST equally fail +closed — before any mutation — when the effective provider resolves to +`relay-mesh`, rather than passing `relay-mesh` through as if it were a +runtime provider. Remote mesh transport is a possible v2 (an in-image mesh +client), not a v1 silent breakage. + +**The governing invariant:** a remote agent's environment differs from the +same record's local spawn **only where the substrate forces it** (paths, +PATH, readiness). Anyone adding a local behavior knob adds it to the shared +resolver, and both spawn paths inherit it; there are not two derivations to +keep in sync. + +### Deploy State Machine + +`start` on any non-Local agent unconditionally issues `deploy` — the desktop +does not track substrate state (M1). Deploy is therefore **not** "create": it +is *converge to at-most-one-live-instance* (I4), implemented as a +**reconciliation loop** keyed on the agent's identity within the provider's +scope. + +**Step 0 — derive and verify identity.** The payload carries the nsec, not +the pubkey. Before any substrate read or mutation, the provider MUST parse +`private_key_nsec` and derive the public key from it; a malformed or +undecodable key is an immediate in-band error. Every selector, name, and +comparison below uses the *derived* pubkey — never a caller-supplied one. + +**Step 1 — select and authenticate candidates.** Candidate objects are +selected by the (truncated) identity label, then each candidate's +**full-pubkey annotation MUST be compared against the derived pubkey** +before it is treated as belonging to this agent. Truncated selectors are +collision-*resistant*, not collision-*free*: the annotation check is what +makes them safe. An object whose annotation does not match MUST NOT be +no-op'd against, deleted, GC'd, or have its Secret touched; the provider +MUST either ignore it or fail with an explicit collision error. Only +annotation-verified objects proceed. + +**Auto-repair is fenced to Buzz-authored, positively identified residue +(normative).** The destructive rows below (delete residue, replace a +never-started body, GC a Secret) are legitimate *only because* every object +they touch carries positive **protocol ownership evidence** — and identity +evidence alone is not ownership evidence. The identity label, the +full-pubkey annotation, and the create-intent fingerprint prove "matches +our schema for this public identity"; all three are public, so any cluster +writer can reproduce them on an object this provider never created. Every +object this provider creates therefore also carries an explicit +management marker — `app.kubernetes.io/managed-by: buzz-backend-kubernetes` +plus a binding schema-version label (§Pod shape) — and **no destructive +repair or GC action fires unless the marker is present**, on top of the +annotation check and the UID+`resourceVersion` fence every delete already +requires. This is protocol evidence, not cryptographic proof: a cluster +writer can forge metadata by definition, and an actor with write access to +the namespace can already delete the pod outright — the marker's job is +making *accidental* schema collisions and third-party objects fail closed, +not defeating a hostile admin. The vision's rule that a never-started body +is substrate-operator residue survives with one qualifier: *Buzz-authored* +create-state (a Secret our provider wrote, a pod carrying our verified +annotations and marker) is the reconciler's to clear, because it is state +the user cannot reasonably clear themselves; *substrate* wreckage — +anything unowned, unmarked, unannotated, or ambiguously identified — still +fails closed to the operator. A provider that cannot positively identify an +object as its own output does not repair around it; it reports it. + +**Step 2 — reconcile.** Ordered rules, evaluated against the verified +observation; on any conflict, *re-enter from step 1* rather than fail: + +| observed | action | rationale | +|---|---|---| +| instance marked for deletion (`deletionTimestamp` set, any phase) | wait for actual disappearance, then re-enter | the user pressed Start and the old instance is unrecoverable; returning the dying instance's id records a success that evaporates. **Note: in Kubernetes there is no `Terminating` phase — a pod being gracefully deleted stays in phase `Running` for its whole grace period.** The deletion mark MUST be checked *before* phase, or this row is mistaken for the no-op row | +| no instance | create, then verify startup (below) | first deploy / after GC | +| terminated (Succeeded/Failed) | delete residue, wait for disappearance, re-enter (→ create) | the **normal restart path**: how a user revives a reaped or shut-down agent | +| live and **started** (harness container running) | **strict no-op; return existing `agent_id`** | Start must never silently kill a live agent mid-turn; "already running" is the honest answer, consistent with I3 | +| exists but **never started**, provably non-recoverable — referenced Secret confirmed absent (by a consistent read, below), or invalid image reference | delete (preconditioned, below), wait for disappearance, re-enter (→ create) | a pod whose harness never ran is not a live agent: nothing can be killed mid-turn (I3), it never held the identity (I4), auto-stop cannot bound it (I5's reaper lives in the harness), and no-op'ing it would return a permanently inert instance as success on every future Start. "Provably" means the provider verified the referenced object's absence or the spec-level defect itself — never a reason string alone | +| exists, **never started**, recoverable, **fingerprint matches** current desired create intent (below) — self-healable startup states: `Unschedulable` (scale-from-zero autoscaling), image pull / `ImagePullBackOff`, transient `CreateContainerConfigError` | observe until started or the operation deadline expires, then return the latest redacted condition — **never delete, on this call or any later one** | these states routinely self-heal — an autoscaler provisions the node, the pull retries, the kubelet re-resolves the Secret (it retries a never-created container regardless of `restartPolicy`). And recoverable-timeout MUST stay observational *across calls*: any finite pod-age threshold can collide with the cluster's own pod-age thresholds (Cluster Autoscaler's `--new-pod-scale-up-delay` / per-pod `pod-scale-up-delay` annotation — the FAQ's example is `"600s"`), and delete-recreate resets exactly the age the autoscaler keys on, converting a slow cold start into a livelock in which every individual decision is correct. A later deploy re-reads: started → strict no-op; still recoverable and same intent → observe under the new call's deadline without resetting pod age; provably non-recoverable → the non-recoverable row. Repeated **identical** Starts can therefore never delete anything, whatever the pod's age — a genuinely slow cluster persists until it heals or an operator acts, and M1 already makes substrate residue the operator's boundary | +| exists, **never started**, recoverable, **fingerprint differs or absent** — the recorded create-intent fingerprint does not match what *this* deploy would create | delete (preconditioned, below), wait for disappearance, re-enter (→ create) | this is not the same generation the user is waiting on — it is a pod built from configuration the user has since *changed*, and without this row the change can never materialize: the pod name is deterministic, GC only reaps terminated pods, Stop needs a live harness, I5's reaper lives in the harness, and there is no `undeploy` — so a never-started pod wedged by its own config (a `memory_request` no node satisfies, a quota-blocked namespace) would swallow every future edit while reporting only "startup not confirmed", indistinguishable from a slow cluster. Divergence is evidence, not a clock — but it has **two** sources, not one: a user config change, and a provider upgrade that moves the baked default image digest (§K8s image; the default is compile-time provider state, so upgrading the provider changes the computed intent with no user action). Both are deliberate: the second is the *only* escape from a wedge caused by a bad baked default (unpullable digest, wrong arch) — a fingerprint blind to the default resolution would hand that wedge back to exactly the population that cannot override `image`. The accepted cost is that a provider upgrade mid-cold-pull discards in-flight startup progress; neither source is clocked to anything the cluster keys on, so no threshold exists to collide with the autoscaler. A **started** pod is never touched by this row: live → strict no-op regardless of divergence (edits reach it via the documented next-generation consequence) | + +**Startup is part of create — phase is not readiness.** `Pending` (and even +`Running` at the pod level) does not mean the harness started: a pod can sit +in `ImagePullBackOff`, `CreateContainerConfigError` (e.g. a missing +`envFrom` Secret), or unschedulable `Pending` forever, and I5's inactivity +reaper cannot bound a harness that never began. Therefore `deploy` MUST NOT +report success at pod acceptance: it succeeds only when the harness +container has actually started (container `state.running`), bounded by the +operation deadline. On failure or deadline expiry it MUST return an in-band +error carrying the actionable condition (the container waiting `reason` / +pod condition), not a generic timeout. "Live" in the no-op row above means +**started**, for the same reason — this is the lesson ephemeral-runner +controllers learned upstream (inspect container state, not pod phase). +Classification MUST combine container state, pod conditions, +referenced-object existence, and the recorded create-intent fingerprint — +**reason strings alone are not a +stable fatality taxonomy**, and pod age is never one (age triggers nothing +destructive; see the recoverable rows and the controlled-view rule below). +In particular, `Unschedulable` is not fatal: a +scale-from-zero pod reports it while the autoscaler provisions capacity, +and the kubelet retries a container that never got a container status +regardless of `restartPolicy: Never` (`ShouldContainerBeRestarted` returns +true for a nil status *before* the restart-policy check — +`kubelet/container/helpers.go`), which is exactly why a briefly-missing +Secret self-heals. `restartPolicy: Never` suppresses restarting a container +that ran and died; it says nothing about one that never started. +A consequence to state plainly: once success includes container start, the +600s operation deadline **is** the cold-start budget — image pull on a +fresh node, scale-from-zero scheduling, all of it. But the deadline bounds +**how long one Start waits synchronously**, nothing more. Deadline expiry +on a still-progressing startup is reported as "startup not confirmed within +the deadline", and MUST NOT trigger cleanup or forced recycle — on this +call *or any later one* (the recoverable rows above — what replaces a +never-started pod is a *config change*, never a deadline): the next deploy's +reconciler observes whatever the startup became and takes the matching row, +preserving the pod's `creationTimestamp` for whatever cluster machinery +keys on it. Whether ten minutes fits the intended cluster class is a +product ruling, +not a correctness one. + +**Destructive decisions come from views you control — reads and writes +both (normative).** This is one rule with three instances, stated once so +nobody optimizes an instance away. §K8s GC's same-clock rule is the time +instance. The other two live here: + +- *Reads*: every read whose result can authorize a deletion — the + Secret-absence confirmation above, and the candidate list the GC pass + filters — MUST use most-recent semantics (`resourceVersion` **unset**, + a quorum read). `resourceVersion: "0"` is served from the watch cache, + which the Kubernetes API contract explicitly allows to be much older + than anything the client has already observed; a stale + Secret-absence read would delete a pod whose Secret exists and whose + container was about to start — the GC race again, arriving through read + consistency instead of a clock. +- *Writes*: a fresh read is necessary but not sufficient — the kubelet can + start the container between observation and delete. Every DELETE + authorized by a classification MUST carry `preconditions.uid` **and** + `preconditions.resourceVersion` from that exact observation + (`metav1.Preconditions` supports both), making the edge a + compare-and-delete. A failed precondition is neither an error nor + permission to retry the delete: re-enter from step 1 and classify the + object that exists now. The full-pubkey annotation check remains — the + precondition pins *when*, the annotation pins *whose*. + +**The 409 discriminator is `Status.reason`, never the status code +(normative).** Two rules in this section require *opposite* actions on +the same HTTP status: a failed delete precondition and a create-conflict +are **both 409** (`NewConflict` and `NewAlreadyExists` each carry +`Code: http.StatusConflict` — `apimachinery/pkg/api/errors/errors.go`). +The discriminator is the Kubernetes API `Status.reason` field: +`Conflict` → abandon the delete and re-enter from step 1; +`AlreadyExists` on create → the convergence rule below (clean up only the +losing attempt's Secret, re-read, adopt the winner). An implementation +that branches on the code alone will eventually take the adoption path on +a failed delete or vice versa. This does not contradict the +reason-strings warning above: API `Status.reason` is a machine-readable +contract token defined by `metav1.Status`; *container waiting* reasons +are kubelet-produced strings with no such contract — the spec distrusts +the latter, not the former. + +**Create-intent fingerprint (normative).** The divergence discriminator +in the never-started rows is a recorded annotation, +`buzz.block.xyz/create-intent`, written at pod create — the same shape as +the image-reference and pubkey annotations the pod already carries. Its +value is an **unkeyed SHA-256** over a canonical serialization of the +provider's **non-secret create-intent template**, computed *before* the +create call. The scope rule that makes a plain hash safe: the input +covers exactly the provider-controlled fields that can affect scheduling +or container creation — resolved image reference, resource +requests/limits, service account, PodSpec command/args, volumes/mounts, +security context, and the provider's other pod-shape knobs — and **never +Secret data or attempt identity**. Secret *values* cannot cause the +never-started wedge this discriminator exists to clear (scheduling reads +pod fields, not Secret values; a bad launch value produces a started +container that fails at the relay — a different row), so hashing them +buys nothing and a plain hash over low-entropy secrets published in a +world-readable annotation would be a dictionary oracle; excluding them +removes the oracle and with it any need for an HMAC key or nsec-derived +key material. Two normalization requirements, or every attempt diverges +by construction: the per-attempt Secret *name* in `envFrom` MUST be +replaced by a fixed placeholder (or the pre-binding template serialized +instead of the concrete PodSpec), and API metadata / server- and +admission-produced output (UID, `resourceVersion`, timestamps, defaulted +fields, the fingerprint annotation itself) is excluded structurally — +the serializer never sees it, an invariant checkable by inspection. +Comparison is always recorded-annotation vs freshly-computed intent, +**never** a diff against the live pod spec: admission defaulting and +mutation would make every pod look divergent, which is why the +fingerprint is computed pre-create. A missing referenced Secret stays +handled by the most-recent absence check (the non-recoverable row), not +by fingerprint divergence; and divergence authorizes deletion only +through the never-started recoverable row — a started pod is strict +no-op whatever its fingerprint says. + +**No-op means zero mutation.** The live-instance row MUST NOT replace or +patch the Secret, patch metadata, or delete anything belonging to the +observed live generation. Configuration and environment edits apply only to +the *next* fresh generation (see the documented consequence below). + +**Conflicts converge, never fail.** Two provider processes can concurrently +observe "no instance" or "terminated" — the deterministic instance name +prevents two live instances, but one caller loses the race. The provider +MUST treat create-conflict (already exists) by re-reading and, if the winner +is an annotation-verified live instance, returning it as the no-op row +would — cleaning up only its own losing attempt's residue, never the +winner's (the Kubernetes binding makes this concrete via per-attempt Secret +names, §K8s Secrets); it MUST treat delete-not-found as success; and it +MUST loop until a +stable outcome or the operation deadline (600s) expires. One deliberate +asymmetry: the **create loser does not apply the fingerprint-divergence +row to the pod that just beat it**, even when the winner's fingerprint +differs from its own intent — it adopts or reconciles the elected winner. +Two contenders with different payloads would otherwise ping-pong deletes +through the conflict path. A *subsequent* deploy that walks in and +observes that never-started divergent winner replaces it normally. +Without this rule, +"two deploys return an `agent_id`" (the idempotency claim below) is false +under concurrency. + +**Documented consequence.** Because live → no-op, configuration edits to a +running remote agent do not take effect until it next exits (unlike local +agents, which re-resolve on every spawn). This is an accepted v1 tradeoff; +a deliberate "recycle" affordance (stop-then-start) is the v2 path to +immediate application. [DECISION E, ruled: per-binding policy — this +binding keeps no-op; the universal property is that no sequence of Starts +yields two live instances in one scope.] Note the asymmetry is deliberate and points the +right way: an edit *cannot* reach a started pod until it exits, but it +*can* reach a never-started one immediately (fingerprint divergence) — +the never-started pod is the one the user is editing *because* it did not +start. + +Idempotency in the protocol sense: any number of concurrent or sequential +`deploy`s with the same payload converge to one live instance, and every +non-erroring call returns an `agent_id` naming it; no sequence of `deploy`s +can yield two live instances in one scope. + +### Stop and Delete + +- **Stop** is not a provider operation. `D` publishes `!shutdown` mentioning + the agent on `R`; the harness verifies the sender is the owner and exits + through its graceful path: agent-pool shutdown, drain of in-flight turns, + publish presence `offline`, close relay connection. **The spec does not + derive an upper bound for this path from its segment timeouts, because + review proved that arithmetic wrong twice**: the visible constants (30s + drain, 2s presence, 5s relay close) omit terms that are *variable*, not + constant — at this PR's base `b4f4ed1a6` the post-drain reap segment + (late-arriving reap `lib.rs:2664`, idle-slot reap loop `:2670`, respawn + drain `:2684-2688`) runs *outside* the 30s drain timeout (opened at + `:2636`, closed at `:2657`) and serially awaits a 5s post-SIGKILL wait + per occupied pool slot (`acp.rs:436`). **That segment alone can reach + `30 + 5×parallelism + 7` — ~87s at the desktop's default parallelism + of 10** (`DEFAULT_AGENT_PARALLELISM`, `types.rs:809`; lowered from 24 by + #3038), ~197s at the harness cap of 32 (`config.rs:293`) — already + exceeding a 60s grace. And it is a *lower* bound on the tail, not the + worst case: the same path runs earlier segments before the prompt drain + even opens — a separate 30s wake-task drain (`:2612`) followed by + serial shutdown of any awakened pools (`:2620-2624`), whose per-slot + `acp.shutdown()` loop (`:3747-3751`) has no timeout of its own. The + total tail is not bounded by today's segment timeouts at all. + The requirement is therefore stated as a budget, not a sum (Known + Defect 7): **the harness MUST bound its total shutdown tail — every + post-signal segment, including per-slot reaping — under one shared + deadline no greater than the declared grace budget**, and the budget + MUST include a **reserved finalization slice** held back for presence + `offline` publish and relay close, **no smaller than those finalizers' + declared bounds — currently 2s + 5s = 7s** — which child cleanup may + never consume: child reaping degrades first (skip remaining per-slot + waits, force-kill), because a shared deadline without the reservation + can legally spend all 60s reaping children and hit SIGKILL before the + one action the grace period exists to protect. The binding declares the + budget (§K8s Grace: 60s); anyone re-deriving "~37s" from the segment + constants is reading numbers without their variables. The desktop's + local stop command rejects remote agents. +- **Delete** with a live `backend_agent_id` requires `force_remote_delete: + true` from the UI's orphan-warning confirmation — a buggy IPC caller + cannot silently orphan substrate objects. + +### Auto-Stop (Inactivity Self-Termination) + +I5's enforcement point. A new harness knob: + +``` +--exit-after-inactivity / BUZZ_ACP_EXIT_AFTER_INACTIVITY +``` + +- **Default 0 = disabled.** The flag ships in the harness every *local* + agent also runs; a reaper bug must not be able to kill a laptop agent. + Remote providers opt in (the Kubernetes binding's `inactivity_seconds` + config field, schema default 7200 = 2h, feeds this env var directly). + **`inactivity_seconds: 0` is likewise a legal, blessed value meaning "no + inactivity bound"** — the explicit opt-in to an indefinitely-lived agent + (I5's lifetime-is-policy rule); it is not a misconfiguration and MUST NOT + be rejected by provider-side validation. +- **"Inactivity" is defined as**: no events dispatched to the agent and no + turns in flight. Raw relay traffic does not count — an agent lurking in a + busy channel it never answers is exactly the waste this bounds. +- **Mechanism**: on expiry of the bound, the harness fires the same shutdown + channel `!shutdown` uses — so inactivity exit gets in-flight drain, + presence→offline, and graceful relay close identically to an owner stop. + **The expiry check MUST NOT depend on pool readiness.** This is a design + constraint learned by inspection, not a transcription: the harness's + existing 30s maintenance tick is gated on `pool_ready` (`lib.rs:1743`), + which under `lazy_pool` starts false (`:1320`) and flips true only on a + wake (`:2570`) — and wakes require pending work (`pool_lifecycle.rs:42`). + A reaper riding that tick composes with the mandated + `BUZZ_ACP_LAZY_POOL=true` (§Launch data) into a deadlock in I5's single + most important case: a never-mentioned lazy pod never runs the tick, so + the idle agent the reaper exists to kill is exactly the one it can never + evaluate. The reaper therefore runs on its own timer, independent of pool + state (an idle-pool check needs no pool). Check granularity makes the + effective bound `t ∈ [T, T+interval)`, immaterial at T=7200. +- **Reserved keys**: `BUZZ_ACP_EXIT_AFTER_INACTIVITY` MUST join + `RESERVED_ENV_KEYS` (`env_vars.rs`) when it lands — it is tier-3 + authoritative (§Launch data), and without reservation a user env var + could disable the reaper and reopen unbounded lifetime through the front + door. `BUZZ_ACP_NO_PRESENCE` (`config.rs:378`) MUST join in the same + change, for the same shape of reason at I3 instead of I5: unreserved, it + lets user env silently defeat the 90s presence bound (I3). One knob + guards "knows when to leave", the other "you can see that it left"; + both are promises users must not be able to un-make by typo. +- Distinctness note: this is a **fourth** timeout concept, deliberately named + away from the existing three (`--idle-timeout` = per-turn ACP wire silence, + 900s; `turn_timeout`; `max_turn_duration` = 7200s — numerically equal to + the default inactivity bound and semantically unrelated). Sharing a flag or + env name with any of them is how the bug ships. + +The harness exiting MUST terminate the substrate's unit of execution, and — +equally load-bearing — the substrate's termination signal MUST reach the +harness process itself; any wrapper MUST forward it. A wrapper that runs +the harness as a child without forwarding signals silently voids both I5's +substrate half *and* the graceful-shutdown budget: the termination signal +lands on the wrapper, the harness never learns to shut down, and the +force-kill leaves presence stale-online — exactly the staleness window the +grace period exists to close. This binding's realization — the harness as +the container's signal-receiving process (PID 1 or the signal target) — is +§K8s Entrypoint's `exec` rule and §Pod shape ([L3], L1 item 3 for the +universal form). +With the supervisor policy that matches the lifetime policy (this +binding's [L3] mapping — bounded → `Never`, indefinite → `OnFailure` +after both prerequisites, §Pod shape; the universal rule is I5's), +harness exit completes the pod on every intentional path — turning +agent-level I5 into substrate-level I5. + +## The Kubernetes Binding (`buzz-backend-kubernetes`) + +The first conforming provider: a Rust crate in `block/buzz`, distributed as a +standalone binary. Everything above is the contract; this section is its +realization. + +### Cluster auth {#k8s-auth} + +Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`) via +`kube-rs`. `provider_config` carries **`context`** and **`namespace`** only +(I2: credentials never transit config). Because kubeconfigs at Block +near-universally use `exec` credential plugins (`aws eks get-token`, +`gke-gcloud-auth-plugin`) that resolve via PATH, and the provider inherits a +Finder-launched desktop's minimal PATH, the provider MUST prepend +`/opt/homebrew/bin`, `/usr/local/bin`, and `~/.local/bin` to its own PATH +before building the client, and on exec-plugin failure MUST name the missing +plugin binary in the error rather than surfacing a kube-rs stack. + +### Namespace {#k8s-namespace} + +One stable namespace per user-visible choice; the provider emits a freshly +generated `buzz-agents-` as the `namespace` field's schema *default* +on every `info` call, so the UI prefills a visible, editable random name with +zero UI changes ("random default" satisfied at the schema layer). If the +namespace does not exist the provider attempts to create it; on RBAC denial +it MUST fail with the literal `kubectl create namespace ` command to +run — it MUST NOT fall back to `default`. + +### Image + +`ghcr.io/block/buzz-sprig`: Alpine base + `bash` (required by the dev-MCP +shell tool) + `git` + CA certificates + the static musl `sprig` multicall +binary with its personality links (`buzz-acp`, `buzz-agent`, `buzz-dev-mcp`, +`rg`, `tree`, `buzz`, `git-credential-nostr`, `git-sign-nostr`) + a baked +system gitconfig wiring the nostr signing and credential helpers. The baked +credential-helper config MUST be scoped to the relay's git URL — mirroring +the local spawn's `credential./git.helper` scoping — never a +global `credential.helper`: a global nostr helper would answer for every +remote, including github.com. ~15–25MB; +not FROM-scratch (bash and git preclude it). Sprig-only: alternate-harness +dependencies (node for Claude Code / Codex) come via the `image` override +field, not a fatter default. Tagging follows the relay image's matrix — +`sha-` on main, semver on `sprig-v*` tags (the sprig tarball's +`+git.` version string is not a legal Docker tag). **The default image +reference MUST be pinned by digest, not tag**: the provider bakes, at +compile time, the multi-arch manifest digest of the image built from its +own commit and defaults `image` to +`ghcr.io/block/buzz-sprig@sha256:` — a `sha-` *tag* +is traceable but still movable (registry tags are mutable pointers; +Kubernetes distinguishes movable tags from immutable digests for exactly +this reason), and the object holding it runs with an nsec. The provider +records the reference it used in a pod annotation, and rejects `:latest`. +User `image` overrides accept tag, digest, or full custom registry +reference — visibly the user's trust decision, with the resolved image ID +recorded in the same annotation for post-hoc attribution. +**An image override MUST contain the runtime ABI** — the `buzz-acp` +entrypoint and everything §Entrypoint and launch ABI requires — not merely +alternate-harness dependencies. A conforming custom image is "buzz-sprig +plus your tools", never "your tools instead". + +### Entrypoint and launch ABI {#k8s-entrypoint} + +Two conforming implementations must produce interchangeable pods, so the +launch contract is normative. + +**Entrypoint.** The container runs the harness as its signal-receiving +process. Sprig is a multicall binary with no supervisor personality — +nothing reaps children or forwards signals — so the entrypoint MUST end in +`exec`: + +```bash +#!/bin/bash +set -e +# nest scaffolding, if DECISION A lands, goes here +exec buzz-acp # exec, not a call — buzz-acp must be PID 1 +``` + +`bash -c "setup && buzz-acp"` (no `exec`) is non-conforming: bash becomes +PID 1, and a PID-1 bash with no trap never delivers SIGTERM to the harness +(PID 1 receives kernel-level default-handler signal immunity), so the pod +rides out the entire grace period and is SIGKILLed with presence still +online — voiding I5's substrate half and the very staleness window +`terminationGracePeriodSeconds: 60` was sized to close. The entrypoint +shape and the grace period are one requirement, not two. + +**Payload → environment mapping.** The provider builds the pod environment +(via the per-agent Secret, §K8s Secrets) by applying the §Launch data +three-tier precedence — `launch.policy_env` (overridable defaults), then +`launch.env` (user/layered), then the authoritative tier from top-level +fields per the reserved-key rule. Only the +non-`launch` scalars and the substrate-forced re-derivations are mapped +individually: + +| source | env var | +|---|---| +| `relay_url` | `BUZZ_RELAY_URL` | +| `private_key_nsec` | `BUZZ_PRIVATE_KEY` and `NOSTR_PRIVATE_KEY` (the git helpers read the latter) | +| `auth_tag` | `BUZZ_AUTH_TAG` (omitted when null; then `launch.owner_pubkey` → `BUZZ_ACP_AGENT_OWNER` is REQUIRED — §Launch data owner rule) | +| `launch.command` | `BUZZ_ACP_AGENT_COMMAND` — the *name*, resolved against the image's own PATH; never a forwarded host path | +| `launch.args` | `BUZZ_ACP_AGENT_ARGS`, comma-joined | +| `launch.env`, `launch.policy_env` | verbatim, at their precedence tiers | +| generation token (§K8s Secrets) | `BUZZ_MANAGED_AGENT_START_NONCE` — the lifecycle-frame correlator and the Secret generation are one identity (§Launch data tier 3) | +| `system_prompt`, `idle_timeout_seconds`, `max_turn_duration_seconds`, `parallelism` | **not mapped by the provider** — the desktop resolves these into `launch.policy_env` (`BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS`), because they are tier-1 behavior knobs: locally they are written *before* the user env layer and none is reserved (`runtime.rs:716-729,763` vs `:860`; `env_vars.rs:54-57`), so user env beats them. A provider that mapped the top-level copies after `launch.env` would silently defeat an override that works locally. The top-level fields remain as display/bookkeeping inputs only | +| `turn_timeout_seconds` | not mapped — deprecated upstream and ignored; the local spawn also does not emit it | +| `respond_to` | `BUZZ_ACP_RESPOND_TO` | +| `respond_to_allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST`, comma-joined | +| — | `BUZZ_ACP_MCP_COMMAND=buzz-dev-mcp` (image-local; the dev-MCP requirement) | +| `provider_config.inactivity_seconds` | `BUZZ_ACP_EXIT_AFTER_INACTIVITY` (schema default 7200; the I5 opt-in, §Auto-Stop — the config field and this env var are one knob, not two) | + +The top-level `model`/`provider` payload fields are display/bookkeeping +inputs; the *environment* consequence of model and provider selection +(per-runtime vars, `provider_locked` suppression, `BUZZ_ACP_MODEL`) arrives +resolved inside `launch.env`/`launch.policy_env`. A provider MUST NOT map +`provider` to any env var itself — that mapping is per-runtime and lives in +the desktop's resolver (§Launch data). + +**Encoding honesty note.** `BUZZ_ACP_AGENT_ARGS` is comma-delimited by the +harness's CLI parser, and the desktop's *local* spawn performs the same +comma-join — an argument containing a comma is unrepresentable in both +paths. This is a harness interface limitation the binding inherits and +matches, not one it introduces; a provider MUST NOT invent a private +escaping scheme the harness would not decode. + +**Working directory.** `HOME` is set to a writable path backed by the +workspace `emptyDir` (e.g. `/home/agent`), and the harness runs with cwd = +`HOME` — mirroring the local spawn's agent-workdir convention. The baked +system gitconfig references the nostr helpers by absolute path so it works +regardless of `HOME`. + +### Pod shape + +- **Bare Pod; `restartPolicy` follows lifetime policy (I5).** No Job, no + controller — controller-grade restart machinery (`Restart=always`-shaped) + would resurrect what `!shutdown` and auto-stop terminate, violating I5. + Within the bare pod, the policy is selected from `inactivity_seconds`: + - **Bounded lifetime (`inactivity_seconds > 0`, the default): `Never`.** + The reaper's clean exit must complete the pod; any restart would undo + the reap. Accidental death is handled by *intent*, not machinery: + eviction → presence `offline` (I3) → user hits Start → the + reconciler's terminated arm re-creates. That sequence is + rescheduling-after-accident gated on a fresh owner intent — apt for an + agent whose owner already accepted "not running" as its default state. + - **Indefinite lifetime (`inactivity_seconds: 0`): `OnFailure`** — once + the harness exit-code contract is pinned (I5 ordering rule; until + then the provider MUST refuse the combination rather than ship + `OnFailure` against an undefended exit convention). `OnFailure` + restarts the *in-place* abnormal deaths — process crash, container + OOM-kill — and honors the intentional ones (clean exit completes the + pod): I5's intent-vs-accident distinction, realized. **Second + prerequisite — reconciler classification:** `OnFailure` introduces a + pod state the deploy state machine's rows do not cover — a + crash-looping harness sits in phase `Running` with + `state.waiting{reason: CrashLoopBackOff}`, `restartCount > 0`: not + deletion-marked, not terminated (the kubelet keeps restarting it), + not "live and started" (`state.running` is false), and not + never-started (it started, repeatedly) — and it fails the startup + success criterion while the kubelet is actively reviving it. Before + the binding ships `OnFailure`, the state machine MUST gain a + crash-loop classification row and the started-criterion's treatment + of `restartCount > 0` MUST be specified; the exit-code contract alone + is *not* the green light. **Honest + limit:** `restartPolicy` is + kubelet-level and cannot survive *node-level* loss — a drain or + API-initiated eviction deletes a bare pod outright, and no + restart policy reschedules a deleted pod. Full "continuous need" + across node loss requires controller-grade machinery this binding + deliberately does not use in v1 (the same machinery I5 distrusts); + the v1 promise for indefinite agents is restart-on-crash, with + node loss surfacing as presence `offline` awaiting a fresh Start. +- **Naming/labeling — the exact contract** (63-char label-value limit; a hex + pubkey is 64 chars, one over): + - pod name: `buzz-agent-` — also the returned + `agent_id` + - label `buzz.block.xyz/agent-pubkey: ` — the selector key + for reconciliation and GC. 128 bits is collision-*resistant*, not + collision-free, which is why the annotation check below is normative, + not decorative + - label `app.kubernetes.io/managed-by: buzz-backend-kubernetes` and label + `buzz.block.xyz/binding-version: ` — the **management + marker** (§Deploy State Machine auto-repair fence): present on every + pod and Secret this provider creates, and **required before any + destructive repair or GC action**. Identity labels/annotations prove + identity; the marker asserts protocol ownership — without it, an object + that merely matches our schema fails closed to the operator + - annotation `buzz.block.xyz/agent-pubkey-full: ` — + **load-bearing**: per §Deploy State Machine step 1, every label-selected + object's annotation MUST equal the derived pubkey before the provider + no-ops against it, deletes it, mutates its Secret, or returns its name + - annotation `buzz.block.xyz/create-intent: ` — the + recorded create intent (§Deploy State Machine, create-intent + fingerprint), written at pod create; the divergence discriminator for + never-started pods + - Secret name: `buzz-agent--`, where `` is a random + per-create-attempt **generation token** — unique, never reused, carrying + the same labels (identity + management marker) and annotation. The + pod's `envFrom` references this exact + Secret name. Deterministic pod name + unique Secret name is what makes + payload and Secret atomic at the pod-spec boundary (§K8s Secrets) +- **Deletion semantics the reconciler must respect.** A Kubernetes `DELETE` + returns success immediately while the object still exists; the name stays + taken until the kubelet finishes the grace period. Two consequences: + (a) a pod being gracefully deleted has `deletionTimestamp` set but remains + in phase `Running` — the reconciler MUST check the deletion mark before + phase (there is no `Terminating` phase to match on); (b) after deleting a + live pod, a naive immediate create gets AlreadyExists for up to the full + grace period — the reconciler MUST poll for actual disappearance (GET → + 404) before creating. The delete call MUST use the object's own grace + period (kube-rs: `DeleteParams { grace_period_seconds: None, .. }`); the + tempting shortcut of passing `0` to skip the poll is a **force-kill** that + discards the 60s shutdown grace pinned below — the poll is mandatory + precisely because the fast path is wrong. For *terminal* + (Succeeded/Failed) pods — and for **unscheduled** pods (no assigned node: + unschedulable or quota-blocked `Pending`, a state users hit while setting + up a namespace) — the apiserver + zeroes the grace period and deletes immediately, so the normal restart + path needs no meaningful wait — do not add a fixed sleep, and do not use + zero-grace cleanup as a reason to skip the poll in the live-pod arm. +- **`terminationGracePeriodSeconds: 60` — a declared budget the harness + MUST honor, not a sum the spec derived.** Kubernetes' default 30s grace + would SIGKILL the harness mid-drain, leaving presence stale-online — the + avoidable half of I3's staleness window — so the binding declares 60s. + But the shutdown tail is *variable*, not constant (§Stop: the post-drain + reap segment alone reaches ~87s at default parallelism at `b4f4ed1a6`, + and earlier untimed segments precede it — the total is not bounded by + today's segment timeouts), so no fixed grace can be proven + sufficient by adding segment timeouts. The two halves of the requirement: + the binding *declares* the budget here, and the harness *enforces* it — + one shared deadline across the entire post-signal path, with a reserved + finalization slice (≥ the finalizers' declared bounds, currently 7s) for + presence `offline` and relay close, child cleanup + degrading first (§Stop, Known Defect 7). Until the harness enforcement + lands, 60s is an operational margin that the tail can exceed. +- **Hardening defaults (normative).** The workload is a prompted coding + agent running repository and tool code while holding an nsec; the pod MUST + NOT hand it ambient cluster credentials or kernel privilege on top: + `automountServiceAccountToken: false` (Kubernetes mounts a ServiceAccount + token unless told otherwise — an API-stealable credential the agent never + needs), `runAsNonRoot: true` with a fixed nonzero UID/GID, + `allowPrivilegeEscalation: false`, capabilities drop-all, + `seccompProfile.type: RuntimeDefault`; never privileged, `hostPID`, + `hostNetwork`, or `hostPath`. `readOnlyRootFilesystem` is *not* required + in v1 — the sprig toolchain writes outside the workspace mount — but is a + named candidate once the image's write surface is mapped. The + `service_account` config field selects an identity for scheduling/RBAC + purposes only; it MUST NOT silently re-enable token mounting — API-token + access, if ever wanted, is a separate explicit opt-in, not a side effect + of naming an SA. +- **Resources**: requests 1 cpu / 2Gi, limits 2 cpu / 4Gi, all four + configurable (`cargo build` in an agent workspace makes 500m/1Gi requests + unrealistic). +- **Workspace**: `emptyDir`. Checkouts and scratch die with the pod; agent + memory is relay-persisted (NIP-AE) and unaffected. PVC support is a + deferred knob. [DECISION A — how remote pods get the nest workspace + (AGENTS.md etc.) that local agents get from the desktop's `ensure_nest`; + current recommendation is a desktop-stated protocol field, not + image-side scaffolding — §Open Decisions.] + +### Secrets {#k8s-secrets} + +Per-agent `Secret` containing the identity variables (built from top-level +payload fields per the reserved-key rule) plus `env_vars`; consumed via +`envFrom`. + +**Secret creation is per-attempt, immutable, and uniquely named** +(`buzz-agent--`, §Pod shape). The rationale is a +concurrency race a deterministic shared Secret name cannot survive: two +concurrent deploys carrying *different* payloads would both write the shared +Secret, the loser's write could land last, and the winner's pod — +deterministic name, winner's spec — would resolve the **loser's** +identity/config through `envFrom`. The losing caller would have mutated the +winning generation despite strict no-op. Unique names close this: each +create attempt writes its own Secret first, then attempts the deterministic +pod create with a spec referencing exactly that Secret. Pod creation elects +the winner; payload and Secret are atomic at the pod-spec boundary, with no +Lease or CAS machinery. + +Lifecycle rules that follow: + +- **Winner**: pod + its referenced Secret live together; GC deletes them + together. +- **Losing contender** (create-conflict): annotation-verify the winning pod, + return its `agent_id` per the convergence rule, and delete **only its own + now-unreferenced Secret** — never the winner's, never any Secret + referenced by an *existing* pod. "Existing" deliberately includes + not-yet-started pods: an `envFrom` reference from a pod still pulling its + image is exactly as load-bearing as one from a running pod. +- **Live no-op arm**: no Secret is written at all (zero mutation). +- **GC**: also deletes annotation-verified **orphan Secrets** — those whose + generation token no existing pod references — covering contenders that + crashed between Secret create and their conflict cleanup. But only when + **age-eligible**: see the normative age gate in §K8s GC — "unreferenced" + is not "orphaned" while a concurrent attempt may still be between its + Secret create and its pod create. + +Fresh configuration therefore materializes exactly when a fresh generation +does. Residual exposure, stated: any principal with +pod-exec or secret-read in the namespace can read the nsec. This is the +substrate-security boundary from §Non-Goals — the namespace is the isolation +unit, and users deploying to shared namespaces accept its ambient RBAC. The +in-pod narrowing that sprig's dev-MCP shim performs (strips the key from its +own env, re-materializes as a 0600 keyfile for the git helpers) limits +accidental leakage into subprocess environments, not hostile cluster access. + +### Garbage collection {#k8s-gc} + +A **generation** is one pod-create attempt and the uniquely-named Secret it +references; the Secret's generation token is the generation's identity, and +the *current* generation is the one referenced by the existing pod's +`envFrom`. + +GC is a **preflight reconciliation pass**, not a post-deploy afterthought: +on every deploy, after identity derivation and before the state transition, +the provider deletes terminated pods (and their referenced Secrets) that +match the pubkey label, **pass the full-pubkey annotation check, and carry +the management marker** (§Pod shape; the auto-repair fence applies to GC +identically), plus +annotation-verified, marker-bearing orphan Secrets whose generation token +no existing pod +references (§K8s Secrets). It never touches the current generation. +Mismatched annotations are never GC'd (§Deploy State Machine step 1), and +an unmarked object is never GC'd regardless of its labels. + +**Orphan-Secret age gate (normative).** An unreferenced Secret is +GC-eligible only when its server-assigned `creationTimestamp` is older than +**twice the deploy operation deadline** (2 × 600s). Rationale — without the +gate, GC composes with per-attempt Secrets into a legal interleaving that +strands a deploy: attempt A creates Secret A; concurrent attempt B runs its +preflight GC *before A creates its pod*, sees Secret A unreferenced, and +deletes it as an "orphan"; A's pod is then accepted referencing a missing +Secret and sits in `CreateContainerConfigError` until a later deploy +repairs it by delete-recreate (§Deploy State Machine never-started rows) — +a stranded deploy either way. Unique Secret +names made payload↔Secret atomic *at the pod-spec boundary*, but +Secret-create→pod-create is not atomic against an independent GC pass — +the standard controller lesson that observations may be stale and +reconciliation must tolerate in-flight peers. The age bound makes +"unreferenced" mean "provably abandoned": any attempt that could still +reference the Secret has exceeded its own deadline. A losing contender's +immediate cleanup of **its own** Secret is exempt — ownership, not age, is +its safety argument. (A Lease per agent identity would also close this +race; the age gate achieves the same with no extra machinery.) + +**Same-clock rule (normative).** The age comparison has two operands and +both MUST come from the apiserver's clock. `creationTimestamp` is +server-assigned; the comparison instant MUST be derived from the HTTP +`Date` response header on the very list/get call the GC pass performs +(RFC 9110 §6.6.1 — origin-server message-origination time), never from the +provider's local `now()`. The provider runs on a user's desktop, and a +local clock fast by more than the margin doesn't *race* — it +deterministically computes every in-flight Secret as expired and deletes +them all, silently, on every pass, reopening exactly the interleaving the +gate exists to close. With both operands from one clock, skew cancels. +(`kube`'s `Client::send` returns the raw `http::Response` with headers, so +this costs one header read, not a departure from the typed API.) If the +`Date` header is absent or unparseable, the provider MUST **skip +orphan-Secret GC for that pass** — never fall back to local time. A +deferred cleanup is free; a wrong deletion is not. + +**Alternative considered — `ownerReferences`, omitted in v1.** Kubernetes' +native GC (a Secret owned by its attempt's Pod is deleted when the owner is +verified absent) cannot *replace* the age gate: an ownerReference needs the +owner's UID, which exists only after pod create, so primary reliance on it +would flip the ordering to Pod-first-then-Secret. The reason that flip +loses is **diagnostics, not repairability**: a never-started winner is +recoverable (the kubelet retries a config-failed container indefinitely, +and the amended no-op rule lets a later deploy delete-and-recreate it with +its own payload), but Pod-first routes *every healthy deploy* through +`CreateContainerConfigError` — the exact condition the startup classifier +treats as an actionable failure signal — so the classifier could no longer +believe that reason without waiting out the deadline, on every deploy. +That trades away normative diagnostics for a cleanup the age gate already +provides. A *supplementary* post-create attachment (patch the Secret with +the winning pod's UID; Secret metadata stays patchable when `immutable` and +`data` are untouched) is sound but adds no required property: the pre-pod +crash window still needs the age-gated sweep as backstop, so v1 omits it +under the complexity budget. Any future implementation that adds it MUST +set `blockOwnerDeletion: false` explicitly (true requires `update` on +`pods/finalizers` — an RBAC verb nothing else here needs — and a Secret +should never delay its pod's deletion), MUST keep owner and dependent in +the same namespace (a cross-namespace owner is treated as *absent*, turning +the safety net into an immediate-delete instruction), and MUST treat +attachment failure as non-fatal cleanup, never a deploy error. + +Running GC first +gives concurrency and Secret ownership one unambiguous order: reconcile +always observes a world with at most one candidate generation *older than +the gate*. Completed +pods from the *current* generation are left in place — their logs are the +only forensics M1 permits. That forensic window is deliberately fragile: +next-deploy GC, node loss, or namespace deletion erases it, and M1 means +there is no log operation to reach for. **Cluster-native log shipping is +therefore a production prerequisite, not an optional nicety** — the +ephemeral-runner lesson: disposable generations still need durable +diagnostics, forwarded off the pod by the cluster operator's stack. The +binding's contribution is correlation, not transport: the pod carries the +full-pubkey annotation, the generation token (doubling as +`BUZZ_MANAGED_AGENT_START_NONCE`, so lifecycle frames and pod logs share a +correlator), the provider version, and the resolved image reference +(§Image) — enough to attribute any shipped log line to an exact identity, +generation, and binary, with no secret in any of it. GC on next-deploy +also self-heals the missing +`undeploy`: delete-then-recreate converges, and a deleted-forever agent's +residue is one Completed pod that never restarts (I5) plus one Secret, +removable with `kubectl delete`. + +### `provider_config` v1 fields + +`context`, `namespace`, `image`, `cpu_request`, `memory_request`, +`cpu_limit`, `memory_limit`, `inactivity_seconds`, `service_account` — +9 of the 20-field validation cap. Node selectors, tolerations, and PVCs are +deliberately baked out of v1 to preserve budget. + +### Distribution + +Its own release workflow (macOS arm64/x64 + Linux musl; the sprig workflow's +ubuntu × musl matrix cannot produce the laptop-side binary), artifacts +attached to releases, installed to `~/.local/bin` (already on the discovery +path). v1 ships no Windows binary [DECISION B]; desktop bundling into the +.app (discovery already prepends the bundle dir) is deferred [DECISION D]. + +## Conformance + +Obligations are split by layer per §Launchers: the **[L1] agent/harness +contract** binds every launcher; the **[L2] provider/deployer contract** +binds provider-managed launches; the **[L3] binding policy** here is the +Kubernetes binding's own. A non-provider launcher (bash script, systemd +unit) owes only the L1 items; a provider on a different substrate owes +L1 + L2 and writes its own L3 realization of the generic L3 property. + +### [L1] Launcher conformance — every launcher + +A launcher — desktop, provider-deployed pod, systemd unit, bash script — +is conforming iff: + +1. It launches the harness with a **valid, nonempty identity**: a + parseable private key, a relay URL, and an auth tag or resolved owner + pubkey — refusing to launch rather than launching identityless (I1's + property, enforced wherever the env is assembled). +2. It does not suppress the harness's promises on a remote agent: + presence stays enabled (`BUZZ_ACP_NO_PRESENCE` never set — remotely, + presence is the only signal, I3), and the inactivity knob + (`BUZZ_ACP_EXIT_AFTER_INACTIVITY`) carries the owner's *deliberate* + lifetime policy, never an accidental passthrough of user env (I5; the + reserved-key rule is the provider path's realization of this). +3. The substrate's **termination signal reaches the harness process**, + with enough grace for its full graceful shutdown before force-kill (I3 + staleness minimization). "Allows" is not enough — a wrapper that + swallows the signal conforms to nothing. +4. Intentional termination (owner `!shutdown`, inactivity reap) exits + through the harness's graceful path under the **pinned clean-exit + contract** (intentional exit ⇒ exit code 0 — Known Defect 6 until the + contract lands). +5. Any supervisor the launcher configures **never restarts an intentional + clean exit** (I5). `Restart=always` and equivalents are non-conforming + at this layer no matter what the substrate calls them. + +### [L2] Provider conformance — provider-managed launches + +A provider is conforming iff, in addition to deploying only L1-conforming +invocations: + +1. `info` and `deploy` implement the wire contract (§Provider + Protocol), including one-JSON-in/one-JSON-out and in-band + `{"ok": false}` errors. **Exit codes carry exactly one bit** — zero = + the operation's output is trustworthy, nonzero = failure regardless of + stdout (§Invocation's rule restated from the provider's side): a + provider MUST exit nonzero on any crash path and MUST NOT encode + structured meaning in nonzero values, because `D` discards partial + output rather than interpreting codes. +2. It never requests or accepts credentials through + `provider_config` (I2). +3. It builds agent identity env from top-level payload fields, never + from `env_vars` (reserved-key rule), applies §Launch data mechanically — + three-tier precedence, host-resolved re-derivation, no re-merge of + legacy `env_vars`, no provider-side model/provider mapping — and refuses + a deploy that resolves neither `auth_tag` nor `launch.owner_pubkey`, or + whose provider is `relay-mesh`. +4. `deploy` implements the reconciliation loop (I4), stated + substrate-neutrally: identity derived from the nsec before any + mutation; candidates verified by **full-identity evidence** before any + action; live (= **started**: the harness process confirmed running, not + merely the body accepted) → strict no-op (zero mutation); never-started + states classified by evidence, not by substrate status strings + (provably-broken → fenced replace; recoverable + same recorded create + intent → observe, never delete, on this call or any later one; + recoverable + divergent intent → fenced replace); every read that can + authorize a destruction uses most-recent semantics; every destructive + write is **fenced to the exact observation that authorized it** + (compare-and-delete — the write fails if the object changed since the + read) and touches only objects carrying the provider's **management + marker** (the auto-repair fence, §Deploy State Machine); + same-status-code conflicts discriminated by a **machine-readable + conflict discriminator**, never the status code alone; success only on + confirmed harness start; conflicts converge by re-entry; + delete-of-absent is success. +5. It emits no secret material in any output (belt to `D`'s + redaction suspenders). +6. **Generic L3 obligation:** its binding *documents* how it realizes each + L2 term on its substrate, and how the owner's lifetime policy (bounded + vs indefinite) and clean-exit restart behavior are realized there — + stating the properties in its own vocabulary, not skipping them. + +### [L3] Kubernetes binding conformance — this binding + +The realization the two lists above require, in this binding's vocabulary: + +1. Each L2 item-4 term maps to the mechanism in §Deploy State Machine: + full-pubkey annotation for identity evidence, container `state.running` + for "started", `resourceVersion`-unset quorum reads for most-recent + semantics, UID+`resourceVersion` delete preconditions for fencing, + `Status.reason` as the 409 discriminator, and the + `app.kubernetes.io/managed-by` + binding-version labels as the + management marker. +2. The deployed invocation realizes the lifetime policy the owner chose + (I5) through this binding's `inactivity_seconds` field: `> 0` → + a working inactivity bound and `restartPolicy: Never`; `0` (the + blessed indefinite opt-in) → no bound and `restartPolicy: OnFailure`, + **only after both prerequisites land** — the pinned exit-code contract + (I5 ordering rule) *and* the crash-loop classification row (§Pod + shape); until then the provider MUST refuse the combination. +3. The harness is the deployed container's **signal-receiving process** + (PID 1 or the target of the pod's termination signal — §K8s + Entrypoint's `exec` rule), and `terminationGracePeriodSeconds` carries + the declared grace budget (§Pod shape). + +Conformance is testable without mechanization: a fake-provider harness can +exercise L2 items 1–3 and 5 over the wire contract — including the pre-secret +negotiation gate (§Discovery): an incompatible **or absent** +`protocol_version` MUST be rejected before any request carrying +`private_key_nsec` is sent; a **same-inode content rewrite** of the +resolved binary after resolution MUST NOT reach the deploy invocation +(the staged artifact still carries the bytes that answered `info`); and a +**pathname swap after validation** — the resolved path re-pointed at a +different file between the gate's checks and process spawn — likewise +MUST NOT redirect the nsec (both cases are exactly what path+metadata +comparison misses) — and an envtest/kind suite +can drive L2 item 4's reconciler against a real apiserver — concurrent +deploys, a deletion-marked pod, terminal restart, an annotation-mismatch +collision, and SIGTERM→presence-offline for the L3 items. Three families of +cases are mandatory because they were the review-found failure modes: +**startup discrimination** (slow-but-valid scheduling → poll-then-succeed; +`Unschedulable` during scale-from-zero → observed until the autoscaler +provisions capacity, then success — never delete, **including when +provisioning completes only after the 600s deadline**: the original pod +identity and `creationTimestamp` survive the expired call and become the +no-op winner on a later deploy, the case that pins the anti-livelock rule; +a label-and-annotation-matching object **without the management marker** → +never deleted, never GC'd, reported (the auto-repair fence under test); +referenced Secret *confirmed absent* → preconditioned delete-recreate or +actionable error, never silent success or no-op; a **never-started winner +is repairable** — pod exists, Secret absent, container never started: a +later deploy MUST delete-and-recreate rather than no-op, the test that pins +started-not-phase as the no-op criterion; and the **classification→DELETE +race** — the container transitions to running between the classifying read +and the delete: the UID+resourceVersion precondition MUST fail and the +live agent MUST be preserved) and the **GC/attempt interleaving** (attempt B's +preflight GC running between attempt A's Secret create and pod create MUST +NOT delete Secret A — the §K8s GC age gate under test; provider death after +Secret create → the age gate protects, then a later GC reaps; and a +**provider local clock fast beyond the margin** MUST NOT delete an +in-flight Secret — cheap with a fake clock, and the same-clock rule's +skip-on-absent-`Date` arm is exercised by stripping the header). A third +family pins the **divergence discriminator and the 409 split**: a failed +delete precondition (code 409, reason `Conflict`) → re-read and +re-classify, never the create-conflict cleanup/adoption path; a create +conflict (code 409, reason `AlreadyExists`) → loser-Secret cleanup and +winner adoption, never treated as a failed delete; identical desired +intent + permanently-Pending pod → no delete across arbitrarily many +Starts, regardless of age; a resource/image correction against a +never-started pod → fingerprint differs, preconditioned +delete-and-recreate (the wedge-escape case); same user config but the +provider's **baked default image digest** changed (provider upgrade) +against a never-started pod → divergence, replace (the second intent +source — the only escape from a bad-default-image wedge); the same correction against +a **started** pod → strict zero-mutation no-op; admission +defaulting/mutating the live pod → no false divergence (the comparison +uses the recorded annotation); the fingerprint serializer property, +asserted structurally — changing only Secret *values* or the generated +Secret *name* leaves the fingerprint unchanged, changing any +fingerprinted pod-create field changes it (equivalently: the serializer +has no access to Secret data or attempt identity); and the +conflict-path asymmetry — two no-instance contenders with different +payloads: the create loser adopts the elected winner rather than +deleting it for divergence, while a subsequent deploy observing that +never-started divergent winner replaces it. A model checker is +the wrong tool here: the failure modes found in review were wrong +*abstractions of Kubernetes* (a nonexistent `Terminating` phase, non-atomic +delete, phase-as-readiness, non-atomic Secret→pod against GC), which a +hand-written model would have reproduced convincingly. + +## Known Defects (at `c1bca1b56`) + +**Citation-pin caveat:** `c1bca1b56` is an unmerged feature-branch commit +that diverged from main on Jul 18 and predates #3038 (default parallelism +24 → 10). Line references marked `at b4f4ed1a6` were re-verified against +this PR's own base; unmarked `c1bca1b56` references may be offset on +current main. A follow-up re-pins the whole document to one merged +commit. + +Desktop- and harness-side, discovered during this design: + +1. **Windows discovery id pollution**: the `.exe` suffix survives into the + provider id, which then fails id validation at deploy — dropdown-visible, + probe-fine, deploy-broken. Fix is a suffix strip in discovery. (v1 + provider scope is macOS+Linux regardless — [DECISION B].) +2. **Provider env inheritance**: `invoke_provider` passes the desktop's + environment through unmodified; combined with launchd's minimal PATH this + breaks kubeconfig exec plugins. Mitigated provider-side (§K8s Auth); + a desktop-side PATH augmentation would fix the class. +3. **Deploy payload bypasses the launch resolver** (the prerequisite this + spec names for §Launch data — a desktop code change, not spec text). + At `c1bca1b56`, `deploy_payload_json` serializes raw record bytes and a + three-layer `merged_user_env` where the local spawn uses + `resolve_effective_harness_descriptor`'s six-layer resolution. Concrete + consequences, each verified in review: (a) no per-runtime model/provider + env — a remote goose agent silently ignores the user's model choice, and + `provider_locked` runtimes would receive vars the desktop deliberately + withholds; (b) persona-derived `agent_command` and definition-provided + `agent_args` serialize as blank/empty — a different command line than the + identical local agent; (c) no `owner_pubkey` — a null-`auth_tag` agent + cannot match `!shutdown` (it *answers* it), stranding §Stop; (d) spawn + policy (`BUZZ_ACP_RELAY_OBSERVER`, runtime `default_env` such as + `GOOSE_MODE=auto`, team instructions, session title, lazy-pool selection) + is absent — remote pods run different observer/approval semantics + (`BUZZ_ACP_DEDUP`/`BUZZ_ACP_MULTIPLE_EVENT_HANDLING` are *not* on this + list: the local writes match the harness defaults, §Launch data); (e) a + mesh-provider agent deploys pointed at a loopback URL that cannot exist + in the pod instead of being refused. Until `deploy_payload_json` emits + the `launch` block, no provider can conform to §Launch data, and the + current payload MUST be treated as insufficient for a + semantics-preserving remote launch. **Security follow-through:** once + secrets can arrive via `launch.env`, desktop redaction MUST collect + candidate values from `launch.env` (and `launch.policy_env`) as well as + legacy `agent.env_vars` — at `c1bca1b56`, `env_secrets_from_request` + reads only `agent.env_vars` (`backend.rs`), leaving a + definition/persona-layer secret outside the literal-value scrub. + Conformance: a provider that echoes a launch-only secret into an error + must come back redacted. +4. **The I5 reaper does not exist, and its natural home is a trap** + (harness code prerequisite). `BUZZ_ACP_EXIT_AFTER_INACTIVITY` appears + nowhere in the harness at `c1bca1b56`; §Auto-Stop is a design, not a + description. Worse, the obvious attachment point — the existing 30s + maintenance tick — is gated on `pool_ready` (`lib.rs:1743`), which under + `lazy_pool` only becomes true when work arrives, so a never-mentioned + lazy pod would never evaluate the bound: I5 dead in its most important + case (§Auto-Stop mechanism rule). The implementation MUST run the expiry + check on a pool-independent timer and MUST add the env var to + `RESERVED_ENV_KEYS` in the same change. +5. **The deploy path never checks `protocol_version`** (desktop code + prerequisite). `provider_deploy` (`backend.rs`) sends the nsec-bearing + `deploy` request without any preceding `info` on the same resolved + executable; §Discovery's pre-secret negotiation gate is a design, not a + description, until the deploy command performs + resolve-once → stage-and-digest → `info` → explicit-version check → + `deploy`, both invocations running the staged bytes. +6. **The clean-exit contract is emergent, not defended** (harness code + prerequisite; gates `OnFailure`). At `b4f4ed1a6`: the graceful path + returns `Ok(())` (`lib.rs:2723`), and owner `!shutdown` (`:2045`), + Ctrl-C (`:1635`), and + SIGTERM (`:1644`) all route into the same shutdown channel — so clean + stops exit 0 *today*, but no distinguished exit code exists and no test + pins "intentional exit ⇒ 0"; every `process::exit(1)` in the crate is a + startup failure. Until a + pinned, tested exit-code contract lands, no supervisor restart policy + (`restartPolicy: OnFailure`, systemd `Restart=on-failure`) may be + deployed against the harness: a refactor returning `Err` from a drain + timeout would silently convert every clean stop into a restart loop — + I5 defeated with no failing test (I5 ordering rule). +7. **The shutdown tail overruns the declared grace budget at default + config** (harness code prerequisite). At `b4f4ed1a6`: the post-drain + reap segment + (`lib.rs:2664-2688`) runs *after* the 30s drain timeout closes + (`:2636,:2657`) and serially awaits a 5s post-SIGKILL wait per occupied + slot (`acp.rs:436`) — that segment alone reaches ~87s at the desktop's + default parallelism of 10 (`types.rs:809`; #3038 lowered it from 24), + ~197s at the harness cap of 32 (`config.rs:293`), against the binding's + 60s grace; and it is not the whole tail — the wake-task drain + (`:2612`) and awakened-pool shutdown (`:2620-2624`, per-slot loop + `:3747-3751`, no timeout) precede it (§Stop), so the total is + unbounded by today's segment timeouts. The fix is one shared deadline + across the entire post-signal + path with a reserved finalization slice (≥ the finalizers' declared + bounds, currently 2s presence + 5s relay close = 7s) for presence + `offline` and + relay close, child cleanup degrading first (§Stop); natural home is the + same harness change as the I5 reaper (defect 4). +8. **Cleared numeric config fields ship as strings** (desktop code + prerequisite, raised by blessing `0`). `coerceConfigValues` + (`desktop/src/features/agents/ui/ProviderConfigFields.tsx:6` at + `b4f4ed1a6`) skips numeric coercion when the value is `""`, so a + *cleared* numeric field reaches the provider as a JSON string instead + of a number. Blessing `inactivity_seconds: 0` makes clearing that + field a legitimate user action, so the empty-string arm now sits on a + documented path: the provider receives `""` where the schema says + integer, and "0 MUST NOT be rejected" cannot protect a value that + never parses as 0. Fix is desktop-side (map cleared numeric → + omit-or-default, never `""`); provider-side, a non-numeric value for a + numeric field is an in-band error, not a silent default. + +## Implementation Correspondence + +| spec concept | code | +|---|---| +| Discovery, resolution rule | `desktop/src-tauri/src/managed_agents/backend.rs` (`discover_provider_candidates`, `resolve_provider_binary`) | +| Invocation, output caps, exit rule | `backend.rs` (`invoke_provider`) | +| Pre-secret negotiation gate | *to be added*: `backend.rs` deploy path — resolve-once → stage-and-digest → `info` → explicit-version check → `deploy` on the staged bytes (Known Defect 5) | +| Redaction | `backend.rs` (`redact_secrets_with`) | +| I2 validation | `backend.rs` (`validate_provider_config`) | +| I1 refusal, payload | `desktop/src-tauri/src/commands/agents_deploy.rs` | +| Launch resolver (shared with local spawn) | `desktop/src-tauri/src/managed_agents/readiness.rs` (`resolve_effective_harness_descriptor`); `launch` block emission *to be added* to `agents_deploy.rs` (Known Defect 3) | +| Mesh rewrite (why relay-mesh is non-deployable) | `desktop/src-tauri/src/managed_agents/relay_mesh.rs`; create-time rejection in `commands/agents.rs` (`normalize_relay_mesh`) | +| Reserved-key strip | `desktop/src-tauri/src/managed_agents/env_vars.rs` (`RESERVED_ENV_KEYS`) | +| Unconditional deploy on Start | `desktop/src-tauri/src/commands/agents.rs` (`start_managed_agent`) | +| Presence publish / offline-on-exit | `crates/buzz-acp/src/lib.rs` (`publish_presence`, shutdown path) | +| `!shutdown` owner check | `crates/buzz-acp/src/lib.rs` (main loop) | +| Graceful shutdown path (budget enforcement *to be added* — Known Defect 7) | `crates/buzz-acp/src/lib.rs` (pool shutdown, then drain / reap / presence / relay close) | +| Clean-exit exit-code contract | *to be added*: `crates/buzz-acp` distinguished exit codes + pinning test (Known Defect 6; gates `OnFailure`) | +| Auto-stop flag | *to be added*: `crates/buzz-acp/src/config.rs` + a pool-independent timer (NOT the `pool_ready`-gated maintenance tick — Known Defect 4) + `RESERVED_ENV_KEYS` entry | +| Kubernetes binding | *to be added*: `crates/buzz-backend-kubernetes` | +| Sprig image | *to be added*: `Dockerfile.sprig` + workflow | + +## Open Decisions + +Marked `[DECISION]` inline; consolidated: + +- **A. Nest scaffolding** — should the image entrypoint scaffold the agent + workspace (AGENTS.md, RESEARCH/, …) that the desktop's `ensure_nest` + provides locally? Recommendation (revised): **workspace becomes a + protocol field the desktop states**, not an image-baked behavior — the + desktop resolves the nest content it would have written locally and + carries it in the launch data, so every substrate materializes the same + workspace from the same source of truth and the image stays + scaffold-free. An image-side template crate was the earlier + recommendation; it loses because it forks the nest definition into a + second implementation that drifts from `ensure_nest`. +- **B. Windows scope** — fix the `.exe` discovery bug in the desktop now; + ship Windows provider binaries only on demand. Recommended as stated. +- **C. Config budget** — the 9-field v1 set above. Recommended as stated. +- **D. Desktop bundling** — `~/.local/bin` install only for v1. Recommended + as stated. +- **E. Running-pod semantics** — no-op (recommended, both reviewers) vs + forcible recycle on Start. Ruled: **per-binding policy**, with one + universal property every binding must preserve — no sequence of Starts + yields two live instances in one scope (I4). The Kubernetes binding + keeps strict no-op in v1; a recycle affordance, if a binding adds one, + is stop-then-start, never delete-under-a-live-agent. +- **F. Mesh deployability** — the spec refuses relay-mesh agents + pre-mutation in v1 (§Launch data: the transport is desktop loopback; + serializing it fails identically but invisibly). Reviewer consensus is + refusal; ratification requested because it makes a visible product cut + (shared-compute agents are local-only until an in-image mesh client + exists). +- **G. Remote override semantics** — the spec keeps local semantics: user + env continues to beat Buzz behavior defaults remotely (three-tier + precedence, §Launch data), because the alternative is a quiet behavior + fork between local and remote spawns of the same record. Flagged because + it is a policy statement about what power users may do to remote pods. +- **H. Startup budget** — with deploy success now requiring container start + (§Deploy State Machine), the 600s operation deadline is the de facto + cold-pull / scale-from-zero budget. The spec fixes the semantics + narrowly: the deadline bounds how long one Start waits synchronously — + never when anything is destroyed (recoverable startup is observational + across calls, so a cluster whose autoscaler `new-pod-scale-up-delay` + exceeds 600s degrades to "Start reports unconfirmed, a later Start + adopts the now-running pod", not a livelock). The remaining SLO ruling + is UX-only: is ten minutes of synchronous waiting the right ceiling for + the intended cluster class? +- **I. Never-started escape hatch** — the create-intent fingerprint + (§Deploy State Machine) lets a config *change* replace a never-started + pod, closing the config wedge. Ruled on the vision-consistency half: + Start-time auto-repair of never-started bodies is legitimate, **fenced + to Buzz-authored, positively identified residue** (§Deploy State Machine + auto-repair rule) — the vision's "never-started body is operator + residue" line gains that qualifier rather than being waived. The + remaining product question: does v1 owe users an explicit in-product + "clear this stuck deployment" affordance for a never-started pod whose + config they have *not* changed (a genuinely slow or broken cluster)? + Both reviewers agree on the mechanism; this is the remaining product + question layered on top of it. + +## Summary + +Remote agents extend Buzz's managed-agent model across a deliberately thin +boundary: one untrusted binary, two JSON operations, and a relay. The +desktop's obligations end at a well-formed, fail-closed deploy payload; the +provider's obligations are convergence and honesty about state; the agent's +obligation is to honor its owner's lifetime choice — bounded by default, +indefinite by declaration, and in either case final when told to stop. +Everything else — status, control, +memory — was already on the relay, which is why the design holds: the relay +was the management plane all along, and the desktop was only ever one of +its doors. From b7bb15122e8a2053b545dc2210afc167f6c7a626 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 2 Aug 2026 12:48:49 -0400 Subject: [PATCH 003/163] feat(projects): add buzz projects CLI commands (NIP-MP kind:30621) (#4020) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the `buzz projects` command group — the NIP-MP Phase 2 write path for kind:30621 multi-repo projects. The relay accepted kind:30621 in #3171; this adds the two-layer Rust builder in `buzz-sdk` and the seven CLI commands. ## What this adds ### `crates/buzz-sdk/src/builders.rs` — two-layer builder **Layer A (protocol):** - `validate_project_envelope(tags, content)` — 8 NIP-MP rules in relay order: `d`-cardinality, `d`-empty/length, member-cap (≤64 `a` tags, checked before per-tag parse), member-tag-arity (2–3 elements), member-coordinate grammar (first-two-colons split, literal `30617`, lowercase 64-hex owner, non-empty remainder), member-duplicate (coordinate only, hint ignored), singleton metadata cardinality, byte bounds (`name` ≤256 / `description` ≤2048 / `buzz-channel` ≤256 / `buzz-visibility` ≤256). - `build_project_with_tags(content, tags)` — raw Layer A builder; RMW mutations path. - `ProjectMemberCoord` — `30617::` + optional opaque relay hint; equality/Hash by coordinate only. **Layer B (writer policy):** - `build_project(slug, name, description, members, channel, visibility)` — constructs `d` tag, enforces UUID channel and `listed|unlisted` visibility, forces empty content; composes onto Layer A. This is the `create` path. **Shared:** - `build_delete_addressable(kind, pubkey, d)` — generic NIP-09 kind:5 coordinate delete; `build_workflow_delete` now delegates to this. - All 31 `NIP-MP.fixtures.json` cases exercised through `build_project_with_tags`; count assertion guards against omissions. ### `crates/buzz-cli/` — seven commands ``` buzz projects create --repo [--name] [--description] [--channel ] [--visibility listed|unlisted] buzz projects get [--owner ] buzz projects list [--owner ] [--limit ] buzz projects add-repo --repo [--repo ]... buzz projects remove-repo --repo [--repo ]... buzz projects update [--name|--clear-name] [--description|--clear-description] [--channel |--clear-channel] [--visibility listed|unlisted|--clear-visibility] buzz projects delete ``` Command semantics: - **`create`**: all local validation (slug, repos, channel, visibility, name length) fires before the collision preflight — invalid input returns `Usage` without a network call. Routes through Layer B (`build_project`). - **`update`**: at least one setter/clearer required — enforced by a clap `ArgGroup` with `required(true).multiple(true)`, with a runtime backstop for programmatic callers; setter + own clearer are mutually exclusive per clap conflicts. - **`add-repo`/`remove-repo`**: coordinate expansion and dedup fire before head fetch — malformed or duplicate `--repo` values return `Usage` without touching the relay. - **`delete`**: head-based tombstone at `created_at = head + 1`; post-submit re-query verifies tombstone landed. - All mutations: strip `auth`, re-validate full envelope through Layer A; `created_at` advances from observed head, never wall-clock. - Relay hints on existing member tags preserved verbatim through RMW. ## Limitations (recorded, not in scope) - **No relay-hint authoring**: `--repo` carries a coordinate only; existing hinted `a` tags survive RMW unchanged. - **Signer-self delete only**: NIP-OA owner-delete extension not exposed; `delete` targets the signer's own coordinate. - **Deletion durability**: watermark carry-over applies; `delete` is best-effort against a later-arriving replacement. ## Live round-trip 21-step transcript executed against a relay built from `origin/main` `b1b283cd4`, covering create, get, multi-field update (name + description + channel in one call), channel set/clear, add-repo, remove-repo, delete (tombstone verified at `head+1`, repeated delete → `NotFound`). Delta transcript confirmed multi-field update, channel set/clear, no-op add-repo → `Conflict` exit 5, empty update and setter+own-clearer both rejected at parse time. Duplicate create → `Conflict`. Cross-owner `add-repo` with full coordinate exercised. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- crates/buzz-cli/src/commands/mod.rs | 28 + crates/buzz-cli/src/commands/projects.rs | 1198 ++++++++++++++++++++++ crates/buzz-cli/src/commands/repos.rs | 26 +- crates/buzz-cli/src/lib.rs | 241 +++++ crates/buzz-sdk/src/builders.rs | 645 +++++++++++- 5 files changed, 2110 insertions(+), 28 deletions(-) create mode 100644 crates/buzz-cli/src/commands/projects.rs diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index 86915906360..1ccc37a7027 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -12,9 +12,37 @@ pub mod notes; pub mod pack; pub mod patches; pub mod pr; +pub mod projects; pub mod reactions; pub mod repos; pub mod social; pub mod upload; pub mod users; pub mod workflows; + +use crate::{client::normalize_write_response, error::CliError}; + +/// Parse a relay write-response JSON blob, mapping a duplicate (dominated) +/// write to [`CliError::Conflict`] with the caller-supplied message. +/// +/// Used by every command that publishes an NIP-33 addressable event and +/// needs to tell accepted from duplicate/dominated. +pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result { + let response: serde_json::Value = serde_json::from_str(raw) + .map_err(|e| CliError::Other(format!("relay response is not JSON: {e} ({raw})")))?; + let accepted = response + .get("accepted") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let message = response + .get("message") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + if !accepted { + return Err(CliError::Other(format!("relay rejected event: {message}"))); + } + if message == "duplicate" || message.starts_with("duplicate:") { + return Err(CliError::Conflict(conflict_msg.to_string())); + } + Ok(normalize_write_response(raw)) +} diff --git a/crates/buzz-cli/src/commands/projects.rs b/crates/buzz-cli/src/commands/projects.rs new file mode 100644 index 00000000000..e6798dbfc44 --- /dev/null +++ b/crates/buzz-cli/src/commands/projects.rs @@ -0,0 +1,1198 @@ +//! `buzz projects` commands — NIP-MP kind:30621 write path. +//! +//! All mutations follow a read-modify-write pattern: +//! 1. Fetch the caller's own live head via `kinds:[30621] + authors:[self] + #d:[slug]`. +//! 2. Mutate the tag set (strip `auth`, apply change). +//! 3. Re-validate the full envelope through Layer A before submitting. +//! 4. Set `created_at = head.created_at + 1` (never wall-clock) to avoid +//! overwriting a concurrently advancing head. +//! +//! Limitations recorded in this phase: +//! - Relay hints are read-preserved but not authored (`--repo` carries +//! a coordinate only; existing hinted tags survive RMW unchanged). +//! - `delete` targets signer-self only (NIP-OA owner-delete path deferred). +//! - Deletion durability against later arrival (watermark follow-up) is +//! not in scope. + +use buzz_core::kind::KIND_PROJECT; +use buzz_sdk::{ + build_delete_addressable, build_project, build_project_with_tags, ProjectMemberCoord, + PROJECT_D_MAX_LEN, +}; +use nostr::{Event, EventBuilder, Tag, Timestamp}; + +use crate::client::BuzzClient; +use crate::commands::parse_write_response; +use crate::error::CliError; + +// ── Buzz repo-ID grammar (bare --repo shorthand) ───────────────────────────── + +/// Pattern for a Buzz-hosted repo identifier (bare `--repo` shorthand). +/// `[a-zA-Z0-9._-]{1,64}` — no colons, so guaranteed collision-free with +/// `30617::` full coordinates. +fn is_bare_repo_id(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +/// Expand a CLI `--repo` argument into a full `30617::` coordinate. +/// +/// Bare form (`[a-zA-Z0-9._-]{1,64}`): owner defaults to the caller's pubkey. +/// Full form (`30617::`): used verbatim. +fn expand_repo_coord(s: &str, caller_pubkey: &str) -> Result { + if is_bare_repo_id(s) { + // Bare form: expand to full coordinate with caller as owner. + let full = format!("30617:{caller_pubkey}:{s}"); + ProjectMemberCoord::parse_full(&full) + .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + } else { + // Full form: must be parseable as a complete coordinate. + ProjectMemberCoord::parse_full(s) + .map_err(|e| CliError::Usage(format!("invalid repo coordinate: {e}"))) + } +} + +// ── Head-fetch helper ───────────────────────────────────────────────────────── + +fn parse_events(json: &str) -> Result, CliError> { + serde_json::from_str(json) + .map_err(|e| CliError::Other(format!("failed to parse relay response: {e}"))) +} + +/// Fetch the caller's own live kind:30621 head for `slug`. +async fn fetch_own_project(client: &BuzzClient, slug: &str) -> Result, CliError> { + fetch_project(client, slug, None).await +} + +/// Fetch a project head by slug and optional owner pubkey. +async fn fetch_project( + client: &BuzzClient, + slug: &str, + owner: Option<&str>, +) -> Result, CliError> { + let pubkey = match owner { + Some(pk) => { + crate::validate::validate_hex64(pk)?; + pk.to_string() + } + None => client.keys().public_key().to_hex(), + }; + let filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "authors": [pubkey], + "#d": [slug], + "limit": 1, + }); + let raw = client.query(&filter).await?; + let mut events = parse_events(&raw)?; + events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); + Ok(events.into_iter().next()) +} + +// ── Tag helpers ─────────────────────────────────────────────────────────────── + +fn tag_name(tag: &Tag) -> Option<&str> { + tag.as_slice().first().map(String::as_str) +} + +fn tag_value(tag: &Tag) -> Option<&str> { + tag.as_slice().get(1).map(String::as_str) +} + +fn make_tag(parts: &[&str]) -> Result { + Tag::parse(parts.iter().copied()) + .map_err(|e| CliError::Other(format!("tag construction failed: {e}"))) +} + +// ── Submit helper ───────────────────────────────────────────────────────────── + +async fn submit_project(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { + let event = client.sign_event(builder)?; + let raw = client.submit_event(event).await?; + println!( + "{}", + parse_write_response(&raw, "project changed concurrently; retry")? + ); + Ok(()) +} + +// ── Build helpers ───────────────────────────────────────────────────────────── + +/// Advance the `created_at` counter off an observed head. +fn next_timestamp(head: &Event) -> Result { + head.created_at + .as_secs() + .checked_add(1) + .map(Timestamp::from) + .ok_or_else(|| CliError::Other("project timestamp cannot be advanced".into())) +} + +/// Strip `auth` from a tag list and pass the resulting envelope through +/// Layer A validation. Returns a validated `EventBuilder` at `next_ts`. +fn rebuild_project( + content: &str, + tags: Vec, + next_ts: Timestamp, +) -> Result { + // Strip auth tags. + let clean_tags: Vec = tags + .into_iter() + .filter(|t| tag_name(t) != Some("auth")) + .collect(); + + build_project_with_tags(content, clean_tags) + .map_err(|e| CliError::Other(format!("envelope validation failed: {e}"))) + .map(|b| b.custom_created_at(next_ts)) +} + +// ── Command implementations ─────────────────────────────────────────────────── + +/// `buzz projects create` +pub async fn cmd_create( + client: &BuzzClient, + slug: &str, + repos: &[String], + name: Option<&str>, + description: Option<&str>, + channel: Option<&str>, + visibility: Option<&str>, +) -> Result<(), CliError> { + // ── Local validation (all checks before any .await) ─────────────────── + validate_project_slug(slug)?; + + let caller_pubkey = client.keys().public_key().to_hex(); + + // Expand and validate repo coordinates. + let members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // Dedupe: preserve first occurrence, reject duplicates with Usage. + let mut seen = std::collections::HashSet::new(); + for m in &members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + // Validate optional metadata (early, before any network call). + if let Some(ch) = channel { + crate::validate::validate_uuid(ch)?; + } + if let Some(vis) = visibility { + validate_visibility(vis)?; + } + if let Some(n) = name { + if n.len() > 256 { + return Err(CliError::Usage(format!( + "project name must not exceed 256 bytes (got {})", + n.len() + ))); + } + } + + // ── Network: collision preflight ────────────────────────────────────── + if fetch_own_project(client, slug).await?.is_some() { + return Err(CliError::Conflict(format!( + "project {slug:?} already exists; use 'buzz projects update' to modify it" + ))); + } + + // ── Build via Layer B (enforces all writer policy) ──────────────────── + let builder = build_project(slug, name, description, &members, channel, visibility) + .map_err(|e| CliError::Usage(e.to_string()))?; + submit_project(client, builder).await +} + +/// `buzz projects get` +pub async fn cmd_get(client: &BuzzClient, slug: &str, owner: Option<&str>) -> Result<(), CliError> { + validate_project_slug(slug)?; + let resp = match fetch_project(client, slug, owner).await? { + Some(event) => serde_json::json!({ + "event_id": event.id.to_hex(), + "pubkey": event.pubkey.to_hex(), + "created_at": event.created_at.as_secs(), + "kind": event.kind.as_u16(), + "tags": event.tags.iter().map(|t| t.as_slice().to_vec()).collect::>(), + "content": event.content, + }), + None => { + let owner_desc = owner.unwrap_or("current identity"); + return Err(CliError::NotFound(format!( + "project {slug:?} not found for {owner_desc}" + ))); + } + }; + println!("{resp}"); + Ok(()) +} + +/// `buzz projects list` +pub async fn cmd_list( + client: &BuzzClient, + owner: Option<&str>, + limit: Option, +) -> Result<(), CliError> { + let pubkey = match owner { + Some(pk) => { + crate::validate::validate_hex64(pk)?; + pk.to_string() + } + None => client.keys().public_key().to_hex(), + }; + let mut filter = serde_json::json!({ + "kinds": [KIND_PROJECT], + "authors": [pubkey], + }); + if let Some(n) = limit { + filter["limit"] = serde_json::json!(n); + } + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) +} + +/// `buzz projects add-repo` +pub async fn cmd_add_repo( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result<(), CliError> { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + // ── Local validation before any .await ──────────────────────────────── + let new_members: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // Dedupe within this invocation: first occurrence wins, duplicate → Usage. + let mut seen = std::collections::HashSet::new(); + for m in &new_members { + if !seen.insert(m.coord.clone()) { + return Err(CliError::Usage(format!( + "duplicate --repo coordinate in this invocation: {:?}", + m.coord + ))); + } + } + + // ── Network: fetch head ─────────────────────────────────────────────── + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Build the new tag set: keep existing tags (including hinted members), + // append new members only if not already present (by coordinate). + let mut tags: Vec = head.tags.iter().cloned().collect(); + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + let mut added = 0usize; + for m in &new_members { + if !existing_coords.contains(m.coord.as_str()) { + let parts = m.to_tag_parts(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + tags.push( + Tag::parse(parts_ref.iter().copied()) + .map_err(|e| CliError::Other(format!("member tag construction failed: {e}")))?, + ); + added += 1; + } + } + + // All requested coordinates were already present — no change to publish. + if added == 0 { + return Err(CliError::Conflict(format!( + "all requested repositories are already members of project {slug:?}" + ))); + } + + let builder = rebuild_project(&head.content, tags, next_ts)?; + submit_project(client, builder).await +} + +/// `buzz projects remove-repo` +pub async fn cmd_remove_repo( + client: &BuzzClient, + slug: &str, + repos: &[String], +) -> Result<(), CliError> { + validate_project_slug(slug)?; + let caller_pubkey = client.keys().public_key().to_hex(); + + // ── Local validation before any .await ──────────────────────────────── + let to_remove: Vec = repos + .iter() + .map(|r| expand_repo_coord(r, &caller_pubkey)) + .collect::, _>>()?; + + // ── Network: fetch head ─────────────────────────────────────────────── + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Verify all requested repos exist in the project. + let existing_coords: std::collections::HashSet = head + .tags + .iter() + .filter(|t| tag_name(t) == Some("a")) + .filter_map(|t| tag_value(t).map(String::from)) + .collect(); + for m in &to_remove { + if !existing_coords.contains(m.coord.as_str()) { + return Err(CliError::NotFound(format!( + "project {slug:?} does not contain member {:?}", + m.coord + ))); + } + } + + let remove_coords: std::collections::HashSet<&str> = + to_remove.iter().map(|m| m.coord.as_str()).collect(); + + // Keep all tags except auth and the removed members. + let tags: Vec = head + .tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + if tag_name(t) == Some("a") { + if let Some(coord) = tag_value(t) { + return !remove_coords.contains(coord); + } + } + true + }) + .cloned() + .collect(); + + // Single rebuild validates the full envelope and strips any remaining auth. + let builder = rebuild_project(&head.content, tags, next_ts)?; + submit_project(client, builder).await +} + +/// `buzz projects update` +/// +/// Requires at least one setter or clearer; a no-op call is a usage error. +#[allow(clippy::too_many_arguments)] +pub async fn cmd_update( + client: &BuzzClient, + slug: &str, + name: Option<&str>, + clear_name: bool, + description: Option<&str>, + clear_description: bool, + channel: Option<&str>, + clear_channel: bool, + visibility: Option<&str>, + clear_visibility: bool, +) -> Result<(), CliError> { + // Guard: at least one mutation required. The clap `ArgGroup` with + // `required(true).multiple(true)` enforces this at parse time; this + // runtime check is a defense-in-depth safety net for callers that invoke + // `cmd_update` directly (e.g. tests and future programmatic callers). + let has_mutation = name.is_some() + || clear_name + || description.is_some() + || clear_description + || channel.is_some() + || clear_channel + || visibility.is_some() + || clear_visibility; + if !has_mutation { + return Err(CliError::Usage( + "buzz projects update requires at least one of: \ + --name, --clear-name, --description, --clear-description, \ + --channel, --clear-channel, --visibility, --clear-visibility" + .into(), + )); + } + + validate_project_slug(slug)?; + if let Some(ch) = channel { + crate::validate::validate_uuid(ch)?; + } + if let Some(vis) = visibility { + validate_visibility(vis)?; + } + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + // Build the new tag set. For each singleton metadata field: + // - setter present: replace value (strip old, append new) + // - clear flag set: drop the tag + // - neither: keep existing + // Non-singleton / non-metadata tags (d, a, unknown) are preserved as-is. + let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"]; + let mut tags: Vec = head + .tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + // Drop singletons we're replacing or clearing. + if let Some(field) = tag_name(t) { + if singleton_fields.contains(&field) { + let clear = match field { + "name" => clear_name || name.is_some(), + "description" => clear_description || description.is_some(), + "buzz-channel" => clear_channel || channel.is_some(), + "buzz-visibility" => clear_visibility || visibility.is_some(), + _ => false, + }; + return !clear; + } + } + true + }) + .cloned() + .collect(); + + // Append new singleton values. + if let Some(n) = name { + tags.push(make_tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(make_tag(&["description", d])?); + } + if let Some(ch) = channel { + tags.push(make_tag(&["buzz-channel", ch])?); + } + if let Some(vis) = visibility { + tags.push(make_tag(&["buzz-visibility", vis])?); + } + + let builder = build_project_with_tags(&head.content, tags) + .map_err(|e| CliError::Other(format!("envelope validation failed: {e}")))? + .custom_created_at(next_ts); + submit_project(client, builder).await +} + +/// `buzz projects delete` +/// +/// Head-based and verified: +/// 1. Fetch own live head — `NotFound` if absent. +/// 2. Build tombstone at `head.created_at + 1`. +/// 3. Submit. +/// 4. Re-query the coordinate; if a newer head survived → `Conflict`. +pub async fn cmd_delete(client: &BuzzClient, slug: &str) -> Result<(), CliError> { + validate_project_slug(slug)?; + + let head = fetch_own_project(client, slug) + .await? + .ok_or_else(|| CliError::NotFound(format!("project {slug:?} not found")))?; + let next_ts = next_timestamp(&head)?; + + let pubkey_hex = client.keys().public_key().to_hex(); + let tombstone = build_delete_addressable(KIND_PROJECT, &pubkey_hex, slug) + .map_err(|e| CliError::Other(format!("failed to build delete event: {e}")))? + .custom_created_at(next_ts); + + let event = client.sign_event(tombstone)?; + let raw = client.submit_event(event).await?; + parse_write_response(&raw, "delete event was dominated; a newer head exists")?; + + // Post-submit verification: re-query to confirm the head is gone. + if let Some(survivor) = fetch_own_project(client, slug).await? { + // A newer head survived the tombstone. + return Err(CliError::Conflict(format!( + "project {slug:?} still exists (head at {}); a concurrent write raced the delete", + survivor.created_at.as_secs() + ))); + } + + println!("{}", serde_json::json!({ "deleted": slug, "status": "ok" })); + Ok(()) +} + +// ── Validation helpers ──────────────────────────────────────────────────────── + +/// Validate a project slug: non-empty, ≤1024 bytes, verbatim. +/// Does NOT impose the Buzz repo-ID grammar — project slugs are more permissive. +fn validate_project_slug(slug: &str) -> Result<(), CliError> { + if slug.is_empty() { + return Err(CliError::Usage("project slug must not be empty".into())); + } + if slug.len() > PROJECT_D_MAX_LEN { + return Err(CliError::Usage(format!( + "project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})", + slug.len() + ))); + } + Ok(()) +} + +/// Validate a `buzz-visibility` value at the writer level. +fn validate_visibility(vis: &str) -> Result<(), CliError> { + if vis != "listed" && vis != "unlisted" { + return Err(CliError::Usage(format!( + "visibility must be 'listed' or 'unlisted' (got {vis:?})" + ))); + } + Ok(()) +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +pub async fn dispatch(cmd: crate::ProjectsCmd, client: &BuzzClient) -> Result<(), CliError> { + use crate::ProjectsCmd; + match cmd { + ProjectsCmd::Create { + slug, + repo, + name, + description, + channel, + visibility, + } => { + cmd_create( + client, + &slug, + &repo, + name.as_deref(), + description.as_deref(), + channel.as_deref(), + visibility.map(|v| v.as_str()), + ) + .await + } + ProjectsCmd::Get { slug, owner } => cmd_get(client, &slug, owner.as_deref()).await, + ProjectsCmd::List { owner, limit } => cmd_list(client, owner.as_deref(), limit).await, + ProjectsCmd::AddRepo { slug, repo } => cmd_add_repo(client, &slug, &repo).await, + ProjectsCmd::RemoveRepo { slug, repo } => cmd_remove_repo(client, &slug, &repo).await, + ProjectsCmd::Update { + slug, + name, + clear_name, + description, + clear_description, + channel, + clear_channel, + visibility, + clear_visibility, + } => { + cmd_update( + client, + &slug, + name.as_deref(), + clear_name, + description.as_deref(), + clear_description, + channel.as_deref(), + clear_channel, + visibility.map(|v| v.as_str()), + clear_visibility, + ) + .await + } + ProjectsCmd::Delete { slug } => cmd_delete(client, &slug).await, + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use buzz_sdk::{validate_project_envelope, PROJECT_MEMBER_CAP}; + use nostr::Tag; + + use super::*; + + // ── Coordinate expansion ────────────────────────────────────────────────── + + const OWNER_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OWNER_B_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + #[test] + fn expand_repo_coord_bare_expands_with_caller_pubkey() { + let coord = expand_repo_coord("my-repo", OWNER_HEX).unwrap(); + assert_eq!(coord.coord, format!("30617:{OWNER_HEX}:my-repo")); + } + + #[test] + fn expand_repo_coord_full_passes_through() { + let full = format!("30617:{OWNER_HEX}:some-repo"); + let coord = expand_repo_coord(&full, OWNER_B_HEX).unwrap(); + // Owner from the full coord, not the caller. + assert_eq!(coord.coord, full); + } + + #[test] + fn expand_repo_coord_full_cross_owner() { + let full = format!("30617:{OWNER_B_HEX}:infra"); + let coord = expand_repo_coord(&full, OWNER_HEX).unwrap(); + assert_eq!(coord.coord, full); + } + + #[test] + fn expand_repo_coord_rejects_uppercase_owner() { + let upper = "30617:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:buzz"; + assert!(expand_repo_coord(upper, OWNER_HEX).is_err()); + } + + #[test] + fn expand_repo_coord_rejects_coordinate_shaped_bare_value() { + // A value with a colon is never a bare id. + let not_bare = "30617:something"; + // parse_full will fail because it's not a valid full coordinate either. + assert!(expand_repo_coord(not_bare, OWNER_HEX).is_err()); + } + + // ── validate_project_slug ───────────────────────────────────────────────── + + #[test] + fn validate_project_slug_accepts_normal() { + assert!(validate_project_slug("my-project").is_ok()); + assert!(validate_project_slug("platform:v2").is_ok()); // colons allowed — more permissive than repo-id + } + + #[test] + fn validate_project_slug_rejects_empty() { + assert!(validate_project_slug("").is_err()); + } + + #[test] + fn validate_project_slug_rejects_over_1024() { + let long = "a".repeat(1025); + assert!(validate_project_slug(&long).is_err()); + } + + #[test] + fn validate_project_slug_accepts_1024() { + let at_limit = "a".repeat(1024); + assert!(validate_project_slug(&at_limit).is_ok()); + } + + // ── validate_visibility ─────────────────────────────────────────────────── + + #[test] + fn validate_visibility_accepts_listed_and_unlisted() { + assert!(validate_visibility("listed").is_ok()); + assert!(validate_visibility("unlisted").is_ok()); + } + + #[test] + fn validate_visibility_rejects_unknown_token() { + assert!(validate_visibility("chartreuse").is_err()); + assert!(validate_visibility("").is_err()); + } + + // ── is_bare_repo_id ─────────────────────────────────────────────────────── + + #[test] + fn bare_repo_id_accepts_valid() { + assert!(is_bare_repo_id("buzz")); + assert!(is_bare_repo_id("my-repo_1.0")); + } + + #[test] + fn bare_repo_id_rejects_colon() { + assert!(!is_bare_repo_id("30617:something")); + assert!(!is_bare_repo_id("has:colon")); + } + + #[test] + fn bare_repo_id_rejects_empty() { + assert!(!is_bare_repo_id("")); + } + + #[test] + fn bare_repo_id_rejects_over_64() { + let long = "a".repeat(65); + assert!(!is_bare_repo_id(&long)); + } + + // ── tag helpers ─────────────────────────────────────────────────────────── + + fn make_test_tag(parts: &[&str]) -> Tag { + Tag::parse(parts.iter().copied()).unwrap() + } + + // ── rebuild_project: hinted / unknown tag preservation ─────────────────── + + #[test] + fn rebuild_project_preserves_hinted_member_tags() { + // A member 'a' tag with a relay hint must survive RMW untouched. + let coord = format!("30617:{OWNER_HEX}:buzz"); + let hint = "wss://relay.example.com"; + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, hint]).unwrap(), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + let a_tag = ev + .tags + .iter() + .find(|t| tag_name(t) == Some("a")) + .expect("a tag present"); + assert_eq!( + a_tag.as_slice(), + &["a".to_string(), coord, hint.to_string()], + "relay hint must survive rebuild" + ); + } + + #[test] + fn rebuild_project_preserves_unknown_tags() { + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["future-metadata", "value"]), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + assert!(ev + .tags + .iter() + .any(|t| tag_name(t) == Some("future-metadata"))); + } + + #[test] + fn rebuild_project_strips_auth_tag() { + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]), + ]; + let ts = Timestamp::from(1_700_000_001u64); + let b = rebuild_project("", tags, ts).unwrap(); + let ev = b.sign_with_keys(&nostr::Keys::generate()).expect("sign"); + assert!( + !ev.tags.iter().any(|t| tag_name(t) == Some("auth")), + "auth tag must be stripped" + ); + } + + #[test] + fn rebuild_project_rejects_over_cap_foreign_head() { + // A foreign head with 65 members must fail Layer A on republish. + let mut tags = vec![make_test_tag(&["d", "wide"])]; + for i in 0..=64u32 { + let coord = format!("30617:{OWNER_HEX}:repo-{i:02}"); + tags.push(make_test_tag(&["a", &coord])); + } + assert_eq!( + tags.iter().filter(|t| tag_name(t) == Some("a")).count(), + 65, + "65 a-tags" + ); + let ts = Timestamp::from(1_700_000_001u64); + // rebuild_project strips auth, but 65 a-tags still exceeds cap. + assert!( + rebuild_project("", tags, ts).is_err(), + "over-cap foreign head must fail rebuild" + ); + } + + #[test] + fn rebuild_project_at_exact_cap_succeeds() { + let mut tags = vec![make_test_tag(&["d", "wide"])]; + for i in 0..PROJECT_MEMBER_CAP { + let coord = format!("30617:{OWNER_HEX}:repo-{i:02}"); + tags.push(make_test_tag(&["a", &coord])); + } + let ts = Timestamp::from(1_700_000_001u64); + assert!(rebuild_project("", tags, ts).is_ok()); + } + + // ── clear-flag semantics ────────────────────────────────────────────────── + + /// Build a minimal head Event for testing update semantics without the relay. + fn make_head_tags(extra: &[Tag]) -> Vec { + let mut tags = vec![make_test_tag(&["d", "platform"])]; + tags.extend_from_slice(extra); + tags + } + + #[allow(clippy::too_many_arguments)] + fn apply_update_tags( + head_tags: Vec, + name: Option<&str>, + clear_name: bool, + description: Option<&str>, + clear_description: bool, + channel: Option<&str>, + clear_channel: bool, + visibility: Option<&str>, + clear_visibility: bool, + ) -> Vec { + // Replicate the tag-mutation logic from cmd_update (sans relay I/O). + let singleton_fields = ["name", "description", "buzz-channel", "buzz-visibility"]; + let mut tags: Vec = head_tags + .iter() + .filter(|t| { + if tag_name(t) == Some("auth") { + return false; + } + if let Some(field) = tag_name(t) { + if singleton_fields.contains(&field) { + let clear = match field { + "name" => clear_name || name.is_some(), + "description" => clear_description || description.is_some(), + "buzz-channel" => clear_channel || channel.is_some(), + "buzz-visibility" => clear_visibility || visibility.is_some(), + _ => false, + }; + return !clear; + } + } + true + }) + .cloned() + .collect(); + if let Some(n) = name { + tags.push(make_test_tag(&["name", n])); + } + if let Some(d) = description { + tags.push(make_test_tag(&["description", d])); + } + if let Some(ch) = channel { + tags.push(make_test_tag(&["buzz-channel", ch])); + } + if let Some(vis) = visibility { + tags.push(make_test_tag(&["buzz-visibility", vis])); + } + tags + } + + #[test] + fn update_omission_preserves_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags(head, None, false, None, false, None, false, None, false); + assert!(result.iter().any(|t| tag_value(t) == Some("Old Name"))); + } + + #[test] + fn update_setter_replaces_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags( + head, + Some("New Name"), + false, + None, + false, + None, + false, + None, + false, + ); + assert!(result.iter().any(|t| tag_value(t) == Some("New Name"))); + assert!(!result.iter().any(|t| tag_value(t) == Some("Old Name"))); + } + + #[test] + fn update_clear_drops_existing_field() { + let head = make_head_tags(&[make_test_tag(&["name", "Old Name"])]); + let result = apply_update_tags(head, None, true, None, false, None, false, None, false); + assert!(!result.iter().any(|t| tag_name(t) == Some("name"))); + } + + #[test] + fn update_clear_visibility_drops_tag() { + let head = make_head_tags(&[make_test_tag(&["buzz-visibility", "unlisted"])]); + let result = apply_update_tags(head, None, false, None, false, None, false, None, true); + assert!(!result + .iter() + .any(|t| tag_name(t) == Some("buzz-visibility"))); + } + + #[test] + fn update_exactly_one_singleton_after_replace() { + // Start with a buzz-channel; replace with a new one; must have exactly one. + let uuid1 = "3580ca9b-47b4-4af9-b22a-1068778f26c6"; + let uuid2 = "00000000-0000-0000-0000-000000000000"; + let head = make_head_tags(&[make_test_tag(&["buzz-channel", uuid1])]); + let result = apply_update_tags( + head, + None, + false, + None, + false, + Some(uuid2), + false, + None, + false, + ); + let channels: Vec<_> = result + .iter() + .filter(|t| tag_name(t) == Some("buzz-channel")) + .collect(); + assert_eq!(channels.len(), 1); + assert_eq!(tag_value(channels[0]), Some(uuid2)); + } + + // ── duplicate-member rejection on republish ─────────────────────────────── + + #[test] + fn duplicate_member_in_foreign_head_fails_rebuild() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &coord]), + make_test_tag(&["a", &coord]), // duplicate + ]; + let ts = Timestamp::from(1_700_000_001u64); + assert!(rebuild_project("", tags, ts).is_err()); + } + + // ── validate_project_envelope integration ──────────────────────────────── + + #[test] + fn validate_project_envelope_accepts_hinted_member() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, "wss://relay.example.com"]).unwrap(), + ]; + assert!(validate_project_envelope(&tags, "").is_ok()); + } + + #[test] + fn validate_project_envelope_rejects_four_element_member() { + let coord = format!("30617:{OWNER_HEX}:buzz"); + let tags = vec![ + make_test_tag(&["d", "platform"]), + Tag::parse(["a", &coord, "wss://relay.example.com", "extra"]).unwrap(), + ]; + assert!(validate_project_envelope(&tags, "").is_err()); + } + + // ── next_timestamp ordering ─────────────────────────────────────────────── + + /// `next_timestamp` must return `head.created_at + 1` regardless of the wall + /// clock. NIP-MP Deletion rule: a tombstone older than the live head does + /// NOT remove it, so we must advance strictly off the observed head — never + /// use wall-clock time, which could be behind a head that was bumped + /// multiple times in the same second. + #[test] + fn next_timestamp_returns_head_plus_one_when_head_is_ahead_of_wall_clock() { + // Build a minimal signed event with a created_at far in the future. + let keys = nostr::Keys::generate(); + let far_future_ts = Timestamp::from(9_999_999_999u64); // year 2286 + let tags = vec![ + make_test_tag(&["d", "platform"]), + make_test_tag(&["a", &format!("30617:{OWNER_HEX}:buzz")]), + ]; + let builder = rebuild_project("", tags, far_future_ts).expect("valid head envelope"); + let head = builder.sign_with_keys(&keys).expect("sign"); + // Verify the event actually has our future timestamp. + assert_eq!(head.created_at, far_future_ts); + + // next_timestamp must return far_future + 1, not now(). + let next = next_timestamp(&head).expect("no overflow"); + assert_eq!( + next.as_secs(), + far_future_ts.as_secs() + 1, + "tombstone must be strictly after head, even when head is far in the future" + ); + } + + // ── empty update guard ──────────────────────────────────────────────────── + + /// `cmd_update` with no setters or clearers must return `CliError::Usage` + /// before making any network call. The guard is synchronous (before the + /// first `.await`) so we can drive it with a dummy client whose address + /// would reject any real connection attempt. + #[tokio::test] + async fn empty_update_returns_usage_error_before_any_network_call() { + let keys = nostr::Keys::generate(); + // Port 9 is the discard protocol — any real connect will be refused + // immediately, but the guard fires before the first await so this + // never reaches the network. + let client = crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) + .expect("client construction"); + + let err = cmd_update( + &client, "my-slug", None, false, // name / clear_name + None, false, // description / clear_description + None, false, // channel / clear_channel + None, false, // visibility / clear_visibility + ) + .await + .expect_err("empty update must fail"); + + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage, got {err:?}" + ); + } + + // ── no-network malformed-input tests ───────────────────────────────────── + // + // All three cases use port 9 (discard protocol): any real connection is + // refused immediately, but local validation fires before the first .await + // so the network is never touched. + + fn discard_client() -> crate::client::BuzzClient { + let keys = nostr::Keys::generate(); + crate::client::BuzzClient::new("http://127.0.0.1:9".into(), keys, None, None) + .expect("client construction") + } + + /// Invalid visibility token must return Usage before touching the relay. + #[tokio::test] + async fn create_invalid_visibility_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create( + &client, + "my-slug", + &["buzz".to_string()], + None, + None, + None, + Some("chartreuse"), + ) + .await + .expect_err("invalid visibility must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for invalid visibility, got {err:?}" + ); + } + + /// A name longer than 256 bytes must return Usage before touching the relay. + #[tokio::test] + async fn create_overlong_name_returns_usage_before_any_network_call() { + let client = discard_client(); + let long_name = "a".repeat(257); + let err = cmd_create( + &client, + "my-slug", + &["buzz".to_string()], + Some(&long_name), + None, + None, + None, + ) + .await + .expect_err("overlong name must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for overlong name, got {err:?}" + ); + } + + /// A malformed --repo coordinate must return Usage before touching the relay. + #[tokio::test] + async fn create_malformed_repo_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_create( + &client, + "my-slug", + &["nope:bad".to_string()], + None, + None, + None, + None, + ) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo, got {err:?}" + ); + } + + /// A malformed --repo coordinate on add-repo must return Usage before touching the relay. + #[tokio::test] + async fn add_repo_malformed_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_add_repo(&client, "my-slug", &["nope:bad".to_string()]) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo on add-repo, got {err:?}" + ); + } + + /// A malformed --repo coordinate on remove-repo must return Usage before touching the relay. + #[tokio::test] + async fn remove_repo_malformed_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let err = cmd_remove_repo(&client, "my-slug", &["nope:bad".to_string()]) + .await + .expect_err("malformed repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for malformed repo on remove-repo, got {err:?}" + ); + } + + // ── duplicate --repo within one invocation ──────────────────────────────── + + /// Supplying the same coordinate twice in one create call must return Usage + /// (names the duplicate) before any network call. + #[tokio::test] + async fn create_duplicate_repo_returns_usage_before_any_network_call() { + let client = discard_client(); + let coord = format!("30617:{OWNER_HEX}:buzz"); + let err = cmd_create( + &client, + "my-slug", + &[coord.clone(), coord.clone()], + None, + None, + None, + None, + ) + .await + .expect_err("duplicate repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for duplicate repo, got {err:?}" + ); + // Error message must name the duplicate coordinate. + assert!( + format!("{err}").contains("buzz"), + "Usage message must name the duplicate coordinate, got {err:?}" + ); + } + + /// Supplying the same coordinate twice in one add-repo call must return Usage + /// (names the duplicate) before any network call. + #[tokio::test] + async fn add_repo_duplicate_coord_returns_usage_before_any_network_call() { + let client = discard_client(); + let coord = format!("30617:{OWNER_HEX}:buzz"); + let err = cmd_add_repo(&client, "my-slug", &[coord.clone(), coord.clone()]) + .await + .expect_err("duplicate repo must fail"); + assert!( + matches!(err, CliError::Usage(_)), + "expected CliError::Usage for duplicate repo on add-repo, got {err:?}" + ); + } + + // ── create collision guard ──────────────────────────────────────────────── + + // The create-collision Conflict path is pinned by the live transcript + // (step: duplicate create → Conflict, exit=5). No relay mock is available + // for a unit test; the no-network tests above cover all pre-await paths. + + // ── add-repo no-op guard ────────────────────────────────────────────────── + + // The add-repo no-op Conflict path is pinned by the live transcript + // (step 7: buzz already present → exit=5). No relay mock is available + // for a unit test; the async no-network tests above cover all pre-await paths. +} diff --git a/crates/buzz-cli/src/commands/repos.rs b/crates/buzz-cli/src/commands/repos.rs index 608d4950554..15e064d9c35 100644 --- a/crates/buzz-cli/src/commands/repos.rs +++ b/crates/buzz-cli/src/commands/repos.rs @@ -4,7 +4,8 @@ use buzz_core::{ }; use nostr::{Event, EventBuilder, Tag, Timestamp}; -use crate::client::{normalize_write_response, BuzzClient}; +use crate::client::BuzzClient; +use crate::commands::parse_write_response; use crate::error::CliError; use crate::validate::validate_repo_id; @@ -186,25 +187,10 @@ fn protection_rules_json(event: &Event) -> Result { } fn validate_write_response(raw: &str) -> Result { - let response: serde_json::Value = serde_json::from_str(raw) - .map_err(|error| CliError::Other(format!("relay response is not JSON: {error} ({raw})")))?; - let accepted = response - .get("accepted") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - let message = response - .get("message") - .and_then(serde_json::Value::as_str) - .unwrap_or(""); - if !accepted { - return Err(CliError::Other(format!("relay rejected event: {message}"))); - } - if message == "duplicate" || message.starts_with("duplicate:") { - return Err(CliError::Conflict( - "repository changed concurrently; fetch the latest rules and retry".into(), - )); - } - Ok(normalize_write_response(raw)) + parse_write_response( + raw, + "repository changed concurrently; fetch the latest rules and retry", + ) } async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> { diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 0860f9dae6c..f745e7b2801 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -212,6 +212,9 @@ enum Cmd { /// Announce and discover git repositories (NIP-34) #[command(subcommand)] Repos(ReposCmd), + /// Create and manage multi-repo projects (NIP-MP) + #[command(subcommand)] + Projects(ProjectsCmd), /// Send, get, list, and set status on git patches (NIP-34) #[command(subcommand)] Patches(PatchesCmd), @@ -1227,6 +1230,122 @@ pub enum RepoPushRole { Member, } +/// Visibility of a multi-repo project listing. +#[derive(Clone, Copy, Debug, clap::ValueEnum)] +pub enum ProjectVisibility { + /// Project appears in public listings (default). + Listed, + /// Project is hidden from public listings. + Unlisted, +} + +impl ProjectVisibility { + pub fn as_str(self) -> &'static str { + match self { + ProjectVisibility::Listed => "listed", + ProjectVisibility::Unlisted => "unlisted", + } + } +} + +#[derive(Subcommand)] +pub enum ProjectsCmd { + /// Create a new multi-repo project (NIP-MP kind:30621) + /// + /// Requires at least one --repo. Fails with Conflict if the project already exists. + Create { + /// Project identifier (slug), up to 1024 bytes + slug: String, + /// Member repository coordinate: bare Buzz repo id (e.g. `buzz`) or full + /// `30617::` for cross-owner or colon-bearing repo ids. + /// At least one --repo is required. + #[arg(long = "repo", required = true)] + repo: Vec, + /// Display name (≤256 bytes) + #[arg(long)] + name: Option, + /// Description (≤2048 bytes) + #[arg(long)] + description: Option, + /// Associated Buzz channel UUID + #[arg(long)] + channel: Option, + /// Visibility: `listed` (default) or `unlisted` + #[arg(long)] + visibility: Option, + }, + /// Get a project by slug + Get { + /// Project slug + slug: String, + /// Owner pubkey (64-char hex). Defaults to the current identity. + #[arg(long)] + owner: Option, + }, + /// List projects + List { + /// Owner pubkey (64-char hex). Defaults to the current identity. + #[arg(long)] + owner: Option, + /// Maximum number of results + #[arg(long)] + limit: Option, + }, + /// Add one or more member repositories to a project + #[command(name = "add-repo")] + AddRepo { + /// Project slug + slug: String, + /// Member repository coordinate (bare id or full `30617::`) + #[arg(long = "repo", required = true)] + repo: Vec, + }, + /// Remove one or more member repositories from a project + #[command(name = "remove-repo")] + RemoveRepo { + /// Project slug + slug: String, + /// Member repository coordinate to remove (bare id or full `30617::`) + #[arg(long = "repo", required = true)] + repo: Vec, + }, + /// Update project metadata (at least one setter or clearer required) + #[command(group = clap::ArgGroup::new("mutation").required(true).multiple(true))] + Update { + /// Project slug + slug: String, + /// Set the display name + #[arg(long, group = "mutation")] + name: Option, + /// Remove the display name + #[arg(long, group = "mutation", conflicts_with = "name")] + clear_name: bool, + /// Set the description + #[arg(long, group = "mutation")] + description: Option, + /// Remove the description + #[arg(long, group = "mutation", conflicts_with = "description")] + clear_description: bool, + /// Set the associated Buzz channel UUID + #[arg(long, group = "mutation")] + channel: Option, + /// Remove the associated channel + #[arg(long, group = "mutation", conflicts_with = "channel")] + clear_channel: bool, + /// Set visibility: `listed` or `unlisted` + #[arg(long, group = "mutation")] + visibility: Option, + /// Remove the visibility tag (absence defaults to `listed`) + #[arg(long, group = "mutation", conflicts_with = "visibility")] + clear_visibility: bool, + }, + /// Delete a project (head-based tombstone; verified after submit) + Delete { + /// Project slug + slug: String, + }, +} + #[derive(Subcommand)] pub enum PatchesCmd { /// Send a git patch (NIP-34 kind:1617) @@ -1865,6 +1984,7 @@ async fn run(cli: Cli) -> Result<(), CliError> { Cmd::Social(sub) => commands::social::dispatch(sub, &client).await, Cmd::Notes(sub) => commands::notes::dispatch(sub, &client).await, Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await, + Cmd::Projects(sub) => commands::projects::dispatch(sub, &client).await, Cmd::Patches(sub) => commands::patches::dispatch(sub, &client).await, Cmd::Issues(sub) => commands::issues::dispatch(sub, &client).await, Cmd::Pr(sub) => commands::pr::dispatch(sub, &client).await, @@ -1974,6 +2094,7 @@ mod tests { "pack", "patches", "pr", + "projects", "reactions", "repos", "social", @@ -2129,6 +2250,18 @@ mod tests { names(&cmd, "patches"), vec!["get", "list", "send", "status"] ); + assert_eq!( + names(&cmd, "projects"), + vec![ + "add-repo", + "create", + "delete", + "get", + "list", + "remove-repo", + "update" + ] + ); assert_eq!( names(&cmd, "issues"), vec!["create", "get", "list", "status"] @@ -2166,6 +2299,7 @@ mod tests { ("pack", 2), ("patches", 4), ("pr", 5), + ("projects", 7), ("reactions", 3), ("repos", 5), ("social", 7), @@ -2233,4 +2367,111 @@ mod tests { .join("\n") ); } + + // ── projects update mutation group ──────────────────────────────────────── + + /// Multiple independent fields must be accepted in the same invocation. + #[test] + fn projects_update_multi_field_is_accepted() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--description", + "Y", + ]) + .is_ok(), + "--name and --description together must be accepted" + ); + } + + /// A setter for one field and a clearer for a different field must be accepted. + #[test] + fn projects_update_setter_with_other_clearer_is_accepted() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--clear-description", + ]) + .is_ok(), + "--name with --clear-description must be accepted" + ); + } + + /// A setter and its own clearer are mutually exclusive — clap must reject this. + #[test] + fn projects_update_setter_with_own_clearer_is_rejected() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--name", + "X", + "--clear-name", + ]) + .is_err(), + "--name and --clear-name together must be rejected by clap" + ); + } + + /// Providing no mutation options at all must be rejected by clap (required group). + #[test] + fn projects_update_no_mutation_is_rejected_by_clap() { + // Without credentials, a valid parse would reach authentication and fail + // with auth_error — but a clap-level rejection happens before any I/O. + // We verify it's a clap error (not just any error) by checking the error + // kind is not a runtime/auth failure — Cli::try_parse_from returns Err + // immediately for argument violations. + assert!( + Cli::try_parse_from(["buzz", "projects", "update", "my-slug"]).is_err(), + "update with no setters or clearers must be rejected at parse time" + ); + } + + /// An unrecognised visibility token must be rejected by clap before any I/O. + #[test] + fn projects_create_invalid_visibility_is_rejected_by_clap() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "create", + "my-slug", + "--repo", + "buzz", + "--visibility", + "chartreuse", + ]) + .is_err(), + "--visibility chartreuse must be rejected at parse time" + ); + } + + /// An unrecognised visibility token on update must be rejected by clap before any I/O. + #[test] + fn projects_update_invalid_visibility_is_rejected_by_clap() { + assert!( + Cli::try_parse_from([ + "buzz", + "projects", + "update", + "my-slug", + "--visibility", + "chartreuse", + ]) + .is_err(), + "--visibility chartreuse on update must be rejected at parse time" + ); + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 8cc9c8650a9..9a139f0377b 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -11,8 +11,8 @@ use buzz_core::{ KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST, KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, - KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_USER_STATUS, - KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, + KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, KIND_PRESENCE_UPDATE, KIND_PROJECT, + KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, }, observer::{ content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, @@ -1499,12 +1499,7 @@ pub fn build_workflow_delete( author_pubkey: &str, workflow_id: Uuid, ) -> Result { - let pk = check_pubkey_hex(author_pubkey, "author_pubkey")?; - let tags = vec![tag(&[ - "a", - &format!("{}:{pk}:{workflow_id}", KIND_WORKFLOW_DEF), - ])?]; - Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags)) + build_delete_addressable(KIND_WORKFLOW_DEF, author_pubkey, &workflow_id.to_string()) } /// Build a workflow trigger event (kind 46020). @@ -1838,6 +1833,364 @@ pub fn build_unarchive_identity_request( ) } +// ─── NIP-MP: Multi-repo projects (kind:30621) ──────────────────────────────── +// +// Public surface: +// • `validate_project_envelope` — Layer A protocol validator (8 ingest rules) +// • `build_project_with_tags` — Layer A raw builder (content + tags, no canonicalization) +// • `ProjectMemberCoord` — parsed member coordinate + optional relay hint +// • `build_project` — Layer B writer-policy builder +// • `build_delete_addressable` — generic NIP-09 kind:5 coordinate delete +// +// Byte-length bounds from NIP-MP §Relay Processing: +/// Maximum byte length of a project `d` tag value. +pub const PROJECT_D_MAX_LEN: usize = 1024; +/// Maximum byte length of a project `name` tag value. +pub const PROJECT_NAME_MAX: usize = 256; +/// Maximum byte length of a project `description` tag value. +pub const PROJECT_DESCRIPTION_MAX: usize = 2048; +/// Maximum byte length of a project `buzz-channel` tag value. +pub const PROJECT_CHANNEL_MAX: usize = 256; +/// Maximum byte length of a project `buzz-visibility` tag value. +pub const PROJECT_VISIBILITY_MAX: usize = 256; +/// Maximum number of `a` member tags per project event (checked before dedup). +pub const PROJECT_MEMBER_CAP: usize = 64; + +/// A validated NIP-MP member `a`-tag coordinate with an optional relay hint. +/// +/// Equality and `Hash` are by `coord` only (per spec: duplicate detection ignores hint). +#[derive(Clone, Debug)] +pub struct ProjectMemberCoord { + /// The full `30617::` coordinate string. + pub coord: String, + /// Optional opaque relay hint (third `a`-tag element, never validated by content). + pub hint: Option, +} + +impl PartialEq for ProjectMemberCoord { + fn eq(&self, other: &Self) -> bool { + self.coord == other.coord + } +} + +impl Eq for ProjectMemberCoord {} + +impl std::hash::Hash for ProjectMemberCoord { + fn hash(&self, state: &mut H) { + self.coord.hash(state); + } +} + +impl ProjectMemberCoord { + /// Parse a full `30617::` coordinate string. + /// + /// Accepts an optional relay hint as the third colon-separated element + /// after the split, but the split is always first-two-colons: kind, owner, + /// everything-else-as-repo-d. + /// + /// Rules enforced: + /// - Exactly three segments after splitting on the first two colons + /// - First segment must be the literal string `"30617"` + /// - Second segment must be exactly 64 lowercase hex characters + /// - Third segment (repo-d) must be non-empty + /// - Uppercase owners are rejected (never normalized) + pub fn parse_full(coord: &str) -> Result { + // Split on first two colons only: kind:owner:rest + let mut parts = coord.splitn(3, ':'); + let kind_part = parts.next().unwrap_or(""); + let owner_part = parts.next().unwrap_or(""); + let rest = parts.next().unwrap_or(""); + + if kind_part != "30617" { + return Err(SdkError::InvalidInput(format!( + "member coordinate must start with '30617:' (got kind {kind_part:?})" + ))); + } + if owner_part.len() != 64 || !owner_part.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput(format!( + "member owner must be a 64-character hex pubkey (got {owner_part:?})" + ))); + } + // Reject uppercase (spec: lowercase hex required) + if owner_part.chars().any(|c| c.is_ascii_uppercase()) { + return Err(SdkError::InvalidInput( + "member owner hex must be lowercase".into(), + )); + } + if rest.is_empty() { + return Err(SdkError::InvalidInput( + "member coordinate repo-d must not be empty".into(), + )); + } + Ok(ProjectMemberCoord { + coord: format!("30617:{owner_part}:{rest}"), + hint: None, + }) + } + + /// Returns the `a`-tag element slice: `[coord]` or `[coord, hint]`. + pub fn to_tag_parts(&self) -> Vec { + let mut parts = vec!["a".to_string(), self.coord.clone()]; + if let Some(h) = &self.hint { + parts.push(h.clone()); + } + parts + } +} + +/// **Layer A**: Validate a complete kind:30621 envelope against the 8 NIP-MP +/// ingest rules. This is the single source of protocol truth used by both +/// `build_project_with_tags` (raw path) and `build_project` (policy path). +/// +/// Rules enforced (matches relay `buzz-db` ingest logic): +/// 1. `d` cardinality: exactly one `d` tag. +/// 2. `d` value: non-empty, ≤1024 bytes. +/// 3. Member cap: raw count of every `a` tag ≤ 64 (checked **before** per-tag +/// parsing, matching relay rule order). +/// 4. Member tag arity: every `a` tag has 2 or 3 elements (no more, no fewer). +/// 5. Member coordinate grammar: first-two-colons split; kind literal `"30617"`; +/// owner lowercase 64-hex; repo-d non-empty verbatim. +/// 6. Member deduplication: coordinate equality only (hint ignored); any +/// coordinate that appears more than once is a duplicate. +/// 7. Singleton metadata: each of `name`, `description`, `buzz-channel`, +/// `buzz-visibility` appears at most once. +/// 8. Metadata byte lengths: `name` ≤256, `description` ≤2048, +/// `buzz-channel` ≤256, `buzz-visibility` ≤256. +pub fn validate_project_envelope(tags: &[Tag], _content: &str) -> Result<(), SdkError> { + // --- Rule 1 & 2: d tag --- + let d_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("d")).collect(); + match d_tags.len() { + 0 => { + return Err(SdkError::InvalidInput( + "project must have exactly one 'd' tag (rule: d-cardinality)".into(), + )) + } + 1 => {} + _ => { + return Err(SdkError::InvalidInput( + "project must have exactly one 'd' tag (rule: d-cardinality)".into(), + )) + } + } + let d_val = tag_value(d_tags[0]).unwrap_or(""); + if d_val.is_empty() { + return Err(SdkError::InvalidInput( + "project 'd' tag must not be empty (rule: d-empty)".into(), + )); + } + if d_val.len() > PROJECT_D_MAX_LEN { + return Err(SdkError::InvalidInput(format!( + "project 'd' tag exceeds {PROJECT_D_MAX_LEN} bytes (rule: d-empty)" + ))); + } + + let a_tags: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some("a")).collect(); + + // --- Rule 3: member cap (checked before per-tag parsing, matching relay rule order) --- + if a_tags.len() > PROJECT_MEMBER_CAP { + return Err(SdkError::InvalidInput(format!( + "project exceeds member cap of {PROJECT_MEMBER_CAP} (got {}) (rule: member-cap)", + a_tags.len() + ))); + } + + // --- Rule 4: member arity --- + for a in &a_tags { + let len = a.as_slice().len() - 1; // exclude the "a" name element + if !(1..=2).contains(&len) { + return Err(SdkError::InvalidInput(format!( + "member 'a' tag must have 1 or 2 value elements (got {len}) (rule: member-tag-arity)" + ))); + } + } + + // --- Rules 5 & 6: coordinate grammar + deduplication --- + let mut seen_coords: std::collections::HashSet = std::collections::HashSet::new(); + for a in &a_tags { + let coord_val = tag_value(a).unwrap_or(""); + ProjectMemberCoord::parse_full(coord_val).map_err(|e| { + SdkError::InvalidInput(format!("{e} (rule: member-coordinate-malformed)")) + })?; + if !seen_coords.insert(coord_val.to_string()) { + return Err(SdkError::InvalidInput(format!( + "duplicate member coordinate {coord_val:?} (rule: member-duplicate)" + ))); + } + } + + // --- Rules 7 & 8: singleton metadata + byte bounds --- + let singleton_fields = [ + ( + "name", + PROJECT_NAME_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "description", + PROJECT_DESCRIPTION_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "buzz-channel", + PROJECT_CHANNEL_MAX, + "metadata-cardinality", + "metadata-length", + ), + ( + "buzz-visibility", + PROJECT_VISIBILITY_MAX, + "metadata-cardinality", + "metadata-length", + ), + ]; + for (field, max_bytes, card_rule, len_rule) in singleton_fields { + let matches: Vec<&Tag> = tags.iter().filter(|t| tag_name(t) == Some(field)).collect(); + if matches.len() > 1 { + return Err(SdkError::InvalidInput(format!( + "project must have at most one '{field}' tag (rule: {card_rule})" + ))); + } + if let Some(t) = matches.first() { + let val = tag_value(t).unwrap_or(""); + if val.len() > max_bytes { + return Err(SdkError::InvalidInput(format!( + "'{field}' tag exceeds {max_bytes} bytes (rule: {len_rule})" + ))); + } + } + } + + Ok(()) +} + +/// Helper: tag name (first element). +fn tag_name(tag: &Tag) -> Option<&str> { + tag.as_slice().first().map(String::as_str) +} + +/// Helper: tag value (second element). +fn tag_value(tag: &Tag) -> Option<&str> { + tag.as_slice().get(1).map(String::as_str) +} + +/// **Layer A raw builder**: Build a kind:30621 project event from a raw +/// `content` string and a raw `tags` slice, without any canonicalization. +/// +/// Validates the entire envelope through `validate_project_envelope` before +/// accepting it. The caller is responsible for supplying the correct `d` tag. +/// This is the path exercised by fixture conformance tests and by read-modify- +/// write mutations in the CLI. +pub fn build_project_with_tags(content: &str, tags: Vec) -> Result { + validate_project_envelope(&tags, content)?; + Ok(EventBuilder::new(Kind::Custom(KIND_PROJECT as u16), content).tags(tags)) +} + +/// **Layer B writer-policy builder**: Build a kind:30621 project event with +/// enforced writer policy: +/// - The `d` tag is constructed from `slug`; `check_project_slug` rejects +/// an empty or over-length slug. +/// - `channel` must be a valid UUID string. +/// - `visibility` must be `"listed"` or `"unlisted"`. +/// - Content is always empty. +/// - Member coordinates are parsed through `ProjectMemberCoord::parse_full`. +/// +/// The resulting envelope is validated through Layer A before the builder is +/// returned. +pub fn build_project( + slug: &str, + name: Option<&str>, + description: Option<&str>, + members: &[ProjectMemberCoord], + channel: Option<&str>, + visibility: Option<&str>, +) -> Result { + // Slug validation + if slug.is_empty() { + return Err(SdkError::InvalidInput( + "project slug must not be empty".into(), + )); + } + if slug.len() > PROJECT_D_MAX_LEN { + return Err(SdkError::InvalidInput(format!( + "project slug must not exceed {PROJECT_D_MAX_LEN} bytes (got {})", + slug.len() + ))); + } + + // Channel UUID validation + if let Some(ch) = channel { + uuid::Uuid::parse_str(ch).map_err(|_| { + SdkError::InvalidInput(format!("buzz-channel must be a valid UUID (got {ch:?})")) + })?; + } + + // Visibility enum validation + if let Some(vis) = visibility { + if vis != "listed" && vis != "unlisted" { + return Err(SdkError::InvalidInput(format!( + "buzz-visibility must be 'listed' or 'unlisted' (got {vis:?})" + ))); + } + } + + let mut tags: Vec = Vec::new(); + tags.push(tag(&["d", slug])?); + + if let Some(n) = name { + tags.push(tag(&["name", n])?); + } + if let Some(d) = description { + tags.push(tag(&["description", d])?); + } + for m in members { + let tag_parts = m.to_tag_parts(); + let parts: Vec<&str> = tag_parts.iter().map(|s| s.as_str()).collect(); + // Safety: to_tag_parts always produces ["a", coord, ...hint] + tags.push( + Tag::parse(parts.iter().copied()).map_err(|e| SdkError::InvalidTag(e.to_string()))?, + ); + } + if let Some(ch) = channel { + tags.push(tag(&["buzz-channel", ch])?); + } + if let Some(vis) = visibility { + tags.push(tag(&["buzz-visibility", vis])?); + } + + build_project_with_tags("", tags) +} + +/// **Generic NIP-09 coordinate delete**: Build a kind:5 deletion event with +/// a single `a`-tag addressing `::`. +/// +/// Validates: +/// - `kind` is an addressable kind (10000–19999 or 30000–39999). +/// - `pubkey` is a 64-character lowercase hex string. +/// - `d` is non-empty. +/// +/// `build_workflow_delete` delegates to this function. +pub fn build_delete_addressable( + kind: u32, + pubkey: &str, + d: &str, +) -> Result { + let is_addressable = (10000..20000).contains(&kind) || (30000..40000).contains(&kind); + if !is_addressable { + return Err(SdkError::InvalidInput(format!( + "kind {kind} is not an addressable kind (must be 10000–19999 or 30000–39999)" + ))); + } + let pk = check_pubkey_hex(pubkey, "pubkey")?; + if d.is_empty() { + return Err(SdkError::InvalidInput("d must not be empty".into())); + } + let coord = format!("{kind}:{pk}:{d}"); + let tags = vec![tag(&["a", &coord])?]; + Ok(EventBuilder::new(Kind::Custom(KIND_DELETION as u16), "").tags(tags)) +} + #[cfg(test)] mod tests { use super::*; @@ -3884,4 +4237,280 @@ mod tests { .iter() .any(|t| t.as_slice().first().map(String::as_str) == Some("replaced-by"))); } + + // ── NIP-MP cap-before-arity ordering ───────────────────────────────────── + + /// When an envelope exceeds the member cap AND contains a malformed `a` tag, + /// the validator must fire `member-cap` (rule 3) — not `member-tag-arity` + /// (rule 4). This matches the relay's ingest ordering and means a client + /// sending an oversized list never receives a per-tag parse error. + #[test] + fn validate_project_envelope_cap_wins_over_arity_when_both_fail() { + let owner = "a".repeat(64); + // Build 65 well-formed `a` tags — enough to trigger the cap. + let mut tags = vec![Tag::parse(["d", "platform"]).unwrap()]; + for i in 0..65usize { + let coord = format!("30617:{owner}:repo-{i}"); + tags.push(Tag::parse(["a", &coord]).unwrap()); + } + // Also add one malformed tag (four elements) that would fire + // member-tag-arity if evaluated before the cap check. + let coord_extra = format!("30617:{owner}:repo-extra"); + tags.push( + Tag::parse([ + "a", + &coord_extra, + "wss://relay.example.com", + "extra-element", + ]) + .unwrap(), + ); + + let err = validate_project_envelope(&tags, "").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("member-cap"), + "expected member-cap to win, got: {msg}" + ); + assert!( + !msg.contains("member-tag-arity"), + "arity rule must not fire before cap rule, got: {msg}" + ); + } + + // ── Layer B writer-policy builder ─────────────────────────────────────── + + const OWNER64: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const VALID_UUID: &str = "3580ca9b-47b4-4af9-b22a-1068778f26c6"; + + fn member_coord(repo: &str) -> ProjectMemberCoord { + ProjectMemberCoord::parse_full(&format!("30617:{OWNER64}:{repo}")).unwrap() + } + + #[test] + fn build_project_emitted_envelope_has_correct_shape() { + // slug, name, description, channel, visibility, and one member. + let m = member_coord("buzz"); + let ev = sign( + build_project( + "my-proj", + Some("My Project"), + Some("A description"), + &[m], + Some(VALID_UUID), + Some("listed"), + ) + .expect("Layer B must accept valid inputs"), + ); + + // Kind must be 30621. + assert_eq!(ev.kind.as_u16(), KIND_PROJECT as u16); + // Content must be empty (Layer B policy). + assert!(ev.content.is_empty(), "content must be empty"); + + let all_tags: Vec> = ev.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + // d tag must be present exactly once. + let d_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "d").collect(); + assert_eq!(d_tags.len(), 1); + assert_eq!(d_tags[0][1], "my-proj"); + + // name, description, buzz-channel, buzz-visibility present. + let name_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "name").collect(); + assert_eq!(name_tags.len(), 1); + assert_eq!(name_tags[0][1], "My Project"); + + let desc_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "description").collect(); + assert_eq!(desc_tags.len(), 1); + assert_eq!(desc_tags[0][1], "A description"); + + let ch_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "buzz-channel").collect(); + assert_eq!(ch_tags.len(), 1); + assert_eq!(ch_tags[0][1], VALID_UUID); + + let vis_tags: Vec<_> = all_tags + .iter() + .filter(|t| t[0] == "buzz-visibility") + .collect(); + assert_eq!(vis_tags.len(), 1); + assert_eq!(vis_tags[0][1], "listed"); + + // member a tag. + let a_tags: Vec<_> = all_tags.iter().filter(|t| t[0] == "a").collect(); + assert_eq!(a_tags.len(), 1); + assert_eq!(a_tags[0][1], format!("30617:{OWNER64}:buzz")); + } + + #[test] + fn build_project_optional_fields_absent_when_not_supplied() { + let m = member_coord("core"); + let ev = sign( + build_project("my-proj", None, None, &[m], None, None) + .expect("minimal build must succeed"), + ); + let names: Vec<_> = ev + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("name")) + .collect(); + assert!(names.is_empty(), "name tag must not be emitted when absent"); + } + + #[test] + fn build_project_rejects_empty_slug() { + let m = member_coord("r"); + let err = build_project("", None, None, &[m], None, None).unwrap_err(); + assert!( + matches!(err, SdkError::InvalidInput(_)), + "empty slug must be InvalidInput, got: {err:?}" + ); + assert!(err.to_string().contains("empty")); + } + + #[test] + fn build_project_rejects_overlong_slug() { + let long_slug = "a".repeat(PROJECT_D_MAX_LEN + 1); + let m = member_coord("r"); + let err = build_project(&long_slug, None, None, &[m], None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn build_project_rejects_invalid_channel_uuid() { + let m = member_coord("r"); + let err = build_project("slug", None, None, &[m], Some("not-a-uuid"), None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(err.to_string().contains("UUID") || err.to_string().contains("uuid")); + } + + #[test] + fn build_project_rejects_invalid_visibility_token() { + let m = member_coord("r"); + let err = build_project("slug", None, None, &[m], None, Some("chartreuse")).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!(err.to_string().contains("listed") || err.to_string().contains("unlisted")); + } + + #[test] + fn build_project_rejects_over_cap_members() { + let members: Vec<_> = (0..=PROJECT_MEMBER_CAP) + .map(|i| member_coord(&format!("repo-{i}"))) + .collect(); + let err = build_project("slug", None, None, &members, None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!( + err.to_string().contains("member-cap"), + "over-cap must report member-cap, got: {err}" + ); + } + + #[test] + fn build_project_rejects_duplicate_members() { + let m = member_coord("same"); + let err = build_project("slug", None, None, &[m.clone(), m], None, None).unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + assert!( + err.to_string().contains("dedup") || err.to_string().contains("duplicate"), + "duplicate member must report dedup, got: {err}" + ); + } + + #[test] + fn build_project_content_is_always_empty() { + // build_project forces content="" regardless; Layer A also enforces + // that the envelope is valid. Any non-empty content would be dropped. + // This test pins the Layer B content-forced-empty policy. + let m = member_coord("r"); + let ev = sign(build_project("slug", None, None, &[m], None, None).unwrap()); + assert!( + ev.content.is_empty(), + "Layer B must always emit empty content" + ); + } + + // ── NIP-MP conformance fixtures ────────────────────────────────────────── + // `build_project_with_tags` directly. Accept cases must build; reject + // cases must fail with an error message containing the expected rule name. + // A count assertion guards against silent omissions. + // + // `include_str!` path is relative to this source file. + fn nip_mp_fixture_tags(json_tags: &serde_json::Value) -> Vec { + json_tags + .as_array() + .unwrap() + .iter() + .map(|t| { + let parts: Vec = t + .as_array() + .unwrap() + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + let parts_ref: Vec<&str> = parts.iter().map(String::as_str).collect(); + Tag::parse(parts_ref.iter().copied()) + .unwrap_or_else(|e| panic!("fixture tag parse error: {e}\n raw: {t}")) + }) + .collect() + } + + #[test] + fn nip_mp_fixtures_all_31_cases_exercised() { + const FIXTURE_JSON: &str = include_str!("../../../docs/nips/NIP-MP.fixtures.json"); + + let data: serde_json::Value = + serde_json::from_str(FIXTURE_JSON).expect("fixture JSON must parse"); + let cases = data["cases"].as_array().expect("cases must be array"); + + // Count gate: the spec says "required to test against this one file" + // with the exact count as-shipped. + assert_eq!( + cases.len(), + 31, + "expected 31 fixture cases, got {} — was NIP-MP.fixtures.json edited?", + cases.len() + ); + + let mut accept_count = 0usize; + let mut reject_count = 0usize; + + for case in cases { + let name = case["name"].as_str().unwrap(); + let expect = case["expect"].as_str().unwrap(); + let template = &case["template"]; + let content = template["content"].as_str().unwrap_or(""); + let tags = nip_mp_fixture_tags(&template["tags"]); + + match expect { + "accept" => { + build_project_with_tags(content, tags).unwrap_or_else(|e| { + panic!("fixture '{name}' (accept) must build successfully, got: {e}") + }); + accept_count += 1; + } + "reject" => { + let reject_rules = case["reject_rules"] + .as_array() + .expect("reject case must have reject_rules") + .iter() + .map(|r| r.as_str().unwrap().to_string()) + .collect::>(); + + let err = build_project_with_tags(content, tags).unwrap_err(); + let err_msg = err.to_string(); + + // The error must mention at least one of the expected rules. + let rule_matched = reject_rules.iter().any(|r| err_msg.contains(r.as_str())); + assert!( + rule_matched, + "fixture '{name}' rejected with wrong rule.\n expected one of: {reject_rules:?}\n got error: {err_msg}" + ); + reject_count += 1; + } + other => panic!("fixture '{name}' has unknown expect value: {other:?}"), + } + } + + assert_eq!(accept_count, 11, "expected 11 accept cases"); + assert_eq!(reject_count, 20, "expected 20 reject cases"); + } } From fc598f5f8d70728d11d0712b9fa8e3acc44ea4c3 Mon Sep 17 00:00:00 2001 From: Tal Weiss Date: Sun, 2 Aug 2026 18:29:41 +0100 Subject: [PATCH 004/163] fix(git): allow deleting the default branch (#4297) Tal here, human. Trying to help. This bug bugged me... ## Summary A repository's first branch becomes its symbolic `HEAD`, and Git's bare-repository default rejects deleting that branch even when another branch survives. This change: - sets `receive.denyDeleteCurrent=ignore` only for the ephemeral `git receive-pack` process - preserves the existing server-side `core.hooksPath` override and authorization hook - lets the existing CAS publication logic select a surviving branch as the next manifest `HEAD` - adds regression coverage using a real stateless `git receive-pack` request and a manifest HEAD-selection test This lets users replace an accidental default branch without deleting the object-storage manifest pointer. ### Related issue Fixes #3572 ### Testing - `cargo test -p buzz-relay api::git::` (128 passed, 5 ignored) - `just ci` - live E2E roundtrip against a release relay with PostgreSQL, Redis, and MinIO: - created a repository through signed Nostr events - verified authorized pushes and rejected unauthorized clone/push - pushed a surviving `master` branch - deleted the active `main` branch over authenticated Smart HTTP - freshly cloned the repository and verified `master` became HEAD, `origin/main` was absent, and repository content remained intact Signed-off-by: Tal Weiss --- crates/buzz-relay/src/api/git/cas_publish.rs | 9 + crates/buzz-relay/src/api/git/transport.rs | 163 ++++++++++++++++++- 2 files changed, 165 insertions(+), 7 deletions(-) diff --git a/crates/buzz-relay/src/api/git/cas_publish.rs b/crates/buzz-relay/src/api/git/cas_publish.rs index c213e2913e8..50bb36d818c 100644 --- a/crates/buzz-relay/src/api/git/cas_publish.rs +++ b/crates/buzz-relay/src/api/git/cas_publish.rs @@ -1370,6 +1370,15 @@ mod tests { ); } + #[test] + fn published_head_moves_to_surviving_branch_after_current_branch_deletion() { + let refs = BTreeMap::from([("refs/heads/master".to_string(), "1".repeat(40))]); + assert_eq!( + resolve_published_head(&refs, "refs/heads/main".to_string(), "refs/heads/main"), + "refs/heads/master" + ); + } + #[test] fn digest_from_key_strips_prefix() { let k = format!("manifests/{}", "a".repeat(64)); diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index f8e0300277c..d3118d8a761 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -1064,7 +1064,7 @@ pub async fn receive_pack( state.config.bind_addr.port() ); let hooks_dir = repo.path().join("hooks").display().to_string(); - let hook_env = vec![ + let mut hook_env = vec![ ("BUZZ_HOOK_URL", hook_url), ( "BUZZ_HOOK_SECRET", @@ -1077,13 +1077,8 @@ pub async fn receive_pack( auth.tenant.community().as_uuid().to_string(), ), ("BUZZ_PUSHER_PUBKEY", pusher_hex.clone()), - // Override any repo-local core.hooksPath setting; defense in - // depth even though the hydrated workspace has no inherited - // config. - ("GIT_CONFIG_COUNT", "1".to_string()), - ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()), - ("GIT_CONFIG_VALUE_0", hooks_dir), ]; + hook_env.extend(receive_pack_git_config(hooks_dir)); // Run receive-pack against the tempdir. Returns the *owned* subprocess // output (PackOutput) — crucially NOT a Response, so the post-push @@ -1111,6 +1106,23 @@ pub async fn receive_pack( Ok(finalize_push(&state, ctx).await) } +/// Per-process git configuration for the hydrated receive-pack workspace. +fn receive_pack_git_config(hooks_dir: String) -> Vec<(&'static str, String)> { + vec![ + // Override any repo-local core.hooksPath setting; defense in depth + // even though the hydrated workspace has no inherited config. + ("GIT_CONFIG_COUNT", "2".to_string()), + ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()), + ("GIT_CONFIG_VALUE_0", hooks_dir), + // A bare repository rejects deletion of its symbolic HEAD branch by + // default. Hydrated repositories are ephemeral, and cas_publish + // selects a surviving branch for the next manifest HEAD, so allow + // receive-pack to apply the deletion before that selection runs. + ("GIT_CONFIG_KEY_1", "receive.denyDeleteCurrent".to_string()), + ("GIT_CONFIG_VALUE_1", "ignore".to_string()), + ] +} + /// Buffered output of a `git --stateless-rpc` subprocess. /// /// The handler holds this as an owned value between subprocess completion @@ -1921,11 +1933,148 @@ mod track_c_tests { use buzz_core::CommunityId; use nostr::{EventBuilder, Keys, Kind, Tag}; use std::collections::BTreeMap; + use std::io::Write; + use std::process::Output; fn oid_sha1() -> String { "cb09a769da1c01f458fa6959d4e8eded38fac8d3".to_string() } + fn run_test_git(cwd: &Path, args: &[&str], extra_env: &[(&str, String)]) -> Output { + let mut cmd = std::process::Command::new("git"); + cmd.current_dir(cwd) + .args(args) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null"); + for (key, value) in extra_env { + cmd.env(key, value); + } + cmd.output().expect("run git") + } + + fn run_test_receive_pack(repo: &Path, request: &[u8], extra_env: &[(&str, String)]) -> Output { + let mut cmd = std::process::Command::new("git"); + cmd.arg("receive-pack") + .arg("--stateless-rpc") + .arg(repo) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null"); + for (key, value) in extra_env { + cmd.env(key, value); + } + + let mut child = cmd.spawn().expect("spawn receive-pack"); + child + .stdin + .take() + .expect("receive-pack stdin") + .write_all(request) + .expect("write receive-pack request"); + child.wait_with_output().expect("wait for receive-pack") + } + + fn assert_git_success(output: Output, operation: &str) { + assert!( + output.status.success(), + "{operation} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + #[test] + fn receive_pack_config_allows_deleting_current_branch() { + let root = tempfile::TempDir::new().expect("tempdir"); + let remote = root.path().join("remote.git"); + let source = root.path().join("source"); + let remote_arg = remote.to_str().expect("utf-8 remote path"); + let source_arg = source.to_str().expect("utf-8 source path"); + + assert_git_success( + run_test_git( + root.path(), + &["init", "--bare", "--initial-branch=main", remote_arg], + &[], + ), + "initialize bare remote", + ); + assert_git_success( + run_test_git( + root.path(), + &["init", "--initial-branch=main", source_arg], + &[], + ), + "initialize source repository", + ); + assert_git_success( + run_test_git(source.as_path(), &["config", "user.name", "Buzz Test"], &[]), + "configure user name", + ); + assert_git_success( + run_test_git( + source.as_path(), + &["config", "user.email", "buzz-test@example.com"], + &[], + ), + "configure user email", + ); + std::fs::write(source.join("README.md"), "test\n").expect("write fixture"); + assert_git_success( + run_test_git(source.as_path(), &["add", "README.md"], &[]), + "stage fixture", + ); + assert_git_success( + run_test_git(source.as_path(), &["commit", "-m", "fixture"], &[]), + "commit fixture", + ); + assert_git_success( + run_test_git( + source.as_path(), + &["push", remote_arg, "main:main", "main:master"], + &[], + ), + "seed main and master", + ); + + let oid_output = run_test_git(remote.as_path(), &["rev-parse", "refs/heads/main"], &[]); + assert!(oid_output.status.success()); + let old_oid = String::from_utf8(oid_output.stdout) + .expect("utf-8 oid") + .trim() + .to_string(); + let command = format!( + "{old_oid} {} refs/heads/main\0report-status\n", + "0".repeat(40) + ); + let mut request = format!("{:04x}", command.len() + 4).into_bytes(); + request.extend_from_slice(command.as_bytes()); + request.extend_from_slice(b"0000"); + + let git_config = receive_pack_git_config(remote.join("hooks").display().to_string()); + let output = run_test_receive_pack(remote.as_path(), &request, &git_config); + assert!( + output.status.success(), + "receive-pack failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !receive_pack_report_rejected(&output.stdout), + "receive-pack rejected the deletion: {}", + String::from_utf8_lossy(&output.stdout) + ); + + assert!(!remote.join("refs/heads/main").exists()); + assert!(remote.join("refs/heads/master").exists()); + } + /// A gzip-encoded request body is transparently inflated before it /// reaches the git subprocess. Git's smart-HTTP client gzips the /// upload-pack/receive-pack request body past a size threshold (fires From 6530b58a61d4602d0a371100fedf80c5998b1e34 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:39:27 -0400 Subject: [PATCH 005/163] feat(k8s): Kubernetes backend plugin + desktop deploy path (#4289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Kubernetes backend plugin (crates/buzz-backend-kubernetes) + desktop deploy path Implements docs/remote-agents.md (merged @ 28ae6cd21) as ONE PR: the provider binary, the desktop changes that make it work, the harness inactivity reaper, the Sprig image, and the conformance/live-test suites. Channel: buzz-remote-agents (29414326-dba7-402d-b384-b1b34d63a2e6), thread c42b70ef. ## What's here (by lane) - **crates/buzz-backend-kubernetes** (Dawn): stdin/stdout JSON provider, info + deploy; pure classify.rs (one match arm per spec state-machine row); reconcile/GC with ownership-marker gate + same-clock orphan check; per-attempt immutable Secrets; three-tier env with clear-then-write authoritative tier. - **Desktop** (Mari): KD3 launch block from resolved descriptor, KD5 pre-secret negotiation gate (resolve-once → stage-and-digest → info → protocol gate → deploy), KD1 Windows extension strip, bundling (externalBin + Justfile + release/canary workflows + stub loops), tauri.windows.conf.json platform override (Decision B: no Windows artifact). - **buzz-acp** (Max): KD4 BUZZ_ACP_EXIT_AFTER_INACTIVITY reaper (pool-independent; reset only at accepted dispatch; in-flight turn/heartbeat defers, never resets); BUZZ_ACP_EXIT_AFTER_INACTIVITY + BUZZ_ACP_NO_PRESENCE reserved. KD8 fix. - **Image + tests** (Perci): Dockerfile.sprig (digest-pinned bases, exec buzz-acp PID 1, relay-scoped credential config), image contract script, provider conformance suites (golden wire fixtures shared with desktop tests), live-local runbook (namespace-scoped, shared-cluster safe). - **Docs** (Sami, first commit): citation re-pin c1bca1b56 → 28ae6cd21 (44/49 were already byte-exact; 3 offsets fixed) + I3 presence-bound correction (below). ## Named spec deviations (deliberate, each with rationale) 1. **No baked default image yet.** ghcr.io/block/buzz-sprig is unpublished (verified: anonymous pull 403 vs control 200). Omitted `image` returns an in-band field-required error instead of a default. 2. **Image override STRICTER than spec §Image:** digest-only (`name@sha256:<64hex>`); ALL tags rejected; `name:tag@digest` normalized. With no baked default the override is the only path, so tag-acceptance would make mutability the v1 norm. Strictness is reversible; a moved tag under an nsec is not. Baked digest default + tag re-acceptance = follow-up with image publish. 3. **imagePullSecrets not in schema (v1).** Explicit user images may rely on namespace-preprovisioned pull credentials — the substrate boundary. Field added only if the publish decision proves it necessary. 9-field budget intact. 4. **Decision A closed: writable empty workspace.** Nest projection = named follow-up; no image-side scaffolding. 5. **Decision D overridden by Tyler (event b55398d8):** provider ships bundled with the desktop like buzz-acp/buzz-agent; spec §Distribution's separate release workflow deleted for v1. 6. **I3/vision presence bound corrected 90s → 180s.** PRESENCE_TTL_SECS moved in #3783 during this spec's base→merge window; the number was inherited, not chosen. Spec :206/:216/:928 + inline quote + VISION_REMOTE_AGENTS.md:59 corrected. ← Tyler: the vision is your document; this edit is flagged for your explicit eyes. 7. **Spec citations are pinned to 28ae6cd21** (main at spec merge) and resolve there, not at this PR's head — this PR's own lanes move crates/buzz-acp/src/lib.rs by ~100 lines (19 citations across KD4/KD6/KD7/ §Stop/§Launch data). Known Defects rows fixed BY this PR retire on merge; the section documents main as of the pin. 8. **KD7 grace tension declared:** pod terminationGracePeriodSeconds=60 vs KD7's measured ~87s shutdown tail at parallelism 10 (~197s at cap 32). KD7 is ruled out of scope, so L1-3's "enough grace for full graceful shutdown" is NOT met at default config — deliberate, resolved by the KD7 follow-up, not silently. ## Question for Tyler Will ghcr.io/block/buzz-sprig publish PUBLIC? If private-by-policy, §Image needs an imagePullSecrets story before the baked-default follow-up can land. ## Out of scope (named follow-ups) KD6 exit-code contract + KD7 shutdown budget (gate OnFailure), OnFailure restart policy, Windows provider binary, PVCs/nest projection, mesh deployability, sprig image publish workflow + baked multi-arch digest default. ## Reproduce locally (four traps that cost us real time) **1. Git hooks inherit the invoking shell's PATH — pin the shell, not just your verification commands.** `rust-toolchain.toml` pins `1.95.0`, but the rustup shim that honors that pin lives in `~/.cargo/bin`. If Homebrew's cargo is earlier on PATH, `cargo` in this repo is 1.89.0, which cannot build the workspace at all: ``` $ /opt/homebrew/bin/cargo check -p buzz-db error: rustc 1.89.0 is not supported by the following packages: sqlx@0.9.0 requires rustc 1.94.0 ... # exit 101 ``` Verifying with `PATH="$HOME/.cargo/bin:$PATH" cargo test` does *not* protect the push: lefthook's `pre-push` → `just test-unit` re-resolves `cargo` from the shell's own PATH, so a green local run is followed by a hook failure on a crate you never touched. Export the PATH for the whole shell, not per-command. This bit twice. **2. Line-scope your mutations, or the mutation edits its own detector.** When mutation-testing the respond-to guard, a whole-file `sed` on the mode literal touches 5 sites — the guard *and* the fixtures/assertions that test it. The mutation and its detector move together and the suite stays green, which reads as "this code is dead" when it actually means "you deleted the experiment": ``` # WRONG — 5 sites, guard and tests mutate together $ sed -i '' 's/"allowlist"/"allowlist-DISABLED"/g' src/env.rs test result: ok. 145 passed; 0 failed # false survivor # RIGHT — 1 site, anchored to the guard's own definition line $ sed -i '' '/^const RESPOND_TO_ALLOWLIST/s/"allowlist"/"allowlist-DISABLED"/' src/env.rs failures: env::tests::allowlist_mode_with_an_empty_list_is_refused env::tests::an_allowlist_entry_that_is_not_64_hex_is_refused test result: FAILED. 143 passed; 2 failed # real kill ``` Restore by copying a pristine file back and confirming `git diff --stat` is empty, not by re-running an inverse `sed`. **3. A completeness guard is not a correctness guard.** The shared wire fixture `tests/fixtures/provider-wire/deploy-full-launch.request.json` passed every test we had while containing four classes of invented data (wrong `respond_to` encoding, an env key no emitter writes, allowlist entries that fail the harness's own 64-hex rule, a `launch.env` key from no descriptor layer). The provider's tests could not have caught this: its types are deliberately indifferent to these values (`Option`, `Vec`, arbitrary map), so "the provider parses it" was never evidence that the desktop emits it. The fix was not a stronger provider assertion but a rule about provenance — "recorded" means executed-and-transcribed, and the desktop's whole-object equality test is the only enforcement that can exist. See the fixture README. **4. Every drift this arc was a value that agreed with itself.** Five invented values were found, and not one was caught by an assertion failing — each was caught by someone asking where a value came from. A named constant referenced symbolically on both the fixture and assertion side. A `sed` that mutated its own detector. Six probe rows that all died at the same unrelated error. A descriptor struct literal compared against a fixture built from that literal (`launch.args: ["run","--session"]`, which the resolver actually returns as `["acp"]`). The general defense is not more assertions but provenance: a stub is a control that varies nothing, and the more faithful it looks the better it hides. Ask what executed, not what passed. *Fixture-test determinism caveat (post-verification, Quinn + Dawn).* The desktop's whole-object fixture test calls the real resolver, which consults a process-global harness registry whose own docs require `registry_test_lock` for any test touching it. The fixture test holds no lock and is nonetheless deterministic — but by containment, not by ordering. Measured, not derived: planting a definition with `id: "goose"` directly into the registry (bypassing the loader) changes the resolved descriptor from `args: ["acp"]` to `args: ["--poisoned"]`, so `resolve_effective_harness_descriptor` **does** reach the registry for this id — it does not short-circuit on the builtin table first. Two controls discriminate: an empty registry and a registry poisoned under a *different* id both return `["acp"]`. What actually protects the test is that the registry has exactly one writer (`update_loaded_harness_registry`, reached only via `warm_harness_registry_from_dir`) — but that writer concatenates **two** sources of unequal strength (`custom_harnesses.rs:319-326`). Custom files pass through `load_custom_harnesses`, whose `check_id_collision` rejects the reserved builtin id `goose` case-insensitively at the loader — and that leg is tested (`load_applies_id_collision_check` writes a real `goose.json` and asserts the loader drops it). Preset definitions (`preset_harness_definitions`, `presets.rs:177-193`) are a bare `.map` over `PRESET_HARNESSES` with **no collision check** — exhaustive call-site enumeration at `60007fda4` finds four production `check_id_collision` sites, none on the preset path. That leg holds only because `goose` is not in the preset table today (intersection of TIER1 and preset ids is empty) — executed, not just read: adding a preset with `id: "goose"`, `args: ["--poisoned"]` and warming via the normal preset-only path (`warm_harness_registry_from_dir(None)`, no custom dir, no direct writer) flips the fixture's emitted `launch.args` from `["acp"]` to `["--poisoned"]` at `60007fda4`, command/env/policy_env unchanged. So: no test in the suite can put a `goose` entry in the registry via the custom path, and no preset currently carries one, so no interleaving can perturb this fixture — containment with one checked leg and one coincidental one. A future fixture built on a **non-builtin** runtime id has no containment at all — it would be order-dependent against whatever registry-writing test ran last and must take the lock. *Late instance, found while reviewing the mode guard.* The guard exact-matches `respond_to` untrimmed and case-sensitively, which is only correct if clap's `ValueEnum` derive is case-sensitive. `config.rs` gives two answers: the derive at `:448-453` carries no `ignore_case`, while the crate's own tests call `RespondTo::from_str(s, true)` — `ignore_case = true`. Reading the source supports either. Measured on the built binary instead: `owner-only` starts, `OWNER-ONLY` / `Owner-Only` / `ALLOWLIST` / `NOBODY` all exit rc=2 `invalid value`. Case-sensitive at the CLI, so the guard is right — and right for a reason the source does not state. The `from_str(_, true)` tests exercise a different surface and are not evidence about the CLI. *Corollary, and the sharper half.* When a test helper **reimplements** production instead of calling it, the helper is a fork — and a fork can be right while production is wrong, or wrong in the same way, and the suite reports green either way. Both `BUZZ_ACP_ALLOWED_*` gates are forked like this: production compares **strings** while the helpers compare **post-parse enums** (`config.rs:2623`) or re-derive the split (`buzz-cli/.../channels.rs:1296`). Production and the helper each carry their *own* copy of the empty-entry filter (`:1025` and `:1300`), so fixing one says nothing about the other. Measured on `buzz-cli`, restoring byte-exact between runs: | tree | result | |---|---| | baseline | 274 passed | | drop the empty-filter in **production** only (the real fix) | **274 passed** — no signal | | drop it in the **test helper** only | **273 passed, 1 failed** (`channels.rs:1338`) | Two independent defects, stacked, and worse together than either alone: production can be fixed with no test ever noticing, *and* the helper cannot be corrected without a false alarm demanding the bug back. The root cause is one bit of type information — `check_allowed_channel_add_policy(allowed_raw: &str, ..)` cannot represent "unset", while production reads `env::var(..) -> Result`, where unset and `""` are different states. A helper whose parameter type can't represent all of production's input states isn't testing production's states — it's testing a subset it silently chose. Same family as the struct-literal descriptor and the fixture drift: the test and the thing it tests agreeing with each other, rather than the test measuring the thing. Neither defect is in this PR's diff (`git diff --name-only 28ae6cd21 -- crates/buzz-cli` is empty); both are now filed as NIP-34 issues on this repo: the fail-open + fork-helper defect at issue event `0524a4113f2d97fd…` and the respond-to self-lock at `e32837498969b5e7…` (filed 2026-08-02 after Quinn measured that no prior filing existed — zero hits on GitHub `block/buzz` open *or* closed and zero on the relay's kind:1621 issues, against working positive controls). The prescription was itself mutation-tested before being written down: repairing the fork's signature (`Option<&str>` + assertion → `None`) still let the reintroduced production bug ship 274-green — an expressive fork is still a fork; it never executes production. So the `buzz-cli` fix has **three parts and one explicit keep**: drop the production filter; **delete** the helper and point its tests at the real `cmd_set_add_policy` (which self-discriminates by error variant — `Usage` = refused, `Network(BadScheme)` = passed the gate — no relay needed); serialize the env-var tests behind one **`tokio::sync::Mutex::const_new`** lock taken with `.lock().await`, including the pre-existing `:1362` integration test (the fork was silently buying test isolation — without the lock, parallel runs flake nondeterministically; a `std::sync::Mutex` held across `.await` trips `clippy::await_holding_lock` under `-D warnings`); and **keep** the then-dead `!allowed.is_empty()` clause with a comment saying why. It is unreachable-false (`split(',')` never yields an empty vec), but it is the only thing that keeps the reintroduced production bug detectable — mutation-tested: on a tree that deletes the clause, reintroducing the empty-filter bug survives 275/0, because `""`/`","`/`" "` refuse either way and the filter goes semantically inert. Dead code can be load-bearing for tests: "provably unreachable" is an argument about behavior, never about coverage. When a helper forks production, the fix has to delete the fork: any change that leaves two implementations standing can only ever be verified against the one the tests call. *Final shape:* the keep and the broad lock are both artifacts of the fork surviving in some form. The extraction variant (Dawn, mutation-tested at `60007fda4`) removes the tension: extract one `check_channel_add_policy_allowed(Option<&str>, &str)` that **production calls**, with the `Option` placed at the env boundary where the `Result` bit actually lives. 5/6 mutants killed; the empty-filter survivor is proven **equivalent** (exhaustive 6174-pair check, 0 divergences, with a diverging negative control; independently re-derived by a second generator — different tokens and shape — 0 divergences on admitted policies, 500 on a non-admitted control), not a coverage hole — on a one-implementation tree there is no fork left to witness, so no dead clause needs keeping. One scope line on that equivalence: it is **caller-conditional**, a property of the only current caller, not of the gate function — `cmd_set_add_policy`'s own match at `:1027-1034` admits only three policies before the gate runs; a second caller reaching the gate with arbitrary strings resurrects m1 as a real hole. The lock does not disappear, it narrows (Dawn's own correction, caught by Mari): lock exactly the tests that mutate the process env — three-plus-one on a fork tree, two on the extraction tree — behind one `tokio::sync::Mutex`, and the lock is part of the assertion, not hygiene: with it deleted, the gate test fails 8/8 runs deterministically by receiving `Network(BadScheme)` where it expects `Usage` — the unset test's `remove_var` clobbers the other's `set_var`, and **the gate test passes straight through the gate**, a false negative on the exact authz assertion the test exists to make. State it as an outcome: these two tests must not observe each other's env writes. 276/0 stable across 5 parallel runs, clippy `-D warnings` clean; independently verified (patch applied to a second worktree: result blob `d67e584be` matches the patch index, full mutant matrix reproduces row for row). One new row no earlier prescription covered: collapsing unset into `Some("")` fails **closed** — an unconfigured deployment refuses every policy — killed by the unset test. Patch: `OUTBOX/BUZZ_CLI_ADD_POLICY_GATE_EXTRACT_FIX.patch`. The filed issue (`0524a411…`) carries the fork-shape prescription; whoever picks it up should prefer the extraction shape, drop the dead-clause keep with it, and keep part 3 outcome-shaped: serialize whichever tests mutate the env. ## Verification (final HEAD `60007fda4`) - Full touched-package suites at each integration merge (log in plan file). At candidate parent `00e5b5fe9`: buzz-backend-kubernetes 154, buzz-acp 673, desktop tauri 2100+3, pnpm 3908, workspace clippy/fmt/tsc all clean. The only delta to `60007fda4` is one character in `scripts/test-k8s-sprig-image-live.sh` (heredoc escape so the readlink probe evaluates pod-side, not host-side at render); `crates/` tree hash is byte-identical at both SHAs, so the Rust receipts attach by tree identity. buzz-backend-kubernetes suite re-run in-shell at `HEAD == 60007fda4`: 154 passed. - Adversarial one-HEAD gate (Sami): guard matrix 12/12, predicate mutants 7/7, doomed-invocation finding closed end-to-end; tree-hash carry to `60007fda4` confirmed (crates/buzz-backend-kubernetes blob unchanged). - Live-local pass per TESTING.md + skill-buzz-testing (Perci, at `60007fda4`): explicit `docker-desktop` context, digest-qualified image imported into node containerd `k8s.io` namespace, pull policy `Never`; pod printed `DIGEST_ABI_OK`, `resolved_spec` and `image_id` both the exact requested digest, script exit 0. Dedicated per-run namespace, ownership labels on every object, scoped cleanup verified empty after. - Implementation review (Wren) at `60007fda4`: 9.6 minimalness / 9.4 elegance / 9.3 correctness, no blocker. - `origin/eva/k8s-backend` == `60007fda4` (ls-remote verified; SHA identity is byte identity). --------- Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Dawn (sprout agent) Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> --- .../auto-tag-on-release-pr-merge.yml | 2 +- .github/workflows/benchmark-harbor.yml | 2 +- .github/workflows/ci.yml | 33 +- .github/workflows/docker.yml | 4 +- .github/workflows/helm-chart.yml | 6 +- .github/workflows/linux-canary.yml | 2 +- .github/workflows/release.yml | 18 +- .github/workflows/signed-macos-canary.yml | 2 +- .github/workflows/sprig-image.yml | 233 +++ .github/workflows/sprig.yml | 6 +- Cargo.lock | 149 ++ Cargo.toml | 8 + Dockerfile.sprig | 44 + Justfile | 26 +- VISION_REMOTE_AGENTS.md | 2 +- crates/buzz-acp/src/config.rs | 25 + crates/buzz-acp/src/lib.rs | 119 +- crates/buzz-acp/src/queue.rs | 5 + crates/buzz-backend-kubernetes/Cargo.toml | 36 + .../buzz-backend-kubernetes/src/classify.rs | 377 ++++ crates/buzz-backend-kubernetes/src/client.rs | 182 ++ crates/buzz-backend-kubernetes/src/cluster.rs | 427 +++++ crates/buzz-backend-kubernetes/src/config.rs | 443 +++++ crates/buzz-backend-kubernetes/src/env.rs | 732 ++++++++ crates/buzz-backend-kubernetes/src/gc.rs | 368 ++++ crates/buzz-backend-kubernetes/src/image.rs | 181 ++ crates/buzz-backend-kubernetes/src/intent.rs | 309 ++++ crates/buzz-backend-kubernetes/src/main.rs | 199 +++ crates/buzz-backend-kubernetes/src/naming.rs | 223 +++ crates/buzz-backend-kubernetes/src/observe.rs | 592 ++++++ crates/buzz-backend-kubernetes/src/pod.rs | 446 +++++ .../buzz-backend-kubernetes/src/reconcile.rs | 1585 +++++++++++++++++ crates/buzz-backend-kubernetes/src/wire.rs | 250 +++ .../tests/fixtures/provider-wire/README.md | 39 + .../deploy-full-launch.request.json | 52 + .../deploy-no-owner.request.json | 9 + .../deploy-no-owner.response.json | 1 + .../deploy-relay-mesh-padded.request.json | 11 + .../deploy-relay-mesh-padded.response.json | 1 + .../deploy-relay-mesh.request.json | 12 + .../deploy-relay-mesh.response.json | 1 + .../deploy-tag-image.request.json | 10 + .../deploy-tag-image.response.json | 1 + .../fixtures/provider-wire/info.request.json | 1 + .../tests/wire_fixtures.rs | 214 +++ desktop/scripts/build-release-config.mjs | 9 + desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 1 + desktop/src-tauri/src/commands/agents.rs | 2 +- .../src-tauri/src/commands/agents_deploy.rs | 246 ++- .../src-tauri/src/commands/agents_tests.rs | 138 +- .../src-tauri/src/managed_agents/backend.rs | 414 ++--- .../src/managed_agents/backend_tests.rs | 452 +++++ .../src-tauri/src/managed_agents/env_vars.rs | 27 +- .../src/managed_agents/env_vars/tests.rs | 9 + desktop/src-tauri/tauri.conf.json | 1 + desktop/src-tauri/tauri.windows.conf.json | 11 + .../agents/ui/ProviderConfigFields.test.mjs | 31 + .../agents/ui/ProviderConfigFields.tsx | 3 +- docs/remote-agents.md | 87 +- scripts/bundle-sidecars.sh | 8 +- scripts/run-tests.sh | 6 + scripts/sprig-entrypoint.sh | 16 + scripts/test-k8s-provider-release.sh | 78 + scripts/test-k8s-sprig-image-live.sh | 97 + scripts/test-sprig-image.sh | 43 + 66 files changed, 8633 insertions(+), 435 deletions(-) create mode 100644 .github/workflows/sprig-image.yml create mode 100644 Dockerfile.sprig create mode 100644 crates/buzz-backend-kubernetes/Cargo.toml create mode 100644 crates/buzz-backend-kubernetes/src/classify.rs create mode 100644 crates/buzz-backend-kubernetes/src/client.rs create mode 100644 crates/buzz-backend-kubernetes/src/cluster.rs create mode 100644 crates/buzz-backend-kubernetes/src/config.rs create mode 100644 crates/buzz-backend-kubernetes/src/env.rs create mode 100644 crates/buzz-backend-kubernetes/src/gc.rs create mode 100644 crates/buzz-backend-kubernetes/src/image.rs create mode 100644 crates/buzz-backend-kubernetes/src/intent.rs create mode 100644 crates/buzz-backend-kubernetes/src/main.rs create mode 100644 crates/buzz-backend-kubernetes/src/naming.rs create mode 100644 crates/buzz-backend-kubernetes/src/observe.rs create mode 100644 crates/buzz-backend-kubernetes/src/pod.rs create mode 100644 crates/buzz-backend-kubernetes/src/reconcile.rs create mode 100644 crates/buzz-backend-kubernetes/src/wire.rs create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json create mode 100644 crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json create mode 100644 crates/buzz-backend-kubernetes/tests/wire_fixtures.rs create mode 100644 desktop/src-tauri/src/managed_agents/backend_tests.rs create mode 100644 desktop/src-tauri/tauri.windows.conf.json create mode 100644 desktop/src/features/agents/ui/ProviderConfigFields.test.mjs create mode 100755 scripts/sprig-entrypoint.sh create mode 100755 scripts/test-k8s-provider-release.sh create mode 100755 scripts/test-k8s-sprig-image-live.sh create mode 100755 scripts/test-sprig-image.sh diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index f31d4b835f6..3a090b3ebd7 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -45,7 +45,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ github.event.pull_request.merge_commit_sha }} fetch-depth: 0 diff --git a/.github/workflows/benchmark-harbor.yml b/.github/workflows/benchmark-harbor.yml index 31efe933c58..6024f005754 100644 --- a/.github/workflows/benchmark-harbor.yml +++ b/.github/workflows/benchmark-harbor.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.12" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d392170f6ae..60507182d5c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: web: ${{ steps.filter.outputs.web }} mobile: ${{ steps.filter.outputs.mobile }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2 @@ -96,7 +96,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -117,7 +117,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 @@ -139,7 +139,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -235,7 +235,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Get pnpm store directory id: pnpm-cache @@ -318,7 +318,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 # Reuse the relay binaries and backend test archive when none of their # inputs changed (desktop-only PRs hit this every time). The key covers @@ -391,7 +391,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Start integration services run: | @@ -580,7 +580,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Install cargo-nextest uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 @@ -744,7 +744,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -785,7 +785,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -820,7 +820,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 2 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -881,7 +881,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - name: Dependency policy run: cargo-deny check @@ -893,7 +893,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Check for dead API token references in client code run: | # Fail if dead API token patterns reappear in desktop, mobile, docs, or config. @@ -922,7 +922,7 @@ jobs: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -959,7 +959,7 @@ jobs: env: TARGET: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 # MSVC needs windows.h (aws-lc-sys et al.), so this runs on a real Windows # runner — hermit, used by the Linux jobs, does not provide MSVC. The # toolchain (1.95.0 + clippy via profile = default) comes from the @@ -1040,7 +1040,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: @@ -1054,6 +1054,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-backend-kubernetes-$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/docker.yml b/.github/workflows/docker.yml index 52f21b28bc3..564cd74e9dd 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -101,7 +101,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -359,7 +359,7 @@ jobs: arch: arm64 steps: - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/helm-chart.yml b/.github/workflows/helm-chart.yml index e3d443d9f36..7118d16708d 100644 --- a/.github/workflows/helm-chart.yml +++ b/.github/workflows/helm-chart.yml @@ -59,7 +59,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # On chart-tag rescue dispatch, lint/render the tagged commit that the # publish job will package, not whatever `main` is when the dispatch @@ -119,7 +119,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -166,7 +166,7 @@ jobs: packages: write # push the chart to GHCR steps: - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: # On the rescue dispatch, build the tagged commit (github.ref is # `main` there); on a tag push, the default ref is already the tag. diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 98064433789..16648787708 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-backend-kubernetes -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 7d5f3fbf400..02011ad3867 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: exit 1 fi - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: fetch-depth: 0 persist-credentials: false @@ -64,7 +64,7 @@ jobs: env: VERSION: ${{ needs.setup.outputs.version }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -91,7 +91,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-backend-kubernetes -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. @@ -278,7 +278,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-apple-darwin steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -308,7 +308,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-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" - name: Build unsigned Tauri app @@ -495,7 +495,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends gh - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -563,7 +563,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-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh - name: Generate release config @@ -666,7 +666,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} TARGET: x86_64-pc-windows-msvc steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 @@ -795,7 +795,7 @@ jobs: VERSION: ${{ needs.setup.outputs.version }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: ref: ${{ needs.setup.outputs.source_sha }} fetch-depth: 0 diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index fb0656028af..0a3a513eef0 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-backend-kubernetes -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/sprig-image.yml b/.github/workflows/sprig-image.yml new file mode 100644 index 00000000000..5d5e12ae0cf --- /dev/null +++ b/.github/workflows/sprig-image.yml @@ -0,0 +1,233 @@ +name: Sprig image + +# Builds and publishes the public agent container image as +# ghcr.io/block/buzz-sprig — the digest-pinned box the Kubernetes backend +# deploys agents into (see Dockerfile.sprig and docs/remote-agents.md). +# +# Strategy mirrors docker.yml (the relay image): each architecture builds on +# its native runner, pushes to GHCR by digest, then a merge job stitches the +# per-arch digests into one multi-arch manifest and attests provenance. +# No QEMU emulation. +# +# Triggers: +# - push to main (paths-filtered) → :main + :sha-<7> +# - tag sprig-v* → semver family (shared with sprig.yml's +# binary release — one tag versions both) +# - pull_request (paths-filtered) → build only, no push +# - workflow_dispatch → manual publish at the current ref +# +# NOTE: the first push creates the GHCR package PRIVATE by default. An org +# admin must flip ghcr.io/block/buzz-sprig to public once (Package settings → +# Change visibility). Subsequent pushes keep the visibility. + +on: + push: + branches: [main] + tags: ["sprig-v[0-9]*"] + paths: + - "Dockerfile.sprig" + - "scripts/sprig-entrypoint.sh" + - ".github/workflows/sprig-image.yml" + - "Cargo.toml" + - "Cargo.lock" + - "rust-toolchain.toml" + - "crates/**" + pull_request: + paths: + - "Dockerfile.sprig" + - "scripts/sprig-entrypoint.sh" + - ".github/workflows/sprig-image.yml" + workflow_dispatch: {} + +concurrency: + group: sprig-image-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type == 'branch' && github.event_name == 'pull_request' }} + +permissions: {} + +env: + # Single source of truth for the image name; override with the + # GHCR_SPRIG_IMAGE repo variable (same pattern as docker.yml). + IMAGE_NAME: ${{ vars.GHCR_SPRIG_IMAGE != '' && vars.GHCR_SPRIG_IMAGE || 'ghcr.io/block/buzz-sprig' }} + +jobs: + build: + name: Build (${{ matrix.platform }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + permissions: + contents: read + packages: write + id-token: write + attestations: write + strategy: + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-24.04 + arch: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + arch: arm64 + + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + with: + # Same OOM cap as docker.yml — Rust compiles blow the 7GB runner + # at buildkit's default parallelism of 4. + buildkitd-config-inline: | + [worker.oci] + max-parallelism = 2 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_NAME }} + # match=^sprig-v(.*)$ strips the tag prefix for the semver parser, + # exactly as docker.yml does for relay-v. :latest comes from + # flavor.latest=auto — stable semver only, never main pushes. + tags: | + type=ref,event=branch + type=sha,prefix=sha-,format=short + type=semver,pattern={{version}},match=^sprig-v(.*)$ + type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$ + labels: | + org.opencontainers.image.title=Buzz Sprig + org.opencontainers.image.description=Agent runtime image for Buzz remote agents (buzz-acp multicall + git + curl) + org.opencontainers.image.licenses=Apache-2.0 + + - name: Build and push by digest + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + with: + context: . + file: ./Dockerfile.sprig + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=${{ github.event_name != 'pull_request' }} + cache-from: | + type=registry,ref=${{ env.IMAGE_NAME }}-buildcache:${{ matrix.arch }} + cache-to: | + ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref={0}-buildcache:{1},mode=max,compression=zstd', env.IMAGE_NAME, matrix.arch) || '' }} + + - name: Export digest + if: github.event_name != 'pull_request' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload digest + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sprig-digest-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + name: Merge multi-arch manifest + if: github.event_name != 'pull_request' + runs-on: ubuntu-24.04 + needs: build + timeout-minutes: 15 + permissions: + contents: read + packages: write + id-token: write + attestations: write + + steps: + - name: Download per-arch digests + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: /tmp/digests + pattern: sprig-digest-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Log in to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 + with: + images: ${{ env.IMAGE_NAME }} + # Must mirror the build job's tag matrix exactly (see docker.yml). + flavor: | + latest=auto + tags: | + type=ref,event=branch + type=sha,prefix=sha-,format=short + type=semver,pattern={{version}},match=^sprig-v(.*)$ + type=semver,pattern={{major}}.{{minor}},match=^sprig-v(.*)$ + + - name: Create and push manifest list + id: manifest + working-directory: /tmp/digests + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + META_TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + tags=() + while IFS= read -r tag; do + [ -n "$tag" ] && tags+=("-t" "$tag") + done <<< "$META_TAGS" + + digests=() + for digest in *; do + digests+=("${IMAGE_NAME}@sha256:${digest}") + done + + docker buildx imagetools create "${tags[@]}" "${digests[@]}" + + first_tag=$(echo "$META_TAGS" | head -n1) + merged_digest=$(docker buildx imagetools inspect "$first_tag" \ + --format '{{json .Manifest}}' | jq -r '.digest') + echo "digest=${merged_digest}" >> "$GITHUB_OUTPUT" + + - name: Attest provenance for the merged image + # Verify with: gh attestation verify oci://ghcr.io/block/buzz-sprig: --owner block + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.manifest.outputs.digest }} + push-to-registry: true + + - name: Summary + env: + IMAGE_NAME: ${{ env.IMAGE_NAME }} + DIGEST: ${{ steps.manifest.outputs.digest }} + run: | + { + echo "### Sprig image published" + echo '```' + echo "${IMAGE_NAME}@${DIGEST}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/sprig.yml b/.github/workflows/sprig.yml index b2dab3583f5..5e50808b3b7 100644 --- a/.github/workflows/sprig.yml +++ b/.github/workflows/sprig.yml @@ -42,7 +42,7 @@ jobs: - x86_64-unknown-linux-musl - aarch64-unknown-linux-musl steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -116,7 +116,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download all Sprig artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -154,7 +154,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - name: Download all Sprig artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/Cargo.lock b/Cargo.lock index fa02e17ce3b..62bcea0cae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -915,6 +915,26 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-backend-kubernetes" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "http", + "http-body-util", + "k8s-openapi", + "kube", + "nostr", + "rand 0.10.1", + "rustls", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "tower", +] + [[package]] name = "buzz-cli" version = "0.1.0" @@ -3643,6 +3663,7 @@ dependencies = [ "http", "hyper", "hyper-util", + "log", "rustls", "rustls-native-certs", "tokio", @@ -4303,6 +4324,31 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "jsonpath-rust" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c00ae348f9f8fd2d09f82a98ca381c60df9e0820d8d79fce43e649b4dc3128b" +dependencies = [ + "pest", + "pest_derive", + "regex", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "k8s-openapi" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06d9e5e61dd037cdc51da0d7e2b2be10f497478ea7e120d85dad632adb99882b" +dependencies = [ + "base64 0.22.1", + "chrono", + "serde", + "serde_json", +] + [[package]] name = "kasuari" version = "0.4.12" @@ -4348,6 +4394,70 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" +[[package]] +name = "kube" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e7bb0b6a46502cc20e4575b6ff401af45cfea150b34ba272a3410b78aa014e" +dependencies = [ + "k8s-openapi", + "kube-client", + "kube-core", +] + +[[package]] +name = "kube-client" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4987d57a184d2b5294fdad3d7fc7f278899469d21a4da39a8f6ca16426567a36" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "either", + "futures", + "home", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-timeout", + "hyper-util", + "jsonpath-rust", + "k8s-openapi", + "kube-core", + "pem", + "rustls", + "secrecy", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tracing", +] + +[[package]] +name = "kube-core" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914bbb770e7bb721a06e3538c0edd2babed46447d128f7c21caa68747060ee73" +dependencies = [ + "chrono", + "derive_more", + "form_urlencoded", + "http", + "k8s-openapi", + "serde", + "serde-value", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "lab" version = "0.11.0" @@ -6265,6 +6375,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-float" version = "4.6.0" @@ -6445,6 +6564,16 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -8170,6 +8299,15 @@ dependencies = [ "cc", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "secret-service" version = "4.0.0" @@ -8269,6 +8407,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float 2.10.1", + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -9869,6 +10017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", + "base64 0.22.1", "bitflags 2.13.0", "bytes", "futures-core", diff --git a/Cargo.toml b/Cargo.toml index 3268cfaf8d3..cc1dd0f9dff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "crates/buzz-relay-mesh", "crates/buzz-dev-mcp", "crates/buzz-voice", + "crates/buzz-backend-kubernetes", "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] @@ -58,6 +59,13 @@ sqlx = { version = "0.9", features = [ redis = { version = "1.0", features = ["tokio-comp", "connection-manager", "tokio-rustls-comp"] } deadpool-redis = { version = "0.23", features = ["rt_tokio_1"] } +# Kubernetes (buzz-backend-kubernetes provider). No `ring` feature here: the +# process-level CryptoProvider is installed explicitly at startup, matching +# buzz-cli/buzz-acp/buzz-admin/buzz-relay/buzz-dev-mcp — see the comment on the +# crate's own rustls dependency. +kube = { version = "2.0", default-features = false, features = ["client", "rustls-tls"] } +k8s-openapi = { version = "0.26", features = ["v1_31"] } + # Nostr nostr = { version = "0.44", features = ["nip44", "nip98"] } diff --git a/Dockerfile.sprig b/Dockerfile.sprig new file mode 100644 index 00000000000..160e0b56625 --- /dev/null +++ b/Dockerfile.sprig @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 +# Multi-arch is produced by building this file on native amd64 and arm64 runners. +# Keep both bases pinned to manifest-list digests so either architecture resolves +# to immutable source bytes. +FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS builder + +RUN apk add --no-cache \ + build-base \ + cmake \ + git \ + musl-dev \ + openssl-dev \ + openssl-libs-static \ + perl \ + pkgconf \ + protoc +WORKDIR /build +COPY . . +RUN cargo build --locked --profile sprig -p sprig \ + && strip target/sprig/sprig + +FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce + +RUN apk add --no-cache bash ca-certificates curl git \ + && adduser -D -h /home/agent agent \ + && install -d -o agent -g agent /workspace /home/agent \ + && git config --system gpg.format x509 \ + && git config --system gpg.x509.program /usr/local/bin/git-sign-nostr \ + && git config --system commit.gpgSign true \ + && git config --system tag.gpgSign true + +COPY --from=builder --chmod=0755 /build/target/sprig/sprig /usr/local/bin/sprig +COPY --chmod=0755 scripts/sprig-entrypoint.sh /usr/local/bin/sprig-entrypoint +RUN for name in \ + buzz-acp buzz-agent buzz-dev-mcp rg tree buzz \ + git-credential-nostr git-sign-nostr; do \ + ln -s sprig "/usr/local/bin/$name"; \ + done + +ENV HOME=/home/agent \ + PATH=/usr/local/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin +WORKDIR /home/agent +USER agent +ENTRYPOINT ["/usr/local/bin/sprig-entrypoint"] diff --git a/Justfile b/Justfile index 64a1f36daf6..8dbe125a7d8 100644 --- a/Justfile +++ b/Justfile @@ -155,7 +155,11 @@ _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 + SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) + if [[ "$TARGET" != *windows* ]]; then + SIDECARS+=(buzz-backend-kubernetes) + fi + for bin in "${SIDECARS[@]}"; do touch "desktop/src-tauri/binaries/${bin}-${TARGET}" done @@ -236,6 +240,9 @@ 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" + if [[ "$TARGET" != *windows* ]]; then + touch "desktop/src-tauri/binaries/buzz-backend-kubernetes-$TARGET" + fi 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" @@ -274,6 +281,7 @@ test: # Run unit tests only (no infra needed) test-unit: #!/usr/bin/env bash + set -euo pipefail if command -v cargo-nextest &>/dev/null; then cargo nextest run -p buzz-core -p buzz-auth --lib cargo nextest run -p buzz-voice --lib @@ -293,6 +301,12 @@ test-unit: # Gateway unit and black-box HTTP tests are infra-free. Postgres-backed # contract/race tests run in the dedicated CI job below. cargo nextest run -p buzz-push-gateway + # Kubernetes backend provider: the decision layers (state machine, GC + # planner, env precedence, naming, wire) are pure functions with a fake + # substrate, so they belong in the unit job. Enumerated explicitly + # because nothing in CI runs `cargo test --workspace` — workspace + # membership alone buys clippy/check, not a single executed test. + cargo nextest run -p buzz-backend-kubernetes else ./scripts/run-tests.sh unit fi @@ -430,7 +444,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-backend-kubernetes -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 @@ -477,10 +491,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-backend-kubernetes -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-backend-kubernetes 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 @@ -506,7 +520,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-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) @@ -533,7 +547,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-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) diff --git a/VISION_REMOTE_AGENTS.md b/VISION_REMOTE_AGENTS.md index b02d1bc92d1..4b187f355a5 100644 --- a/VISION_REMOTE_AGENTS.md +++ b/VISION_REMOTE_AGENTS.md @@ -56,7 +56,7 @@ Remote agents solve it from the inside. Because the desktop retains no substrate **The body's state is mortal.** Files, checkouts, half-finished working trees — gone with the body unless the substrate persists them. The agent survives; its scratch space doesn't. Durable knowledge belongs on the relay, and agents are built to put it there. -**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about ninety if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. Ninety seconds of a wrong dot, never an indefinite one. +**Presence can lag the truth, but not for long.** If the substrate kills a body without ceremony, the presence dot can outlive the agent — by seconds if the connection drops cleanly, by at most about three minutes if it doesn't. Presence is a lease the agent renews, not a flag it sets: a dead agent stops renewing and the relay forgets it. A bounded wrong dot, never an indefinite one. **A running agent finishes on the configuration it started with.** New keys, new models, new settings take effect on the next body. And an instance that never got far enough to run — a body that failed to start — is the substrate operator's residue to clear, with the substrate's own tools. Editing an agent mid-sentence was never on the menu. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a0..35aaec188db 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -474,6 +474,11 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)] pub relay_observer: bool, + /// Exit after this many seconds with no dispatched events and no turn in flight. + /// 0 disables inactivity self-termination. + #[arg(long, env = "BUZZ_ACP_EXIT_AFTER_INACTIVITY", default_value_t = 0)] + pub exit_after_inactivity: u64, + /// Connect and subscribe before starting the ACP/LLM subprocess pool. #[arg(long, env = "BUZZ_ACP_LAZY_POOL", default_value_t = false)] pub lazy_pool: bool, @@ -550,6 +555,8 @@ pub struct Config { pub has_generated_codex_config: bool, /// Whether to publish encrypted observer frames through the relay. pub relay_observer: bool, + /// Seconds without dispatched events before an idle harness exits. 0 = disabled. + pub exit_after_inactivity_secs: u64, /// Whether ACP/LLM subprocess initialization is deferred until accepted work arrives. pub lazy_pool: bool, /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. @@ -1098,6 +1105,7 @@ impl Config { persona_env_vars, has_generated_codex_config, relay_observer: args.relay_observer, + exit_after_inactivity_secs: args.exit_after_inactivity, lazy_pool: args.lazy_pool, agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), no_base_prompt: args.no_base_prompt, @@ -1468,6 +1476,7 @@ mod tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -2167,6 +2176,22 @@ channels = "ALL" assert!(err.to_string().contains("turn liveness interval must be 0")); } + #[test] + fn inactivity_exit_defaults_disabled_and_accepts_cli_value() { + let key = "0".repeat(64); + let default = CliArgs::parse_from(["buzz-acp", "--private-key", &key]); + assert_eq!(default.exit_after_inactivity, 0); + + let configured = CliArgs::parse_from([ + "buzz-acp", + "--private-key", + &key, + "--exit-after-inactivity", + "120", + ]); + assert_eq!(configured.exit_after_inactivity, 120); + } + #[test] fn lazy_pool_defaults_off() { let key = "0".repeat(64); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322c..811253e4ac0 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -1228,6 +1228,59 @@ impl Drop for RespawnGuard { // sync entry point — `std::env::set_var` is only safe before tokio spawns // worker threads (Rust 2024 edition safety requirement). +fn inactivity_expired( + last_activity: tokio::time::Instant, + now: tokio::time::Instant, + bound: Duration, + turn_in_flight: bool, +) -> bool { + !bound.is_zero() && !turn_in_flight && now.duration_since(last_activity) >= bound +} + +#[cfg(test)] +mod inactivity_tests { + use super::*; + + #[test] + fn zero_disables_expiry_and_in_flight_turns_defer_it() { + let started = tokio::time::Instant::now(); + let after_bound = started + Duration::from_secs(61); + + assert!(!inactivity_expired( + started, + after_bound, + Duration::ZERO, + false + )); + assert!(!inactivity_expired( + started, + after_bound, + Duration::from_secs(60), + true + )); + assert!(inactivity_expired( + started, + after_bound, + Duration::from_secs(60), + false + )); + } + + #[test] + fn dispatched_activity_restarts_the_inactivity_bound() { + let started = tokio::time::Instant::now(); + let dispatched = started + Duration::from_secs(50); + let checked = started + Duration::from_secs(61); + + assert!(!inactivity_expired( + dispatched, + checked, + Duration::from_secs(60), + false + )); + } +} + pub fn run() -> Result<()> { config::propagate_legacy_env_vars(); tokio_main() @@ -1601,6 +1654,21 @@ async fn tokio_main() -> Result<()> { let mut typing_channels: HashMap = HashMap::new(); let mut presence_task: Option> = None; + // Independent of pool readiness: a never-mentioned lazy agent must still + // self-terminate. The watch interval is capped so small configured bounds + // remain reasonably precise without waking long-lived agents frequently. + let inactivity_bound = Duration::from_secs(config.exit_after_inactivity_secs); + let mut last_activity = tokio::time::Instant::now(); + let mut inactivity_reaper = if inactivity_bound.is_zero() { + None + } else { + let interval = inactivity_bound.min(Duration::from_secs(30)); + Some(tokio::time::interval_at( + tokio::time::Instant::now() + interval, + interval, + )) + }; + // Runs at the TOP of every loop iteration via Instant check — cannot be // starved by the biased select. Slot refill spawns background tasks so // spawn_and_init never blocks the main loop. @@ -1774,7 +1842,9 @@ async fn tokio_main() -> Result<()> { // called on relay events or pool results, neither of which // arrive when the channel is silent. if queue.has_flushable_work() { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -1810,7 +1880,9 @@ async fn tokio_main() -> Result<()> { // this, batches requeued during crash recovery sit idle until the // next relay event arrives — which can be minutes on quiet channels. if respawn_collected { - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2258,7 +2330,7 @@ async fn tokio_main() -> Result<()> { } if pool_ready { for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2275,6 +2347,27 @@ async fn tokio_main() -> Result<()> { } None } + _ = async { + match inactivity_reaper.as_mut() { + Some(timer) => timer.tick().await, + None => std::future::pending().await, + } + } => { + let _ = result_rx; + if inactivity_expired( + last_activity, + tokio::time::Instant::now(), + inactivity_bound, + queue.has_in_flight() || heartbeat_in_flight, + ) { + tracing::info!( + inactivity_seconds = config.exit_after_inactivity_secs, + "inactivity bound reached — exiting gracefully" + ); + let _ = shutdown_tx.send(()); + } + None + } _ = async { match heartbeat.as_mut() { Some(hb) => hb.tick().await, @@ -2287,7 +2380,7 @@ async fn tokio_main() -> Result<()> { } else if queue.has_flushable_work() { tracing::debug!("heartbeat_skipped_events"); for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2385,7 +2478,9 @@ async fn tokio_main() -> Result<()> { { break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2408,7 +2503,9 @@ async fn tokio_main() -> Result<()> { tracing::error!("all agents dead — exiting"); break; } - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2550,7 +2647,9 @@ async fn tokio_main() -> Result<()> { // tear down the in-flight task; on its completion the // queue drains. We still try here in case the in-flight // task has already returned. - for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx) { + for (channel_id, thread_tags) in + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) + { typing_channels.insert(channel_id, thread_tags); } } @@ -2577,7 +2676,7 @@ async fn tokio_main() -> Result<()> { None, ); for (channel_id, thread_tags) in - dispatch_pending(&mut pool, &mut queue, &ctx) + dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) { typing_channels.insert(channel_id, thread_tags); } @@ -2911,6 +3010,7 @@ fn dispatch_pending( pool: &mut AgentPool, queue: &mut EventQueue, ctx: &Arc, + last_activity: &mut tokio::time::Instant, ) -> Vec<(Uuid, ThreadTags)> { let mut dispatched_channels = Vec::new(); loop { @@ -2990,6 +3090,7 @@ fn dispatch_pending( }, ); dispatched_channels.push((channel_id, typing_scope)); + *last_activity = tokio::time::Instant::now(); } tracing::debug!( dispatched = dispatched_channels.len(), @@ -5031,6 +5132,7 @@ mod build_mcp_servers_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, @@ -5252,6 +5354,7 @@ mod error_outcome_emission_tests { persona_env_vars: vec![], has_generated_codex_config: false, relay_observer: false, + exit_after_inactivity_secs: 0, lazy_pool: false, agent_owner: None, no_base_prompt: false, diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 029bf86dbf4..5c960de2024 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -646,6 +646,11 @@ impl EventQueue { self.in_flight_channels.contains(&channel_id) } + /// Whether any channel currently has a turn in flight. + pub fn has_in_flight(&self) -> bool { + !self.in_flight_channels.is_empty() + } + // ── Goose-native steer withhold (side table) ────────────────────────── // // While a goose-native `_goose/unstable/session/steer` write is in flight diff --git a/crates/buzz-backend-kubernetes/Cargo.toml b/crates/buzz-backend-kubernetes/Cargo.toml new file mode 100644 index 00000000000..1cd17030db7 --- /dev/null +++ b/crates/buzz-backend-kubernetes/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "buzz-backend-kubernetes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Kubernetes backend provider for Buzz remote agents (docs/remote-agents.md)" + +[[bin]] +name = "buzz-backend-kubernetes" +path = "src/main.rs" + +[dependencies] +kube = { workspace = true } +k8s-openapi = { workspace = true } +nostr = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +hex = { workspace = true } +rand = { workspace = true } +chrono = { workspace = true } +http = "1" +http-body-util = "0.1" + +# Explicit rustls dep with the ring provider — required to install the +# process-level CryptoProvider at startup. Without it this binary panics on its +# first TLS connection to the apiserver: the release build compiles every +# sidecar in one cargo invocation (.github/workflows/release.yml), which unifies +# both ring and aws-lc-rs features and leaves rustls unable to auto-select a +# provider. Same dependency and reason as crates/buzz-cli/Cargo.toml. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } + +[dev-dependencies] +tower = { workspace = true } diff --git a/crates/buzz-backend-kubernetes/src/classify.rs b/crates/buzz-backend-kubernetes/src/classify.rs new file mode 100644 index 00000000000..e9cfaba4025 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/classify.rs @@ -0,0 +1,377 @@ +//! The deploy state machine (spec §Deploy State Machine), as a pure function. +//! +//! `classify` maps a verified observation plus the desired create intent to +//! one [`Action`]. It performs no I/O, so every row of the spec's table is a +//! unit test with no cluster. `reconcile` executes actions and re-enters. +//! +//! Two invariants are structural rather than remembered: +//! +//! * [`Action::Delete`] carries the [`Fence`] from the exact observation that +//! authorized it. There is no way to build a delete without one, so a later +//! helper cannot re-read and silently substitute a fresher fence. +//! * The pull-failure classifier ([`PullFailure`]) reaches only +//! [`Action::Report`] and [`Action::Observe`]. It is absent from +//! `Action::Delete`'s type, so "reason strings are never deletion +//! authority" is enforced by the compiler. + +use crate::intent::Fingerprint; + +/// The compare-and-delete fence: UID + resourceVersion from the observation +/// that authorized the deletion. A failed precondition means the object +/// changed since the read — re-enter, never retry the delete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fence { + pub uid: String, + pub resource_version: String, +} + +/// Why a pod that never started looks permanently broken. Reporting only. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PullFailure { + /// Registry auth: a 403/401 `ImagePullBackOff` retries forever without + /// ever succeeding, so "the pull retries" is false for this case. + Unauthorized, + /// The digest or repository does not exist at that registry. + ManifestUnknown, + /// The image has no variant for the node's architecture. + ArchMismatch, +} + +/// The container's startup state, already decoded from pod status. Decoding +/// happens at the edge so this module stays free of API types. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Startup { + /// `state.running` — the harness process is up. This, not pod phase, is + /// what "live" means. + Started, + /// Started once and reached a terminal phase (Succeeded/Failed). + Terminated, + /// Never started, and self-healing is plausible: unschedulable during + /// scale-from-zero, an image pull in progress, a transient + /// `CreateContainerConfigError` whose Secret exists. + NeverStartedRecoverable, + /// Never started, and the provider *verified* the cause — not a reason + /// string. Either the referenced Secret is confirmed absent by a + /// most-recent read, or the image reference is structurally invalid. + NeverStartedProvablyBroken, + /// Never started; the pull is failing in a way that will not self-heal. + /// Still recoverable in the *never delete* sense — this only changes what + /// we report and how long we wait. + NeverStartedPullFailing(PullFailure), +} + +/// A pod that passed identity and ownership verification: label-selected, +/// full-pubkey annotation equal to the derived pubkey, management marker +/// present. Constructing this type is the verification step's output, so an +/// unverified object cannot reach `classify` at all. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedPod { + pub name: String, + pub fence: Fence, + /// Set once the apiserver accepts a delete. In Kubernetes there is no + /// `Terminating` phase — a pod being gracefully deleted stays in phase + /// `Running` for its whole grace period — so this must be checked + /// *before* startup state or the dying pod reads as the no-op row. + pub deletion_marked: bool, + pub startup: Startup, + /// The `buzz.block.xyz/create-intent` annotation as recorded at create. + /// `None` for a pod written before the annotation existed, which counts + /// as divergence. + pub recorded_intent: Option, +} + +/// What the reconciler should do next. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Create the pod, then wait for the harness container to start. + Create, + /// Compare-and-delete, poll for actual disappearance, then re-enter. + Delete { name: String, fence: Fence }, + /// Wait for a deletion already in flight, then re-enter. + AwaitDisappearance { name: String }, + /// Strict no-op: return this `agent_id`, mutate nothing. + NoOp { agent_id: String }, + /// Keep observing until started or the operation deadline expires; on + /// expiry report the latest condition. Never deletes, on this call or any + /// later one. + Observe { name: String }, + /// Surface an actionable condition immediately rather than burning the + /// deadline on a failure that will not self-heal. + Report { name: String, failure: PullFailure }, +} + +/// Apply the spec's ordered rules to one verified observation. +/// +/// `desired` is the freshly computed create intent; comparison is always +/// recorded-annotation vs freshly-computed, never a diff against the live pod +/// (admission defaulting would make every pod look divergent). +pub fn classify(observed: Option<&VerifiedPod>, desired: &Fingerprint) -> Action { + let Some(pod) = observed else { + // Row: no instance → create. First deploy, or after GC. + return Action::Create; + }; + + // Row: deletion-marked, ANY phase. Checked before startup state because + // there is no `Terminating` phase to match on — a gracefully deleting pod + // reports phase `Running` throughout its grace period, so testing startup + // first would mistake it for the live no-op row and return an id that + // evaporates. + if pod.deletion_marked { + return Action::AwaitDisappearance { + name: pod.name.clone(), + }; + } + + match &pod.startup { + // Row: live and started → strict no-op. Start must never kill a live + // agent mid-turn, whatever the fingerprint says. + Startup::Started => Action::NoOp { + agent_id: pod.name.clone(), + }, + + // Row: terminated → delete residue, then re-enter to create. This is + // the normal restart path — how a user revives a reaped agent. + Startup::Terminated => Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + }, + + // Row: never started, provably non-recoverable → fenced replace. + // "Provably" means a verified absence or a structural defect, never a + // reason string. + Startup::NeverStartedProvablyBroken => Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + }, + + // Inside the recoverable row: a pull that will not self-heal is + // reported immediately instead of consuming the 600s deadline. This + // changes reporting and wait behavior only — no delete authority. + Startup::NeverStartedPullFailing(failure) => Action::Report { + name: pod.name.clone(), + failure: *failure, + }, + + // Row: never started, recoverable — split on create-intent + // divergence. Divergence is evidence of a config change the user is + // waiting on, and it is the *only* thing that replaces a + // never-started pod. Pod age triggers nothing: any finite age + // threshold collides with Cluster Autoscaler's own pod-age delays, + // and delete-recreate resets exactly the age it keys on. + Startup::NeverStartedRecoverable => { + if pod.recorded_intent.as_ref() == Some(desired) { + Action::Observe { + name: pod.name.clone(), + } + } else { + Action::Delete { + name: pod.name.clone(), + fence: pod.fence.clone(), + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fp(seed: &str) -> Fingerprint { + Fingerprint::for_test(seed) + } + + fn pod(startup: Startup, intent: Option) -> VerifiedPod { + VerifiedPod { + name: "buzz-agent-abc123def456".into(), + fence: Fence { + uid: "uid-1".into(), + resource_version: "rv-1".into(), + }, + deletion_marked: false, + startup, + recorded_intent: intent, + } + } + + #[test] + fn no_instance_creates() { + assert_eq!(classify(None, &fp("a")), Action::Create); + } + + #[test] + fn started_pod_is_strict_no_op() { + let p = pod(Startup::Started, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::NoOp { + agent_id: p.name.clone() + } + ); + } + + /// The asymmetry the spec states plainly: an edit cannot reach a started + /// pod until it exits, but it *can* reach a never-started one — the + /// never-started pod is the one the user is editing because it did not + /// start. + #[test] + fn started_pod_no_ops_even_when_intent_diverges() { + let p = pod(Startup::Started, Some(fp("old"))); + assert_eq!( + classify(Some(&p), &fp("new")), + Action::NoOp { + agent_id: p.name.clone() + } + ); + } + + /// In Kubernetes a gracefully deleting pod stays in phase `Running`. If + /// the deletion mark were checked after startup state, this pod would + /// take the no-op row and `deploy` would return an id that evaporates. + #[test] + fn deletion_mark_beats_every_startup_state() { + for startup in [ + Startup::Started, + Startup::Terminated, + Startup::NeverStartedRecoverable, + Startup::NeverStartedProvablyBroken, + Startup::NeverStartedPullFailing(PullFailure::Unauthorized), + ] { + let mut p = pod(startup.clone(), Some(fp("a"))); + p.deletion_marked = true; + assert_eq!( + classify(Some(&p), &fp("a")), + Action::AwaitDisappearance { + name: p.name.clone() + }, + "deletion mark ignored for {startup:?}" + ); + } + } + + #[test] + fn terminated_pod_is_replaced() { + let p = pod(Startup::Terminated, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// A never-started winner is repairable: pod exists, Secret confirmed + /// absent, container never started. A later deploy must delete-recreate + /// rather than no-op — the test that pins started-not-phase as the no-op + /// criterion. + #[test] + fn provably_broken_never_started_pod_is_replaced() { + let p = pod(Startup::NeverStartedProvablyBroken, Some(fp("a"))); + assert_eq!( + classify(Some(&p), &fp("a")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// The anti-livelock rule: identical desired intent means *never* delete, + /// however long the pod has been pending. Age is not an input to this + /// function at all, which is the strongest way to say so. + #[test] + fn recoverable_with_matching_intent_only_observes() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("same"))); + assert_eq!( + classify(Some(&p), &fp("same")), + Action::Observe { + name: p.name.clone() + } + ); + } + + /// Repeated identical Starts can never delete anything — the same + /// classification, arbitrarily many times. + #[test] + fn repeated_identical_starts_never_delete() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("same"))); + for _ in 0..100 { + assert!(!matches!( + classify(Some(&p), &fp("same")), + Action::Delete { .. } + )); + } + } + + /// The wedge escape: the user corrected a resource request or image, so + /// the never-started pod is built from configuration they have since + /// changed. Without this row the edit could never materialize. + #[test] + fn recoverable_with_divergent_intent_is_replaced() { + let p = pod(Startup::NeverStartedRecoverable, Some(fp("old"))); + assert_eq!( + classify(Some(&p), &fp("new")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// A pod predating the annotation has no recorded intent — that is + /// absence, which the spec groups with divergence. + #[test] + fn missing_recorded_intent_counts_as_divergence() { + let p = pod(Startup::NeverStartedRecoverable, None); + assert_eq!( + classify(Some(&p), &fp("any")), + Action::Delete { + name: p.name.clone(), + fence: p.fence.clone() + } + ); + } + + /// Permanent-looking pull failures report immediately instead of burning + /// 600s — and, critically, never delete. + #[test] + fn pull_failures_report_and_never_delete() { + for failure in [ + PullFailure::Unauthorized, + PullFailure::ManifestUnknown, + PullFailure::ArchMismatch, + ] { + let p = pod(Startup::NeverStartedPullFailing(failure), Some(fp("a"))); + // Divergent intent too — still no delete from this arm. + for desired in [fp("a"), fp("different")] { + assert_eq!( + classify(Some(&p), &desired), + Action::Report { + name: p.name.clone(), + failure + } + ); + } + } + } + + /// Every delete carries the fence from the observation that authorized + /// it. Exhaustive over the delete-producing states, so a future arm that + /// forgets is caught here rather than in a cluster. + #[test] + fn every_delete_carries_the_authorizing_fence() { + let states = [ + (Startup::Terminated, fp("a")), + (Startup::NeverStartedProvablyBroken, fp("a")), + (Startup::NeverStartedRecoverable, fp("divergent")), + ]; + for (startup, desired) in states { + let p = pod(startup, Some(fp("a"))); + match classify(Some(&p), &desired) { + Action::Delete { fence, .. } => assert_eq!(fence, p.fence), + other => panic!("expected Delete, got {other:?}"), + } + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/client.rs b/crates/buzz-backend-kubernetes/src/client.rs new file mode 100644 index 00000000000..0c3bed65b74 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/client.rs @@ -0,0 +1,182 @@ +//! Cluster auth and client construction (spec §Cluster auth, +//! `docs/remote-agents.md:985-995`). +//! +//! Standard kubeconfig resolution (`$KUBECONFIG` → `~/.kube/config`). +//! `provider_config` carries `context` and `namespace` only — credentials +//! never transit config (I2, `:196-198`). + +use kube::config::{ExecConfig, KubeConfigOptions, Kubeconfig}; +use kube::{Client, Config}; +use std::path::{Path, PathBuf}; + +/// Directories prepended to `PATH` before the client is built. +/// +/// Kubeconfigs at Block near-universally authenticate through `exec` +/// credential plugins (`aws eks get-token`, `gke-gcloud-auth-plugin`) that +/// resolve via `PATH` — and this provider inherits a Finder-launched +/// desktop's minimal `PATH`, which contains none of the places those plugins +/// install to (`:989-994`). +const PATH_PREPEND: [&str; 2] = ["/opt/homebrew/bin", "/usr/local/bin"]; + +/// Compute the new `PATH` value: plugin directories first, inherited entries +/// after, in order. Pure so the ordering can be tested without mutating the +/// process's environment. +fn prepended_path(home: Option<&Path>, existing: &std::ffi::OsStr) -> Option { + let mut dirs: Vec = PATH_PREPEND.iter().map(PathBuf::from).collect(); + if let Some(home) = home { + dirs.push(home.join(".local/bin")); + } + // An empty inherited PATH splits into one empty entry, which POSIX + // resolves as the current directory — a place a credential plugin should + // never be looked up. Drop empties rather than propagate them. + dirs.extend(std::env::split_paths(existing).filter(|p| !p.as_os_str().is_empty())); + std::env::join_paths(dirs).ok() +} + +/// Prepend the plugin directories to this process's `PATH`. +/// +/// Modifies the provider's own environment, which is sound here: one process +/// per operation, called before any client or task exists, and the child +/// processes that read it are exactly the credential plugins this exists for. +fn prepend_plugin_path() { + let home = std::env::var_os("HOME"); + let existing = std::env::var_os("PATH").unwrap_or_default(); + if let Some(joined) = prepended_path(home.as_ref().map(Path::new), &existing) { + std::env::set_var("PATH", joined); + } +} + +/// Is `command` runnable — an executable on `PATH`, or an existing path? +fn resolves_on_path(command: &str) -> bool { + if command.contains(std::path::MAIN_SEPARATOR) { + return Path::new(command).is_file(); + } + std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).any(|dir| dir.join(command).is_file())) + .unwrap_or(false) +} + +/// The exec plugin the selected context authenticates with, if any. +/// +/// Read from the kubeconfig directly rather than from `Config`, which does not +/// expose it. A read failure yields `None`: this lookup exists only to improve +/// an error message, and must never be the thing that fails a deploy. +fn exec_plugin_for(context: Option<&str>) -> Option { + let kubeconfig = Kubeconfig::read().ok()?; + let context_name = context + .map(str::to_string) + .or_else(|| kubeconfig.current_context.clone())?; + let user_name = kubeconfig + .contexts + .iter() + .find(|c| c.name == context_name) + .and_then(|c| c.context.as_ref()) + .and_then(|c| c.user.clone())?; + kubeconfig + .auth_infos + .iter() + .find(|a| a.name == user_name) + .and_then(|a| a.auth_info.as_ref()) + .and_then(|a| a.exec.clone()) +} + +/// Turn a client-construction failure into an error a user can act on. +/// +/// When the context authenticates through an exec plugin that is not on +/// `PATH`, that is almost always the cause, and the actionable fact is the +/// plugin's name — not a kube-rs error chain (`:994-995`). +fn explain(context: Option<&str>, error: &kube::Error) -> String { + if let Some(command) = exec_plugin_for(context).and_then(|e| e.command) { + if !resolves_on_path(&command) { + return format!( + "kubeconfig context {} authenticates with the credential plugin \ + {command:?}, which is not on PATH. Install it or add its \ + directory to PATH, then try again.", + context.unwrap_or("(current)") + ); + } + } + format!( + "could not connect to the cluster using kubeconfig context {}: {error}", + context.unwrap_or("(current)") + ) +} + +/// Build a client for the selected context. +pub async fn connect(context: Option<&str>) -> Result { + prepend_plugin_path(); + + let options = KubeConfigOptions { + context: context.map(str::to_string), + ..Default::default() + }; + let config = Config::from_kubeconfig(&options).await.map_err(|e| { + // A named context that does not exist is a user typo, and the + // kube-rs message for it is already specific. + format!( + "could not load kubeconfig for context {}: {e}", + context.unwrap_or("(current)") + ) + })?; + + Client::try_from(config).map_err(|e| explain(context, &e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three plugin directories must end up ahead of the inherited PATH, + /// or a Finder-launched desktop never finds `aws`/`gke-gcloud-auth-plugin`. + /// Tested on the pure computation: mutating the process PATH here would + /// race every other test in the binary. + #[test] + fn plugin_directories_are_prepended_in_order() { + let joined = prepended_path( + Some(Path::new("/tmp/fake-home")), + std::ffi::OsStr::new("/inherited/bin:/usr/bin"), + ) + .unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/tmp/fake-home/.local/bin", + "/inherited/bin", + "/usr/bin", + ] + .map(PathBuf::from) + ); + } + + /// No `HOME` is not a failure — the two absolute directories still apply. + #[test] + fn missing_home_still_prepends_the_absolute_directories() { + let joined = prepended_path(None, std::ffi::OsStr::new("/inherited/bin")).unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + ["/opt/homebrew/bin", "/usr/local/bin", "/inherited/bin"].map(PathBuf::from) + ); + } + + /// An empty inherited PATH must not produce an empty entry, which the + /// shell and `resolves_on_path` would both read as the cwd. + #[test] + fn empty_inherited_path_yields_no_empty_entry() { + let joined = prepended_path(None, std::ffi::OsStr::new("")).unwrap(); + let dirs: Vec = std::env::split_paths(&joined).collect(); + assert_eq!( + dirs, + ["/opt/homebrew/bin", "/usr/local/bin"].map(PathBuf::from) + ); + } + + #[test] + fn resolves_absolute_paths_directly() { + assert!(resolves_on_path("/bin/sh")); + assert!(!resolves_on_path("/nonexistent/plugin-binary")); + } +} diff --git a/crates/buzz-backend-kubernetes/src/cluster.rs b/crates/buzz-backend-kubernetes/src/cluster.rs new file mode 100644 index 00000000000..755f41f6c8a --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/cluster.rs @@ -0,0 +1,427 @@ +//! The real [`Substrate`]: kube-rs against a live apiserver. +//! +//! Everything that *decides* lives in `classify`/`gc`; this module only +//! performs I/O and maps apiserver responses onto the trait's vocabulary. +//! Three mappings here are normative rather than incidental: +//! +//! * **409 is discriminated on `Status.reason`, never on the code.** A create +//! 409 is `AlreadyExists`; a delete 409 from a failed precondition is +//! `Conflict`. Branching on `code == 409` conflates a lost create race with +//! a stale fence and is the trap the spec names (`:780-794`). +//! * **Reads leave `resourceVersion` unset**, which is the quorum read. `"0"` +//! is the cache read, and a confirmed absence from a cache is proof of +//! nothing (`:761-769`). +//! * **Deletes never set `grace_period_seconds`**, so the object's own 60s +//! budget applies. Passing `0` is a force-kill that discards the shutdown +//! window the pod declares (`:1185-1189`). + +use crate::classify::Fence; +use crate::reconcile::{CreateOutcome, DeleteOutcome, Substrate}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Namespace, Pod, Secret}; +use kube::api::{Api, DeleteParams, GetParams, ListParams, PostParams, Preconditions}; +use kube::core::ErrorResponse; +use kube::{Client, Resource}; +use std::time::{Duration, Instant}; + +/// `Status.reason` values we branch on. Spelled once so the two 409 arms are +/// visibly the same discriminator read two ways. +/// +/// These are wire strings `apimachinery` chooses, not names this crate picks: +/// `StatusReasonAlreadyExists`, `StatusReasonConflict`, `StatusReasonNotFound`, +/// and `StatusReasonForbidden` in `k8s.io/apimachinery/pkg/apis/meta/v1/types.go`. +/// kube-core types `ErrorResponse::reason` as a bare `String`, so there is no +/// upstream constant to bind to and the spelling is pinned by test instead. +const REASON_ALREADY_EXISTS: &str = "AlreadyExists"; +const REASON_CONFLICT: &str = "Conflict"; +const REASON_NOT_FOUND: &str = "NotFound"; +const REASON_FORBIDDEN: &str = "Forbidden"; + +/// The apiserver-backed substrate for one deploy operation. +pub struct Cluster { + client: Client, + namespace: String, + /// Start of *this operation*, for the deadline. Monotonic: the 600s budget + /// must not move when the wall clock does. + started: Instant, +} + +/// The typed API error underneath a `kube::Error`, if it is one. +fn api_error(error: &kube::Error) -> Option<&ErrorResponse> { + match error { + kube::Error::Api(response) => Some(response), + _ => None, + } +} + +/// Does this error carry the given `Status.reason`? +fn reason_is(error: &kube::Error, reason: &str) -> bool { + api_error(error).is_some_and(|e| e.reason == reason) +} + +impl Cluster { + pub fn new(client: Client, namespace: &str) -> Self { + Self { + client, + namespace: namespace.to_string(), + started: Instant::now(), + } + } + + fn pods(&self) -> Api { + Api::namespaced(self.client.clone(), &self.namespace) + } + + fn secrets(&self) -> Api { + Api::namespaced(self.client.clone(), &self.namespace) + } + + /// List an object kind through the raw client so the response's HTTP + /// `Date` header is reachable. + /// + /// `Api::list` returns only the decoded body, and the apiserver's clock is + /// the *only* clock the orphan-Secret age gate may use — a desktop's local + /// clock running fast computes every in-flight Secret as expired + /// (`:1321-1335`). So the list goes through `Client::send`, which hands + /// back the whole `http::Response`. + async fn list_with_date( + &self, + selector: &str, + ) -> Result<(Vec, Option>), String> + where + K: Resource + + Clone + + serde::de::DeserializeOwned + + std::fmt::Debug, + K::DynamicType: Default, + { + let dt = K::DynamicType::default(); + let url = K::url_path(&dt, Some(&self.namespace)); + // resourceVersion deliberately unset: quorum read. + let params = ListParams { + label_selector: Some(selector.to_string()), + ..Default::default() + }; + let request = kube::core::Request::new(url) + .list(¶ms) + .map_err(|e| format!("could not build a list request: {e}"))?; + let (parts, body) = request.into_parts(); + let response = self + .client + .send(http::Request::from_parts(parts, body.into())) + .await + .map_err(|e| format!("could not list {}: {e}", K::plural(&dt)))?; + + // Parsed before the body is consumed, and independently of it: a + // missing or malformed header is not a list failure, it just means the + // orphan sweep has no clock and skips. + let server_now = response + .headers() + .get(http::header::DATE) + .and_then(|v| v.to_str().ok()) + .and_then(|v| DateTime::parse_from_rfc2822(v).ok()) + .map(|v| v.with_timezone(&Utc)); + + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .map_err(|e| format!("could not read the {} list body: {e}", K::plural(&dt)))? + .to_bytes(); + let list: kube::core::ObjectList = serde_json::from_slice(&bytes) + .map_err(|e| format!("could not decode the {} list: {e}", K::plural(&dt)))?; + + Ok((list.items, server_now)) + } +} + +impl Substrate for Cluster { + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> { + let api: Api = Api::all(self.client.clone()); + if api + .get_opt(namespace) + .await + .map_err(|e| format!("could not check whether namespace {namespace} exists: {e}"))? + .is_some() + { + return Ok(()); + } + + let spec = Namespace { + metadata: kube::core::ObjectMeta { + name: Some(namespace.to_string()), + ..Default::default() + }, + ..Default::default() + }; + match api.create(&PostParams::default(), &spec).await { + Ok(_) => Ok(()), + // Someone else created it between our check and our create. That + // is the desired end state, not a failure. + Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(()), + // Namespace-create is frequently denied on shared clusters. Name + // the exact command an operator runs, and never silently fall back + // to `default` — deploying an agent into someone else's namespace + // is worse than refusing (`:1002-1005`). + Err(e) if reason_is(&e, REASON_FORBIDDEN) => Err(format!( + "not authorized to create namespace {namespace}. Ask a cluster \ + administrator to run `kubectl create namespace {namespace}`, \ + then try again." + )), + Err(e) => Err(format!("could not create namespace {namespace}: {e}")), + } + } + + async fn list_pods(&self, selector: &str) -> Result<(Vec, Option>), String> { + self.list_with_date::(selector).await + } + + async fn list_secrets(&self, selector: &str) -> Result, String> { + Ok(self.list_with_date::(selector).await?.0) + } + + async fn secret_exists(&self, name: &str) -> Result { + // `GetParams::default()` leaves resourceVersion unset — the quorum + // read this check requires to be proof of anything. + match self.secrets().get_with(name, &GetParams::default()).await { + Ok(_) => Ok(true), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(false), + Err(e) => Err(format!("could not check whether secret {name} exists: {e}")), + } + } + + async fn create_secret(&self, secret: &Secret) -> Result<(), String> { + let name = secret.metadata.name.clone().unwrap_or_default(); + self.secrets() + .create(&PostParams::default(), secret) + .await + .map(|_| ()) + .map_err(|e| format!("could not create secret {name}: {e}")) + } + + async fn create_pod(&self, pod: &Pod) -> Result { + let name = pod.metadata.name.clone().unwrap_or_default(); + match self.pods().create(&PostParams::default(), pod).await { + Ok(_) => Ok(CreateOutcome::Created), + // The deterministic name is taken: a concurrent attempt won the + // election. Discriminated on the reason — a 409 whose reason is + // `Conflict` is a different condition and must not be read as a + // lost race. + Err(e) if reason_is(&e, REASON_ALREADY_EXISTS) => Ok(CreateOutcome::AlreadyExists), + Err(e) => Err(format!("could not create pod {name}: {e}")), + } + } + + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result { + let params = DeleteParams { + preconditions: Some(Preconditions { + uid: Some(fence.uid.clone()), + resource_version: Some(fence.resource_version.clone()), + }), + // grace_period_seconds deliberately unset: the pod's own 60s + // budget applies. + ..Default::default() + }; + match self.pods().delete(name, ¶ms).await { + Ok(_) => Ok(DeleteOutcome::Accepted), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(DeleteOutcome::NotFound), + // The object changed since the observation that authorized this + // delete. Same HTTP code as the create race above, different + // reason, different meaning. + Err(e) if reason_is(&e, REASON_CONFLICT) => Ok(DeleteOutcome::PreconditionFailed), + Err(e) => Err(format!("could not delete pod {name}: {e}")), + } + } + + async fn delete_secret(&self, name: &str) -> Result<(), String> { + match self.secrets().delete(name, &DeleteParams::default()).await { + Ok(_) => Ok(()), + // Already gone is the desired end state. + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(()), + Err(e) => Err(format!("could not delete secret {name}: {e}")), + } + } + + async fn get_pod(&self, name: &str) -> Result, String> { + match self.pods().get_with(name, &GetParams::default()).await { + Ok(pod) => Ok(Some(pod)), + Err(e) if reason_is(&e, REASON_NOT_FOUND) => Ok(None), + Err(e) => Err(format!("could not read pod {name}: {e}")), + } + } + + async fn sleep(&self, duration: Duration) { + tokio::time::sleep(duration).await; + } + + fn elapsed(&self) -> Duration { + self.started.elapsed() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::Response; + use kube::client::Body; + use std::sync::{Arc, Mutex}; + use tower::service_fn; + + fn list_response(date: Option<&str>) -> Response { + let mut response = Response::builder().status(200); + if let Some(date) = date { + response = response.header(http::header::DATE, date); + } + response + .body(Body::from( + serde_json::to_vec(&serde_json::json!({ + "apiVersion": "v1", + "kind": "PodList", + "metadata": {"resourceVersion": "17"}, + "items": [{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": {"name": "sprig"}, + "spec": {"containers": [{"name": "agent", "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]} + }] + })) + .unwrap(), + )) + .unwrap() + } + + async fn list_through_real_request_path( + date: Option<&'static str>, + ) -> (Vec, Option>, String) { + let observed_uri = Arc::new(Mutex::new(None)); + let service_uri = Arc::clone(&observed_uri); + let service = service_fn(move |request: http::Request| { + let service_uri = Arc::clone(&service_uri); + async move { + *service_uri.lock().unwrap() = Some(request.uri().to_string()); + Ok::<_, std::convert::Infallible>(list_response(date)) + } + }); + let cluster = Cluster::new(Client::new(service, "ignored"), "owned-ns"); + let result = cluster + .list_with_date::("app.kubernetes.io/managed-by=buzz-backend-kubernetes") + .await + .unwrap(); + let uri = observed_uri.lock().unwrap().take().unwrap(); + (result.0, result.1, uri) + } + + /// Exercise the shipped `Request` + `Client::send` seam. A fake + /// reconciler would not prove that kube-rs emits a quorum list request or + /// that the apiserver's clock survives body decoding. + #[tokio::test] + async fn list_with_date_uses_a_quorum_request_and_returns_the_server_clock() { + let (pods, server_now, uri) = + list_through_real_request_path(Some("Sun, 02 Aug 2026 04:00:00 GMT")).await; + + assert_eq!(pods.len(), 1, "fixture must contain one decoded pod"); + assert_eq!(pods[0].metadata.name.as_deref(), Some("sprig")); + assert!(uri.starts_with("/api/v1/namespaces/owned-ns/pods?")); + assert!( + uri.contains("labelSelector=app.kubernetes.io%2Fmanaged-by%3Dbuzz-backend-kubernetes") + ); + assert!( + !uri.contains("resourceVersion"), + "cache read leaked into {uri}" + ); + assert_eq!( + server_now.unwrap().to_rfc3339(), + "2026-08-02T04:00:00+00:00" + ); + } + + /// Header failure is deliberately not list failure: without a trustworthy + /// apiserver clock the orphan sweep skips, but normal reconciliation still + /// receives the decoded objects. + #[tokio::test] + async fn list_with_date_keeps_items_when_the_server_clock_is_unusable() { + for date in [Some("not a date"), None] { + let (pods, server_now, _) = list_through_real_request_path(date).await; + assert_eq!(pods.len(), 1, "fixture must contain one decoded pod"); + assert!(server_now.is_none(), "unexpected clock for {date:?}"); + } + } + + /// A typed apiserver error, as kube-rs surfaces it. + fn api(reason: &str, code: u16) -> kube::Error { + kube::Error::Api(ErrorResponse { + status: "Failure".into(), + message: String::new(), + reason: reason.into(), + code, + }) + } + + /// The one discriminator the whole file rests on, and the trap the spec + /// predicts: "an implementation that branches on the code alone will + /// eventually take the adoption path on a failed delete or vice versa" + /// (`:788-790`). + /// + /// Both of these are 409. Reading the *code* makes them identical; reading + /// `Status.reason` keeps a lost create race and a stale fence apart. The + /// mutation that must fail this test is `e.reason == …` → `e.code == 409`, + /// which no other test in the crate would catch — the fakes never produce + /// a real `kube::Error`. + #[test] + fn the_two_409s_are_never_conflated() { + let already_exists = api(REASON_ALREADY_EXISTS, 409); + let conflict = api(REASON_CONFLICT, 409); + + assert!(reason_is(&already_exists, REASON_ALREADY_EXISTS)); + assert!(reason_is(&conflict, REASON_CONFLICT)); + // The cross terms are the whole point. + assert!(!reason_is(&already_exists, REASON_CONFLICT)); + assert!(!reason_is(&conflict, REASON_ALREADY_EXISTS)); + } + + /// A transport-level failure is not an apiserver verdict. It must fall + /// through to the error arm rather than being read as any reason — a + /// connection reset silently classified as `NotFound` would report a pod + /// as confirmed-absent, which the classifier treats as proof. + #[test] + fn a_non_api_error_carries_no_reason() { + let transport = kube::Error::LinesCodecMaxLineLengthExceeded; + assert!(api_error(&transport).is_none()); + for reason in [ + REASON_ALREADY_EXISTS, + REASON_CONFLICT, + REASON_NOT_FOUND, + REASON_FORBIDDEN, + ] { + assert!(!reason_is(&transport, reason), "matched {reason}"); + } + } + + /// `reason` is `#[serde(default)]` in kube-core, so an apiserver that + /// omits it yields an empty string. That must match nothing rather than + /// matching an empty pattern by accident. + #[test] + fn an_absent_reason_matches_nothing() { + let bare = api("", 409); + assert!(!reason_is(&bare, REASON_ALREADY_EXISTS)); + assert!(!reason_is(&bare, REASON_CONFLICT)); + } + + /// The consts are the apiserver's spelling, asserted against literals + /// rather than against themselves. + /// + /// Every other test here references the consts symbolically on both sides + /// — fixture *and* assertion — which is true for any pair of distinct + /// values. That tests the discriminator is self-consistent, not that it is + /// correct: swapping the two 409 values inverts `AlreadyExists` and + /// `Conflict` at a real apiserver (`:788-790`'s failure, reached by + /// editing a string instead of a branch) with every other test still + /// green. These are `apimachinery`'s wire strings and kube-core exposes no + /// constant for them, so a literal is the only external anchor available. + /// Found by Quinn's mutation matrix; M2/M3/M4 survived without it. + #[test] + fn the_reason_consts_are_the_apiservers_spelling() { + assert_eq!(REASON_ALREADY_EXISTS, "AlreadyExists"); + assert_eq!(REASON_CONFLICT, "Conflict"); + assert_eq!(REASON_NOT_FOUND, "NotFound"); + assert_eq!(REASON_FORBIDDEN, "Forbidden"); + } +} diff --git a/crates/buzz-backend-kubernetes/src/config.rs b/crates/buzz-backend-kubernetes/src/config.rs new file mode 100644 index 00000000000..39b68ce0f2c --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/config.rs @@ -0,0 +1,443 @@ +//! `provider_config` parsing and the `info` config schema +//! (spec §`provider_config` v1 fields, `docs/remote-agents.md:1384-1389`). +//! +//! Nine fields, all optional except `image` (v1 ships no baked default — +//! §Image). No credential field exists, by I2: cluster auth comes from ambient +//! kubeconfig resolution and nothing else (`:196-198`). + +use crate::image::{self, ImageRef}; + +/// Resource requests and limits (§Pod shape: 1cpu/2Gi → 2cpu/4Gi, all four +/// configurable — `cargo build` in an agent workspace makes 500m/1Gi +/// unrealistic). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Resources { + pub cpu_request: String, + pub memory_request: String, + pub cpu_limit: String, + pub memory_limit: String, +} + +impl Default for Resources { + fn default() -> Self { + Self { + cpu_request: "1".into(), + memory_request: "2Gi".into(), + cpu_limit: "2".into(), + memory_limit: "4Gi".into(), + } + } +} + +/// Default inactivity budget: the I5 opt-in (§Auto-Stop). The config field and +/// `BUZZ_ACP_EXIT_AFTER_INACTIVITY` are one knob, not two. +pub const DEFAULT_INACTIVITY_SECONDS: u64 = 7200; + +/// Fixed nonzero UID/GID for the agent container (§Pod shape hardening). +pub const RUN_AS_UID: i64 = 10001; +pub const RUN_AS_GID: i64 = 10001; + +/// Writable workspace root; also `HOME` and the harness's cwd +/// (§Working directory). +pub const WORKSPACE_PATH: &str = "/home/agent"; + +/// `terminationGracePeriodSeconds` — a declared budget, not a derived sum +/// (§Pod shape). Kubernetes' default 30s would SIGKILL the harness mid-drain. +pub const TERMINATION_GRACE_SECONDS: i64 = 60; + +/// The only restart policy v1 ships. `OnFailure` is double-gated on the +/// harness exit-code contract *and* a crash-loop classification row the state +/// machine does not have (`:1121-1139`); until both land the provider refuses +/// the combination rather than shipping against an undefended convention. +pub const RESTART_POLICY: &str = "Never"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProviderConfig { + /// kubeconfig context; `None` uses the current context. + pub context: Option, + pub namespace: String, + pub image: ImageRef, + pub resources: Resources, + /// `None` when `inactivity_seconds` was 0 — refused in v1, see [`parse`]. + pub inactivity_seconds: Option, + pub service_account: Option, +} + +/// Read an optional non-empty string field. Rejects non-string scalars rather +/// than stringifying them, so a mistyped field is named at the boundary. +fn optional_string(cfg: &serde_json::Value, field: &str) -> Result, String> { + match cfg.get(field) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s.trim().to_string())), + Some(other) => Err(format!( + "provider_config.{field} must be a string, got {other}" + )), + } +} + +/// Read an optional unsigned integer. The desktop's form omits blank numeric +/// fields rather than sending `""`, but a hand-crafted payload may send a +/// numeric string — accept both, refuse anything else. +fn optional_u64(cfg: &serde_json::Value, field: &str) -> Result, String> { + match cfg.get(field) { + None | Some(serde_json::Value::Null) => Ok(None), + Some(serde_json::Value::Number(n)) => n.as_u64().map(Some).ok_or_else(|| { + format!("provider_config.{field} must be a non-negative integer, got {n}") + }), + Some(serde_json::Value::String(s)) if s.trim().is_empty() => Ok(None), + Some(serde_json::Value::String(s)) => s.trim().parse::().map(Some).map_err(|_| { + format!("provider_config.{field} must be a non-negative integer, got {s:?}") + }), + Some(other) => Err(format!( + "provider_config.{field} must be a non-negative integer, got {other}" + )), + } +} + +/// A Kubernetes namespace name: RFC 1123 label, ≤63 chars. Validated here so a +/// typo fails with a named field instead of an apiserver rejection partway +/// through a deploy. +fn valid_namespace(name: &str) -> bool { + !name.is_empty() + && name.len() <= 63 + && name.starts_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && name.ends_with(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit()) + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +pub fn parse(cfg: &serde_json::Value) -> Result { + if !cfg.is_object() && !cfg.is_null() { + return Err("provider_config must be a JSON object".to_string()); + } + + let namespace = optional_string(cfg, "namespace")?.ok_or_else(|| { + "provider_config.namespace is required: the info schema supplies a \ + generated default, so an empty value means the form was cleared" + .to_string() + })?; + if !valid_namespace(&namespace) { + return Err(format!( + "provider_config.namespace {namespace:?} is not a valid Kubernetes \ + namespace (lowercase alphanumerics and '-', ≤63 characters)" + )); + } + + let image = image::parse(optional_string(cfg, "image")?.unwrap_or_default().as_str())?; + + let defaults = Resources::default(); + let resources = Resources { + cpu_request: optional_string(cfg, "cpu_request")?.unwrap_or(defaults.cpu_request), + memory_request: optional_string(cfg, "memory_request")?.unwrap_or(defaults.memory_request), + cpu_limit: optional_string(cfg, "cpu_limit")?.unwrap_or(defaults.cpu_limit), + memory_limit: optional_string(cfg, "memory_limit")?.unwrap_or(defaults.memory_limit), + }; + + // `inactivity_seconds: 0` is a legal, blessed value in the spec (§Auto-Stop) + // meaning "no auto-stop" — but it selects `restartPolicy: OnFailure`, which + // §Pod shape forbids until the harness exit-code contract is pinned AND the + // state machine gains a crash-loop row. Refusing the *combination* is what + // the spec asks for; silently downgrading to `Never` would ship an + // indefinite agent that dies on its first crash. + let inactivity_seconds = match optional_u64(cfg, "inactivity_seconds")? { + None => Some(DEFAULT_INACTIVITY_SECONDS), + Some(0) => { + return Err( + "provider_config.inactivity_seconds: 0 (indefinite lifetime) is not \ + supported in this version: it requires restartPolicy OnFailure, \ + which is gated on the harness exit-code contract. Set a positive \ + number of seconds." + .to_string(), + ) + } + Some(n) => Some(n), + }; + + Ok(ProviderConfig { + context: optional_string(cfg, "context")?, + namespace, + image, + resources, + inactivity_seconds, + service_account: optional_string(cfg, "service_account")?, + }) +} + +/// A fresh `buzz-agents-` namespace default. +/// +/// Computed per `info` call, which is how "random default" is satisfied with +/// zero UI changes: the schema's `default` prefills the form (§K8s Namespace). +pub fn generated_namespace() -> String { + use rand::RngExt; + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let mut rng = rand::rng(); + let suffix: String = (0..6) + .map(|_| ALPHABET[rng.random_range(0..ALPHABET.len())] as char) + .collect(); + format!("buzz-agents-{suffix}") +} + +/// The `config_schema` returned by `info`. Drives the UI form: +/// `properties[*].default` prefill, scalar coercion, `required` gating +/// (`:407-411`). +pub fn config_schema() -> serde_json::Value { + let defaults = Resources::default(); + serde_json::json!({ + "type": "object", + "properties": { + "context": { + "type": "string", + "title": "Kubeconfig context", + "description": "Context from your kubeconfig. Leave empty to use the current context." + }, + "namespace": { + "type": "string", + "title": "Namespace", + "description": "Created if it does not exist.", + "default": generated_namespace() + }, + "image": { + "type": "string", + "title": "Agent image", + "description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:. Tags are not accepted: this pod holds the agent's private key." + }, + "cpu_request": { + "type": "string", "title": "CPU request", "default": defaults.cpu_request + }, + "memory_request": { + "type": "string", "title": "Memory request", "default": defaults.memory_request + }, + "cpu_limit": { + "type": "string", "title": "CPU limit", "default": defaults.cpu_limit + }, + "memory_limit": { + "type": "string", "title": "Memory limit", "default": defaults.memory_limit + }, + "inactivity_seconds": { + "type": "number", + "title": "Stop after inactivity (seconds)", + "description": "The agent exits after this long with no work, and can be started again at any time.", + "default": DEFAULT_INACTIVITY_SECONDS + }, + "service_account": { + "type": "string", + "title": "Service account", + "description": "Scheduling/RBAC identity only. No API token is mounted." + } + }, + "required": ["namespace", "image"] + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn digest_ref() -> String { + format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)) + } + + fn minimal() -> serde_json::Value { + serde_json::json!({"namespace": "buzz-agents-abc123", "image": digest_ref()}) + } + + #[test] + fn applies_spec_defaults() { + let c = parse(&minimal()).unwrap(); + assert_eq!(c.resources, Resources::default()); + assert_eq!(c.resources.cpu_request, "1"); + assert_eq!(c.resources.memory_request, "2Gi"); + assert_eq!(c.resources.cpu_limit, "2"); + assert_eq!(c.resources.memory_limit, "4Gi"); + assert_eq!(c.inactivity_seconds, Some(DEFAULT_INACTIVITY_SECONDS)); + assert_eq!(c.context, None); + assert_eq!(c.service_account, None); + } + + #[test] + fn all_four_resources_are_configurable() { + let mut cfg = minimal(); + cfg["cpu_request"] = "500m".into(); + cfg["memory_request"] = "1Gi".into(); + cfg["cpu_limit"] = "4".into(); + cfg["memory_limit"] = "8Gi".into(); + let c = parse(&cfg).unwrap(); + assert_eq!( + c.resources, + Resources { + cpu_request: "500m".into(), + memory_request: "1Gi".into(), + cpu_limit: "4".into(), + memory_limit: "8Gi".into(), + } + ); + } + + /// The desktop's form omits blank numeric fields; a hand-crafted payload + /// may send a numeric string. Both must mean the same thing. + #[test] + fn inactivity_accepts_number_string_and_omission() { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = serde_json::json!(300); + assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300)); + + cfg["inactivity_seconds"] = serde_json::json!("300"); + assert_eq!(parse(&cfg).unwrap().inactivity_seconds, Some(300)); + + cfg["inactivity_seconds"] = serde_json::json!(""); + assert_eq!( + parse(&cfg).unwrap().inactivity_seconds, + Some(DEFAULT_INACTIVITY_SECONDS) + ); + } + + /// Indefinite lifetime selects `OnFailure`, which is gated. Refuse rather + /// than silently downgrade — a downgraded agent dies on its first crash + /// while the user believes they asked for indefinite. + #[test] + fn refuses_indefinite_lifetime() { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = serde_json::json!(0); + let err = parse(&cfg).unwrap_err(); + assert!(err.contains("inactivity_seconds"), "got: {err}"); + assert!( + err.contains("OnFailure"), + "error should name the gate: {err}" + ); + } + + #[test] + fn rejects_negative_and_non_numeric_inactivity() { + for bad in [ + serde_json::json!(-1), + serde_json::json!(1.5), + serde_json::json!("soon"), + serde_json::json!(true), + ] { + let mut cfg = minimal(); + cfg["inactivity_seconds"] = bad.clone(); + assert!(parse(&cfg).is_err(), "accepted {bad}"); + } + } + + #[test] + fn image_is_required_and_must_be_digest_pinned() { + let mut cfg = minimal(); + cfg.as_object_mut().unwrap().remove("image"); + assert!(parse(&cfg).unwrap_err().contains("provider_config.image")); + + cfg["image"] = "ghcr.io/block/buzz-sprig:latest".into(); + assert!(parse(&cfg).unwrap_err().contains("digest-pinned")); + } + + #[test] + fn rejects_invalid_namespace_names() { + for bad in [ + "", + "Buzz-Agents", + "-leading", + "trailing-", + "has_underscore", + &"n".repeat(64), + ] { + let mut cfg = minimal(); + cfg["namespace"] = bad.into(); + assert!(parse(&cfg).is_err(), "accepted namespace {bad:?}"); + } + } + + /// I2 corollary: there is no config path for cluster credentials, so a + /// caller that tries to supply one gets no effect from it. Asserting the + /// parsed struct has no such field is the closest a test can get to + /// "the type makes it impossible". + #[test] + fn credential_fields_have_no_effect() { + let mut cfg = minimal(); + cfg["token"] = "hunter2".into(); + cfg["client_key"] = "hunter2".into(); + let c = parse(&cfg).unwrap(); + let rendered = format!("{c:?}"); + assert!( + !rendered.contains("hunter2"), + "config absorbed a credential: {rendered}" + ); + } + + #[test] + fn mistyped_string_fields_are_named() { + let mut cfg = minimal(); + cfg["namespace"] = serde_json::json!(42); + assert!(parse(&cfg) + .unwrap_err() + .contains("provider_config.namespace")); + } + + #[test] + fn generated_namespaces_are_fresh_and_valid() { + let a = generated_namespace(); + let b = generated_namespace(); + assert_ne!(a, b, "namespace default is not random"); + assert!(valid_namespace(&a), "{a} is not a valid namespace"); + assert!(a.starts_with("buzz-agents-")); + assert_eq!(a.len(), "buzz-agents-".len() + 6); + } + + /// The schema's own namespace default must be a value the parser accepts — + /// otherwise the UI prefills a form that fails on submit. + #[test] + fn schema_default_namespace_round_trips_through_parse() { + let schema = config_schema(); + let default = schema["properties"]["namespace"]["default"] + .as_str() + .unwrap(); + let cfg = serde_json::json!({"namespace": default, "image": digest_ref()}); + assert_eq!(parse(&cfg).unwrap().namespace, default); + } + + /// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the + /// count is pinned so a field added without a spec change is caught here. + #[test] + fn schema_declares_exactly_the_nine_v1_fields() { + let schema = config_schema(); + let props = schema["properties"].as_object().unwrap(); + let mut keys: Vec<&str> = props.keys().map(String::as_str).collect(); + keys.sort(); + assert_eq!( + keys, + [ + "context", + "cpu_limit", + "cpu_request", + "image", + "inactivity_seconds", + "memory_limit", + "memory_request", + "namespace", + "service_account" + ] + ); + assert_eq!( + schema["required"], + serde_json::json!(["namespace", "image"]) + ); + } + + /// I2's key lint rejects any field whose word-split contains + /// secret|password|token|key|credential. A schema field tripping it would + /// make every deploy fail validation desktop-side (`:185-198`). + #[test] + fn no_schema_field_trips_the_i2_key_lint() { + const BANNED: [&str; 5] = ["secret", "password", "token", "key", "credential"]; + let schema = config_schema(); + for field in schema["properties"].as_object().unwrap().keys() { + for word in field.split(['_', '-']) { + assert!( + !BANNED.contains(&word), + "field {field:?} contains I2-banned word {word:?}" + ); + } + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/env.rs b/crates/buzz-backend-kubernetes/src/env.rs new file mode 100644 index 00000000000..badff621e8d --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/env.rs @@ -0,0 +1,732 @@ +//! Building the pod environment (spec §Launch data, §Entrypoint mapping table). +//! +//! The three tiers are resolved *here*, before serialization, because a +//! Kubernetes Secret's `data` is a flat map with no precedence of its own: if +//! two tiers supplied the same key, whichever entry landed in the map would +//! win silently. Resolving in-provider makes later-wins explicit and testable. + +use crate::wire::{AgentPayload, LaunchBlock}; +use std::collections::BTreeMap; + +/// Keys the authoritative tier owns. +/// +/// Load-bearing, not documentation: tier 3 *clears* every key on this list +/// before writing its own values, so a key the authoritative tier has no value +/// for is **removed** rather than left holding a lower-tier value. Plain +/// overwrite is not enough — most of these are written conditionally +/// (`BUZZ_ACP_AGENT_ARGS` only when `launch.args` is non-empty, +/// `BUZZ_ACP_RESPOND_TO` only when set), and without the clear, a lower tier +/// could supply the value for exactly the cases the authoritative tier stays +/// silent on. Clearing is also what the local spawn does: the desktop strips +/// reserved keys from user env before the authoritative layer is written +/// (`env_vars.rs:54-57`), so absent-means-absent in both paths. +const AUTHORITATIVE_KEYS: &[&str] = &[ + "BUZZ_RELAY_URL", + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_ACP_AGENT_OWNER", + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_MCP_COMMAND", + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + START_NONCE_KEY, +]; + +/// The attempt's generation, as the harness sees it. Also the Secret's name +/// suffix — one generation, one identity — so the reconciler restamps this on +/// every create attempt rather than letting the caller's value persist across +/// a retry. +pub const START_NONCE_KEY: &str = "BUZZ_MANAGED_AGENT_START_NONCE"; + +/// Presence is the only remote liveness signal (I3), so a launch that +/// suppresses it is non-conforming (L1 item 2) — and unlike a reserved-key +/// collision, there is no "authoritative value" to overwrite it with. Refuse. +const FORBIDDEN_KEY: &str = "BUZZ_ACP_NO_PRESENCE"; + +/// Kubernetes' own cap on the summed value bytes of a Secret +/// (`MaxSecretSize`, `pkg/apis/core/types.go`). Enforced here so an oversized +/// env surfaces as a named provider error rather than an apiserver rejection +/// partway through a deploy. +const MAX_SECRET_BYTES: usize = 1024 * 1024; + +/// A POSIX-shaped env var name: `[A-Za-z_][A-Za-z0-9_]*`. +/// +/// Kubernetes validates Secret *keys* as `IsConfigMapKey` +/// (`[-._a-zA-Z0-9]+`), which is looser — `foo.bar` is a legal Secret key. +/// What the kubelet then does with such a key **changed between versions**: +/// through 1.29 it filtered invalid env names out of `envFrom` and emitted an +/// `InvalidEnvironmentVariableNames` warning event +/// (`pkg/kubelet/kubelet_pods.go:646,654` at v1.29.0); from 1.30 that filter +/// is gone (KEP-4369) and the key is injected verbatim. The same manifest +/// would silently drop a variable on one cluster and set it on another, so we +/// fail closed on the provider side and get one deterministic behavior. +fn is_posix_env_key(key: &str) -> bool { + let mut chars = key.chars(); + match chars.next() { + Some(c) if c == '_' || c.is_ascii_alphabetic() => {} + _ => return false, + } + chars.all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +/// An identity component (L1 item 1) is present only if it is nonempty after +/// trimming — and the **trimmed form is what gets stored**. The validator and +/// the writer must never disagree about the value: a guard that accepts +/// `" wss://relay "` and then writes it with the padding intact has only +/// moved the failure from a loud refusal to a connect error in the harness. +fn identity_component(value: &str) -> Option<&str> { + let trimmed = value.trim(); + (!trimmed.is_empty()).then_some(trimmed) +} + +/// The harness's `allowlist` gate mode, spelled as the desktop serializes +/// `RespondTo` (kebab-case) and as `buzz-acp`'s CLI parses it. +const RESPOND_TO_ALLOWLIST: &str = "allowlist"; + +/// Every gate mode `buzz-acp` accepts, spelled as its `clap::ValueEnum` parses +/// them (`config.rs:95-101`, kebab-case via `RespondTo`'s `Display`). +/// +/// Deliberately the **harness's** four and not the desktop's three: the desktop +/// rejects `nobody` on purpose (`managed_agents/types.rs:871-880`), but the +/// harness starts fine with it. This guard exists to cover non-desktop callers, +/// so inheriting a desktop-only narrowing would refuse a launch that works. +const RESPOND_TO_MODES: [&str; 4] = ["owner-only", RESPOND_TO_ALLOWLIST, "anyone", "nobody"]; + +/// Refuse a respond-to gate the harness will reject at config parse. +/// +/// The local spawn path re-validates this before spawning — "doing it here +/// means we never spawn a doomed process" (`runtime.rs:378`) — but the deploy +/// path projects the record's fields straight through. Without this, a gate +/// the harness refuses becomes a pod that exits 1 at startup; `restartPolicy: +/// Never` turns that into `Terminated` → `Delete` → recreate, and each cycle +/// leaves a Secret the in-call path never reaps (only a later deploy's orphan +/// sweep does, at `ORPHAN_SECRET_MIN_AGE_SECS`). The user-visible ending is +/// "startup not confirmed", indistinguishable from a slow cluster. +/// +/// Mirrors `buzz-acp`'s own rules exactly (`config.rs:95-101,996-1004,629-641`), +/// deliberately including their asymmetry: the allowlist is validated **only** +/// in allowlist mode, and merely warned about otherwise. Validating it in +/// every mode would refuse a deploy whose identical local spawn succeeds — +/// and a stale list is already harmless here, since +/// `BUZZ_ACP_RESPOND_TO_ALLOWLIST` is an authoritative key that tier 3 clears. +fn validate_respond_to_gate(respond_to: &str, allowlist: Option<&[String]>) -> Result<(), String> { + // Exact, untrimmed: `clap` does not trim, so `" allowlist "` is `rc=2` at + // the harness — a parse failure even earlier than the config errors below. + if !RESPOND_TO_MODES.contains(&respond_to) { + return Err(format!( + "deploy refused: respond_to {respond_to:?} is not a mode the \ + harness accepts (expected one of {}) — the pod would fail to \ + parse its arguments, be replaced, and leave a Secret behind on \ + every attempt", + RESPOND_TO_MODES.join(", ") + )); + } + if respond_to != RESPOND_TO_ALLOWLIST { + return Ok(()); + } + let entries = allowlist.unwrap_or_default(); + if entries.is_empty() { + return Err(format!( + "deploy refused: respond_to is {RESPOND_TO_ALLOWLIST:?} but the \ + allowlist is empty — the harness refuses this at startup, so the \ + pod would fail, be replaced, and leave a Secret behind on every \ + attempt" + )); + } + for entry in entries { + let trimmed = entry.trim(); + if trimmed.len() != 64 || !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!( + "deploy refused: invalid pubkey in respond_to_allowlist: \ + {entry:?} (must be exactly 64 hex characters)" + )); + } + } + Ok(()) +} + +/// Inputs the provider itself supplies to the authoritative tier. +pub struct AuthoritativeInputs<'a> { + /// The attempt's generation token — also the Secret's name suffix, so the + /// lifecycle correlator and the Secret generation are one identity. + pub generation: &'a str, + /// Resolved from `provider_config.inactivity_seconds`; `None` when the + /// indefinite opt-in was chosen (which this version refuses elsewhere). + pub inactivity_seconds: Option, +} + +/// Resolve the full pod environment. +/// +/// Order is the spec's, and the function body is deliberately three writes in +/// that order — tier 1, tier 2, tier 3 — so "later wins" is visible rather +/// than argued. +pub fn build_env( + agent: &AgentPayload, + auth: AuthoritativeInputs<'_>, +) -> Result, String> { + let default_launch = LaunchBlock::default(); + let launch = agent.launch.as_ref().unwrap_or(&default_launch); + + let mut env: BTreeMap = BTreeMap::new(); + + // Tier 1 — overridable behavior defaults. + env.extend(launch.policy_env.clone()); + + // Tier 2 — user/layered env. The descriptor already merged + // global < persona < agent, so `agent.env_vars` is NOT re-merged on top + // (§Launch data tier 2) — doing so would resurrect a layer the desktop + // already resolved. When the desktop predates the `launch` block we fall + // back to the legacy field, which is the only case it is the truth. + if agent.launch.is_some() { + env.extend(launch.env.clone()); + } else { + env.extend(agent.env_vars.clone()); + } + + // Validate what the lower tiers contributed, before the authoritative + // tier overwrites any of it. A reserved-key collision is NOT fatal: the + // spec's precedence is later-wins, so tier 3 simply overwrites it, which + // is exactly what a local spawn does. Only a key that has no + // authoritative counterpart to overwrite it — presence suppression — is + // a refusal. + for key in env.keys() { + if !is_posix_env_key(key) { + return Err(format!( + "env key {key:?} is not a POSIX environment variable name \ + ([A-Za-z_][A-Za-z0-9_]*); Kubernetes would treat it \ + inconsistently across cluster versions" + )); + } + if key.eq_ignore_ascii_case(FORBIDDEN_KEY) { + return Err(format!( + "{FORBIDDEN_KEY} must not be set on a remote agent: presence \ + is the only signal that a remote agent is alive" + )); + } + } + + // Tier 3 — authoritative. Every key it owns is cleared first, then the + // values it has are written, so it wins at a key whether or not it has a + // value there (see [`AUTHORITATIVE_KEYS`]). + for key in AUTHORITATIVE_KEYS { + env.remove(*key); + } + // Identity comes from top-level payload fields, never from `env_vars` + // (§Reserved-key rule). All three components must be nonempty: an agent + // that cannot reach a relay is the identityless launch L1 item 1 exists to + // prevent, and a blank field would otherwise sail through into the Secret + // and produce a pod that starts, fails to connect, and looks like a + // network problem. + let Some(relay_url) = identity_component(&agent.relay_url) else { + return Err("deploy refused: relay_url is empty — the agent would have \ + no relay to connect to" + .to_string()); + }; + env.insert("BUZZ_RELAY_URL".into(), relay_url.to_string()); + env.insert("BUZZ_PRIVATE_KEY".into(), agent.private_key_nsec.clone()); + // The git credential/signing helpers read NOSTR_PRIVATE_KEY. + env.insert("NOSTR_PRIVATE_KEY".into(), agent.private_key_nsec.clone()); + + // Owner: at least one of these must resolve, or the harness cannot match + // `!shutdown` and §Stop describes a mechanism that does not work. + let auth_tag = agent.auth_tag.as_deref().and_then(identity_component); + let owner = launch.owner_pubkey.as_deref().and_then(identity_component); + match (auth_tag, owner) { + (None, None) => { + return Err("deploy refused: neither auth_tag nor launch.owner_pubkey \ + resolved — without an owner the agent cannot honor \ + !shutdown" + .to_string()) + } + (tag, own) => { + if let Some(t) = tag { + env.insert("BUZZ_AUTH_TAG".into(), t.to_string()); + } + if let Some(o) = own { + env.insert("BUZZ_ACP_AGENT_OWNER".into(), o.to_string()); + } + } + } + + // The harness and MCP binaries are resolved against the *image's* PATH. + // A host path forwarded from the desktop is guaranteed absent in the + // container (§Launch data, host-resolved values). + if let Some(command) = launch.command.as_deref().filter(|c| !c.is_empty()) { + env.insert("BUZZ_ACP_AGENT_COMMAND".into(), command.to_string()); + } + if !launch.args.is_empty() { + // Comma-joined because that is what the harness's CLI parser decodes, + // and what the desktop's local spawn does. An argument containing a + // comma is unrepresentable in both paths; inventing an escaping + // scheme here would produce args the harness cannot decode. + env.insert("BUZZ_ACP_AGENT_ARGS".into(), launch.args.join(",")); + } + env.insert("BUZZ_ACP_MCP_COMMAND".into(), "buzz-dev-mcp".into()); + + if let Some(respond_to) = agent.respond_to.as_deref().filter(|s| !s.is_empty()) { + validate_respond_to_gate(respond_to, agent.respond_to_allowlist.as_deref())?; + env.insert("BUZZ_ACP_RESPOND_TO".into(), respond_to.to_string()); + } + if let Some(list) = agent + .respond_to_allowlist + .as_ref() + .filter(|l| !l.is_empty()) + { + env.insert("BUZZ_ACP_RESPOND_TO_ALLOWLIST".into(), list.join(",")); + } + + if let Some(secs) = auth.inactivity_seconds { + env.insert("BUZZ_ACP_EXIT_AFTER_INACTIVITY".into(), secs.to_string()); + } + // The generation token doubles as the lifecycle-frame correlator, so pod + // logs and observer frames share one identity (§K8s Secrets). + env.insert(START_NONCE_KEY.into(), auth.generation.to_string()); + + let total: usize = env.values().map(String::len).sum(); + if total > MAX_SECRET_BYTES { + return Err(format!( + "agent environment is {total} bytes; Kubernetes caps Secret data \ + at {MAX_SECRET_BYTES}" + )); + } + + Ok(env) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn payload_json(extra_agent: serde_json::Value) -> AgentPayload { + let mut agent = serde_json::json!({ + "name": "a", + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1example", + "auth_tag": "tag-1", + }); + let (serde_json::Value::Object(base), serde_json::Value::Object(extra)) = + (&mut agent, extra_agent) + else { + panic!("expected objects") + }; + base.extend(extra); + serde_json::from_value(agent).unwrap() + } + + fn build(agent: &AgentPayload) -> Result, String> { + build_env( + agent, + AuthoritativeInputs { + generation: "gen0001", + inactivity_seconds: Some(7200), + }, + ) + } + + #[test] + fn identity_comes_from_top_level_fields() { + let env = build(&payload_json(serde_json::json!({}))).unwrap(); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + } + + /// Wren's amendment, and the spec's later-wins rule: a lower tier that + /// spoofs an authoritative key is *overwritten*, not refused. Refusing + /// would diverge from the local spawn, where the same env is written + /// before the authoritative layer and simply loses. + #[test] + fn lower_tiers_cannot_spoof_authoritative_values() { + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "goose", + "policy_env": { + "BUZZ_PRIVATE_KEY": "nsec1attacker", + "BUZZ_MANAGED_AGENT_START_NONCE": "forged", + }, + "env": { + "BUZZ_RELAY_URL": "wss://attacker.example", + "NOSTR_PRIVATE_KEY": "nsec1attacker", + "BUZZ_AUTH_TAG": "forged-tag", + "BUZZ_ACP_AGENT_OWNER": "cafe", + "BUZZ_ACP_AGENT_COMMAND": "/bin/sh", + "BUZZ_ACP_MCP_COMMAND": "/bin/sh", + "BUZZ_ACP_EXIT_AFTER_INACTIVITY": "0", + }, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["NOSTR_PRIVATE_KEY"], "nsec1example"); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beef"); + assert_eq!(env["BUZZ_ACP_AGENT_COMMAND"], "goose"); + assert_eq!(env["BUZZ_ACP_MCP_COMMAND"], "buzz-dev-mcp"); + assert_eq!(env["BUZZ_ACP_EXIT_AFTER_INACTIVITY"], "7200"); + assert_eq!(env["BUZZ_MANAGED_AGENT_START_NONCE"], "gen0001"); + } + + /// Tier 1 is *overridable* — user env beats policy defaults, matching the + /// local spawn, where the user layer is written after them. Getting this + /// backwards would make remote agents ignore overrides local agents honor. + #[test] + fn user_env_overrides_policy_defaults() { + let agent = payload_json(serde_json::json!({ + "launch": { + "policy_env": {"GOOSE_MODE": "auto", "BUZZ_ACP_MODEL": "sonnet"}, + "env": {"GOOSE_MODE": "chat"}, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + assert_eq!(env["GOOSE_MODE"], "chat"); + assert_eq!(env["BUZZ_ACP_MODEL"], "sonnet"); + } + + /// `launch.env` already contains the merged user env, so re-merging the + /// legacy field would undo a layering the desktop already resolved. + #[test] + fn legacy_env_vars_are_not_remerged_when_launch_present() { + let agent = payload_json(serde_json::json!({ + "env_vars": {"STALE": "yes", "SHARED": "legacy"}, + "launch": {"env": {"SHARED": "resolved"}, "owner_pubkey": "beef"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["SHARED"], "resolved"); + assert!(!env.contains_key("STALE"), "legacy env_vars re-merged"); + } + + /// ...but a desktop predating the `launch` block has nothing else to + /// offer, so the legacy field is the truth in exactly that case. + #[test] + fn legacy_env_vars_used_when_launch_absent() { + let agent = payload_json(serde_json::json!({"env_vars": {"API": "v"}})); + let env = build(&agent).unwrap(); + assert_eq!(env["API"], "v"); + } + + #[test] + fn refuses_when_no_owner_resolves() { + let agent = payload_json(serde_json::json!({"auth_tag": null})); + let err = build(&agent).unwrap_err(); + assert!(err.contains("!shutdown"), "unhelpful error: {err}"); + } + + /// An empty string is not an owner. Without this the refusal is + /// bypassable by a blank field and the pod launches unable to be stopped. + #[test] + fn empty_owner_fields_count_as_absent() { + for blank in ["", " "] { + let agent = payload_json(serde_json::json!({ + "auth_tag": blank, + "launch": {"owner_pubkey": blank} + })); + assert!( + build(&agent).is_err(), + "whitespace resolved as an owner: {blank:?}" + ); + } + } + + /// The other half of every identity guard: what is *stored*. A validator + /// that trims and a writer that doesn't disagree about the value, and the + /// padding reaches the harness inside the Secret. Assert on the stored + /// string — asserting only that the deploy was accepted passes either way. + #[test] + fn identity_components_are_stored_trimmed() { + let agent = payload_json(serde_json::json!({ + "relay_url": " wss://relay.example ", + "auth_tag": " tag-1 ", + "launch": {"owner_pubkey": " beefcafe "} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_RELAY_URL"], "wss://relay.example"); + assert_eq!(env["BUZZ_AUTH_TAG"], "tag-1"); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe"); + } + + /// L1 item 1's third identity component. The nsec arm is enforced in + /// `naming.rs` and the owner arm above; without this one an agent + /// deploys with nothing to connect to — a pod that starts, fails at the + /// relay, and reads as a network fault rather than a refused launch. + #[test] + fn refuses_empty_relay_url() { + for blank in ["", " "] { + let agent = payload_json(serde_json::json!({"relay_url": blank})); + let err = build(&agent).unwrap_err(); + assert!(err.contains("relay_url"), "unhelpful error: {err}"); + } + } + + #[test] + fn owner_pubkey_alone_is_sufficient() { + let agent = payload_json(serde_json::json!({ + "auth_tag": null, + "launch": {"owner_pubkey": "beefcafe"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_AGENT_OWNER"], "beefcafe"); + assert!(!env.contains_key("BUZZ_AUTH_TAG")); + } + + #[test] + fn refuses_presence_suppression() { + let agent = payload_json(serde_json::json!({ + "launch": {"env": {"BUZZ_ACP_NO_PRESENCE": "1"}, "owner_pubkey": "beef"} + })); + let err = build(&agent).unwrap_err(); + assert!(err.contains("BUZZ_ACP_NO_PRESENCE"), "got: {err}"); + } + + /// `foo.bar` is a legal Secret key but not a legal env name: pre-1.30 + /// kubelets drop it, 1.30+ inject it. Refuse rather than behave + /// differently depending on the cluster. + #[test] + fn refuses_non_posix_env_keys() { + for bad in ["foo.bar", "foo-bar", "1LEADING", "", "has space"] { + let agent = payload_json(serde_json::json!({ + "launch": {"env": {bad: "v"}, "owner_pubkey": "beef"} + })); + assert!(build(&agent).is_err(), "accepted non-POSIX key {bad:?}"); + } + } + + #[test] + fn args_are_comma_joined_and_omitted_when_empty() { + let agent = payload_json(serde_json::json!({ + "launch": {"command": "goose", "args": ["run", "--no-session"], "owner_pubkey": "b"} + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_AGENT_ARGS"], "run,--no-session"); + + let agent = payload_json(serde_json::json!({ + "launch": {"command": "goose", "args": [], "owner_pubkey": "b"} + })); + assert!(!build(&agent).unwrap().contains_key("BUZZ_ACP_AGENT_ARGS")); + } + + /// The top-level `model`/`provider` fields are display inputs; their + /// environment consequence is per-runtime and arrives already resolved + /// inside `launch`. A provider-side mapping is wrong for three of the + /// four built-in runtimes. + #[test] + fn provider_never_maps_model_or_provider_itself() { + let agent = payload_json(serde_json::json!({ + "model": "claude-opus", "provider": "anthropic", + "launch": {"owner_pubkey": "beef"} + })); + let env = build(&agent).unwrap(); + for key in [ + "BUZZ_AGENT_PROVIDER", + "BUZZ_AGENT_MODEL", + "GOOSE_PROVIDER", + "GOOSE_MODEL", + ] { + assert!(!env.contains_key(key), "provider mapped {key} itself"); + } + } + + /// `turn_timeout_seconds` is deprecated and ignored upstream; the local + /// spawn does not emit it either. + #[test] + fn turn_timeout_is_not_mapped() { + let agent = payload_json(serde_json::json!({ + "turn_timeout_seconds": 30, "launch": {"owner_pubkey": "b"} + })); + let env = build(&agent).unwrap(); + assert!(!env.keys().any(|k| k.contains("TURN_TIMEOUT"))); + } + + #[test] + fn inactivity_omitted_when_unset() { + let agent = payload_json(serde_json::json!({"launch": {"owner_pubkey": "b"}})); + let env = build_env( + &agent, + AuthoritativeInputs { + generation: "g", + inactivity_seconds: None, + }, + ) + .unwrap(); + assert!(!env.contains_key("BUZZ_ACP_EXIT_AFTER_INACTIVITY")); + } + + /// Structural guard over the whole authoritative list at once: whatever + /// the lower tiers contain, no authoritative key holds a lower-tier value + /// — including the conditionally-written ones the authoritative tier has + /// nothing to say about, which must be **absent** rather than spoofed. + /// This test caught exactly that: `BUZZ_ACP_AGENT_ARGS` is only written + /// when `launch.args` is non-empty, so plain later-wins overwrite left the + /// spoofed value in place. + #[test] + fn no_authoritative_key_retains_a_lower_tier_value() { + let spoofed: serde_json::Map = AUTHORITATIVE_KEYS + .iter() + .map(|k| ((*k).to_string(), serde_json::json!("SPOOFED"))) + .collect(); + // Split across both lower tiers: policy_env and env are separate + // insertion points, and a fix that only cleared one would pass a + // single-tier test. + let agent = payload_json(serde_json::json!({ + "launch": { + "command": "goose", + "policy_env": spoofed.clone(), + "env": spoofed, + "owner_pubkey": "beef" + } + })); + let env = build(&agent).unwrap(); + for key in AUTHORITATIVE_KEYS { + assert_ne!( + env.get(*key).map(String::as_str), + Some("SPOOFED"), + "{key} kept its lower-tier value" + ); + } + // The keys the authoritative tier had no value for are gone, not + // merely different. + for absent in [ + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + ] { + assert!(!env.contains_key(absent), "{absent} survived the clear"); + } + } + + /// A 64-hex pubkey, the only allowlist entry shape the harness accepts. + fn pubkey(fill: char) -> String { + std::iter::repeat_n(fill, 64).collect() + } + + /// The gate the harness refuses first (`config.rs:996-1004`). Refusing it + /// here is the difference between one error message and an unbounded + /// fail-replace loop that leaves a Secret per attempt. + #[test] + fn allowlist_mode_with_an_empty_list_is_refused() { + for empty in [serde_json::json!([]), serde_json::Value::Null] { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": empty, + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("the allowlist is empty"), + "unexpected error: {err}" + ); + } + } + + /// `config.rs:629-641` — each entry must be exactly 64 hex characters. + /// The rejects are the distinct ways to miss that: too short, right length + /// but not hex, empty, and one character short of valid. + #[test] + fn an_allowlist_entry_that_is_not_64_hex_is_refused() { + for bad in ["abc1234", &"z".repeat(64), "", &pubkey('a')[..63]] { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": [pubkey('a'), bad], + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("must be exactly 64 hex characters"), + "{bad:?} was accepted; error was: {err}" + ); + } + } + + /// The positive control: the guard refuses bad gates, not every gate. + /// Without this, a validator that refused unconditionally would pass both + /// tests above. + #[test] + fn a_valid_allowlist_gate_is_accepted_and_comma_joined() { + let agent = payload_json(serde_json::json!({ + "respond_to": "allowlist", + "respond_to_allowlist": [pubkey('a'), pubkey('b')], + })); + let env = build(&agent).unwrap(); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], "allowlist"); + assert_eq!( + env["BUZZ_ACP_RESPOND_TO_ALLOWLIST"], + format!("{},{}", pubkey('a'), pubkey('b')) + ); + } + + /// The harness validates the allowlist **only** in allowlist mode and + /// merely warns otherwise (`config.rs:1005-1010`). A stricter provider + /// would refuse a deploy whose identical local spawn succeeds, so this + /// pins the asymmetry rather than leaving it to look like an oversight. + #[test] + fn a_junk_allowlist_is_tolerated_outside_allowlist_mode() { + for mode in ["owner-only", "anyone"] { + let agent = payload_json(serde_json::json!({ + "respond_to": mode, + "respond_to_allowlist": ["not-a-pubkey"], + })); + let env = build(&agent) + .unwrap_or_else(|e| panic!("{mode} with a stale list must deploy: {e}")); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode); + } + } + + /// `respond_to` is an opaque `String` on the wire but a `clap::ValueEnum` + /// at the harness, so an unrecognized mode dies at `rc=2` — before config + /// parsing runs at all, earlier than either refusal above. Measured + /// against the built binary: `invalid value 'npub1abc' for '--respond-to'`. + /// This is the shape our own fixture carried until it was corrected. + #[test] + fn a_mode_the_harness_cannot_parse_is_refused() { + for bad in ["npub1abc", "OWNER-ONLY", "owner_only", "allowlistt", "x"] { + let agent = payload_json(serde_json::json!({ "respond_to": bad })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("is not a mode the harness accepts"), + "{bad:?} was accepted; error was: {err}" + ); + } + } + + /// `clap` does not trim its value-enum input, so a padded mode is `rc=2` + /// even though the same string trimmed is valid. Measured: `invalid value + /// ' allowlist ' for '--respond-to'`. Trimming here would accept a deploy + /// the harness refuses — the exact direction this guard exists to prevent. + #[test] + fn a_padded_mode_is_refused_because_clap_does_not_trim() { + for padded in [" allowlist ", "allowlist ", " owner-only", "\tnobody"] { + let agent = payload_json(serde_json::json!({ + "respond_to": padded, + "respond_to_allowlist": [pubkey('a')], + })); + let err = build(&agent).unwrap_err(); + assert!( + err.contains("is not a mode the harness accepts"), + "{padded:?} was accepted; error was: {err}" + ); + } + } + + /// Positive control for the mode check, and the reason it validates the + /// harness's four rather than the desktop's three: `nobody` is rejected by + /// `parse_wire` on purpose (`managed_agents/types.rs:871-880`) but starts + /// fine at the harness. A guard mirroring the desktop enum would refuse a + /// working launch from a non-desktop caller — the callers this guard is + /// for. Without this test, refusing `nobody` would pass everything above. + #[test] + fn every_mode_the_harness_accepts_is_deployable() { + for mode in ["owner-only", "allowlist", "anyone", "nobody"] { + let agent = payload_json(serde_json::json!({ + "respond_to": mode, + "respond_to_allowlist": [pubkey('a')], + })); + let env = build(&agent) + .unwrap_or_else(|e| panic!("{mode} is valid at the harness but was refused: {e}")); + assert_eq!(env["BUZZ_ACP_RESPOND_TO"], mode); + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/gc.rs b/crates/buzz-backend-kubernetes/src/gc.rs new file mode 100644 index 00000000000..2e18825aa47 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/gc.rs @@ -0,0 +1,368 @@ +//! Preflight garbage collection (spec §K8s GC, `docs/remote-agents.md:1282-1335`). +//! +//! GC runs on every deploy, after identity derivation and before the state +//! transition. It deletes terminated pods (and their referenced Secrets) and +//! age-eligible orphan Secrets — every one of which must pass the full-pubkey +//! annotation check *and* carry the management marker. An unmarked object is +//! never GC'd regardless of its labels. +//! +//! The decision layer here is pure. The effectful caller supplies the observed +//! objects and the apiserver's clock; this module decides what may be deleted. + +use crate::naming::AgentIdentity; +use crate::observe::{referenced_secret, secret_is_ours}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Pod, Secret}; + +/// The deploy operation deadline (spec §Deploy: `timeout: 600s`). +pub const OPERATION_DEADLINE_SECS: i64 = 600; + +/// An unreferenced Secret is GC-eligible only once it is older than **twice** +/// the deploy deadline. Rationale: Secret-create → pod-create is not atomic +/// against an independent GC pass, so without the gate a concurrent attempt's +/// preflight GC can delete a Secret whose pod has not been created yet and +/// strand that deploy. The age bound makes "unreferenced" mean "provably +/// abandoned" — any attempt that could still reference it has exceeded its own +/// deadline (`:1301-1319`). +pub const ORPHAN_SECRET_MIN_AGE_SECS: i64 = 2 * OPERATION_DEADLINE_SECS; + +/// What a GC pass decided to delete. Names only: the caller re-reads each +/// object's own fence at delete time. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct GcPlan { + /// Terminated, verified, marker-bearing pods. + pub pods: Vec, + /// Age-eligible, verified, marker-bearing orphan Secrets. + pub secrets: Vec, +} + +/// Plan a GC pass. +/// +/// `now` is the apiserver's clock — the HTTP `Date` header from the very list +/// call that produced `secrets`. `None` means the header was absent or +/// unparseable, in which case **orphan-Secret GC is skipped entirely** rather +/// than falling back to local time: this provider runs on a user's desktop, +/// and a local clock fast by more than the margin does not race — it +/// deterministically computes every in-flight Secret as expired, on every +/// pass, reopening exactly the interleaving the gate exists to close +/// (`:1321-1335`). A deferred cleanup is free; a wrong deletion is not. +/// +/// Terminated-pod GC does not use the clock and is unaffected. +pub fn plan( + identity: &AgentIdentity, + pods: &[Pod], + secrets: &[Secret], + terminated: impl Fn(&Pod) -> bool, + now: Option>, +) -> GcPlan { + // Only pods that pass the full fence participate — in either direction. + // An unverified pod is neither deleted nor allowed to protect a Secret: + // it cannot be ours, so its `envFrom` cannot reference our generation. + let ours: Vec<&Pod> = pods + .iter() + .filter(|p| { + crate::observe::verify(p, identity, crate::classify::Startup::Started).is_some() + }) + .collect(); + + let doomed_pods: Vec<&&Pod> = ours.iter().filter(|p| terminated(p)).collect(); + + // A Secret referenced by ANY existing pod is protected — deliberately + // including not-yet-started pods, whose `envFrom` is exactly as + // load-bearing as a running pod's (`:1262-1264`). Pods being GC'd in this + // same pass are excluded, so their Secrets go with them. + let doomed_names: Vec<&str> = doomed_pods + .iter() + .filter_map(|p| p.metadata.name.as_deref()) + .collect(); + let protected: Vec = ours + .iter() + .filter(|p| !doomed_names.contains(&p.metadata.name.as_deref().unwrap_or_default())) + .filter_map(|p| referenced_secret(p)) + .collect(); + + let mut plan = GcPlan { + pods: doomed_names.iter().map(|n| n.to_string()).collect(), + secrets: doomed_pods + .iter() + .filter_map(|p| referenced_secret(p)) + .collect(), + }; + + // Orphan sweep: only with a server clock. + if let Some(now) = now { + for secret in secrets { + if !secret_is_ours(secret, identity) { + continue; + } + let Some(name) = secret.metadata.name.as_deref() else { + continue; + }; + if protected.contains(&name.to_string()) || plan.secrets.iter().any(|s| s == name) { + continue; + } + let Some(created) = secret.metadata.creation_timestamp.as_ref() else { + // No server-assigned timestamp means no age proof. Skip. + continue; + }; + if (now - created.0).num_seconds() >= ORPHAN_SECRET_MIN_AGE_SECS { + plan.secrets.push(name.to_string()); + } + } + } + + plan +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::naming::{ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY}; + use k8s_openapi::api::core::v1::{Container, EnvFromSource, PodSpec, SecretEnvSource}; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + use std::collections::BTreeMap; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn pod_named(id: &AgentIdentity, name: &str, secret: Option<&str>) -> Pod { + Pod { + metadata: ObjectMeta { + name: Some(name.into()), + uid: Some(format!("uid-{name}")), + resource_version: Some("1".into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + ..Default::default() + }, + spec: secret.map(|s| PodSpec { + containers: vec![Container { + name: "agent".into(), + env_from: Some(vec![EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: s.into(), + optional: Some(false), + }), + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }), + ..Default::default() + } + } + + fn secret_named(id: &AgentIdentity, name: &str, age_secs: i64, now: DateTime) -> Secret { + Secret { + metadata: ObjectMeta { + name: Some(name.into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + creation_timestamp: Some(Time(now - chrono::Duration::seconds(age_secs))), + ..Default::default() + }, + ..Default::default() + } + } + + fn never(_: &Pod) -> bool { + false + } + fn always(_: &Pod) -> bool { + true + } + + #[test] + fn terminated_pods_and_their_secrets_are_collected_together() { + let id = identity(); + let now = Utc::now(); + let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let plan = plan(&id, &[pod], &[], always, Some(now)); + assert_eq!(plan.pods, ["buzz-agent-dead"]); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } + + #[test] + fn live_pods_are_never_collected() { + let id = identity(); + let pod = pod_named(&id, "buzz-agent-live", Some("buzz-agent-live-gen1")); + let plan = plan(&id, &[pod], &[], never, Some(Utc::now())); + assert_eq!(plan, GcPlan::default()); + } + + /// The auto-repair fence applies to GC identically: an object that lacks + /// the marker, or carries a different pubkey, is never touched — however + /// well its labels match. + #[test] + fn unmarked_and_mismatched_objects_are_never_collected() { + let id = identity(); + let other = identity(); + let now = Utc::now(); + + let mut unmarked = pod_named(&id, "look-alike", Some("look-alike-gen1")); + let mut labels = id.labels(); + labels.remove(LABEL_MANAGED_BY); + unmarked.metadata.labels = Some(labels); + + let mut foreign = pod_named(&id, "someone-elses", Some("someone-elses-gen1")); + foreign.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + + let mut unmarked_secret = secret_named(&id, "orphan-unmarked", 100_000, now); + unmarked_secret.metadata.labels = Some(BTreeMap::new()); + let mut foreign_secret = secret_named(&id, "orphan-foreign", 100_000, now); + foreign_secret.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + + let plan = plan( + &id, + &[unmarked, foreign], + &[unmarked_secret, foreign_secret], + always, + Some(now), + ); + assert_eq!( + plan, + GcPlan::default(), + "GC touched an object it does not own" + ); + } + + /// The interleaving the age gate exists to close: attempt A creates its + /// Secret; concurrent attempt B's preflight GC runs before A creates its + /// pod. Without the gate B deletes A's Secret and strands A. + #[test] + fn young_unreferenced_secrets_are_protected() { + let id = identity(); + let now = Utc::now(); + let fresh = secret_named(&id, "buzz-agent-x-gen-inflight", 5, now); + assert_eq!( + plan(&id, &[], &[fresh], never, Some(now)), + GcPlan::default() + ); + } + + /// Past twice the deadline, any attempt that could still reference the + /// Secret has exceeded its own deadline — so it is provably abandoned. + #[test] + fn secrets_older_than_twice_the_deadline_are_collected() { + let id = identity(); + let now = Utc::now(); + let old = secret_named( + &id, + "buzz-agent-x-gen-abandoned", + ORPHAN_SECRET_MIN_AGE_SECS + 1, + now, + ); + let plan = plan(&id, &[], &[old], never, Some(now)); + assert_eq!(plan.secrets, ["buzz-agent-x-gen-abandoned"]); + } + + /// The boundary itself, both sides. `>= 1200s` is eligible. + #[test] + fn age_gate_boundary_is_exact() { + let id = identity(); + let now = Utc::now(); + let just_under = secret_named(&id, "under", ORPHAN_SECRET_MIN_AGE_SECS - 1, now); + let exactly = secret_named(&id, "exact", ORPHAN_SECRET_MIN_AGE_SECS, now); + assert!(plan(&id, &[], &[just_under], never, Some(now)) + .secrets + .is_empty()); + assert_eq!( + plan(&id, &[], &[exactly], never, Some(now)).secrets, + ["exact"] + ); + } + + /// The same-clock rule. No apiserver `Date` header → skip the orphan + /// sweep entirely. A local clock fast by more than the margin would + /// silently delete every in-flight Secret on every pass. + #[test] + fn without_a_server_clock_the_orphan_sweep_is_skipped() { + let id = identity(); + let now = Utc::now(); + let ancient = secret_named(&id, "buzz-agent-x-gen-ancient", 10_000_000, now); + let plan = plan(&id, &[], &[ancient], never, None); + assert!( + plan.secrets.is_empty(), + "orphan swept without a server clock — a fast local clock would delete live Secrets" + ); + } + + /// ...but terminated-pod GC does not consult the clock, so it still runs. + #[test] + fn terminated_pod_gc_runs_without_a_server_clock() { + let id = identity(); + let pod = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let plan = plan(&id, &[pod], &[], always, None); + assert_eq!(plan.pods, ["buzz-agent-dead"]); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } + + /// "Existing" includes not-yet-started pods: a Secret referenced by a pod + /// still pulling its image must not be swept, however old it is. + #[test] + fn secrets_referenced_by_a_pending_pod_are_protected() { + let id = identity(); + let now = Utc::now(); + let pending = pod_named(&id, "buzz-agent-pending", Some("buzz-agent-pending-gen1")); + let old = secret_named(&id, "buzz-agent-pending-gen1", 10_000_000, now); + let plan = plan(&id, &[pending], &[old], never, Some(now)); + assert!(plan.secrets.is_empty(), "swept a referenced Secret"); + } + + /// A Secret with no server-assigned creationTimestamp has no age proof, + /// so it is skipped rather than assumed old. + #[test] + fn secrets_without_a_creation_timestamp_are_skipped() { + let id = identity(); + let now = Utc::now(); + let mut no_timestamp = secret_named(&id, "buzz-agent-x-gen-unknown", 10_000_000, now); + no_timestamp.metadata.creation_timestamp = None; + assert!(plan(&id, &[], &[no_timestamp], never, Some(now)) + .secrets + .is_empty()); + } + + /// A Secret belonging to a pod being collected in this same pass goes with + /// it, and must not be listed twice. + #[test] + fn a_collected_pods_secret_is_listed_once() { + let id = identity(); + let now = Utc::now(); + let dead = pod_named(&id, "buzz-agent-dead", Some("buzz-agent-dead-gen1")); + let its_secret = secret_named(&id, "buzz-agent-dead-gen1", 10_000_000, now); + let plan = plan(&id, &[dead], &[its_secret], always, Some(now)); + assert_eq!(plan.secrets, ["buzz-agent-dead-gen1"]); + } +} diff --git a/crates/buzz-backend-kubernetes/src/image.rs b/crates/buzz-backend-kubernetes/src/image.rs new file mode 100644 index 00000000000..b35e6bab121 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/image.rs @@ -0,0 +1,181 @@ +//! Image reference validation (spec §Image). +//! +//! The object holding this reference runs with an nsec, so the reference must +//! be **immutable**. Registry tags are mutable pointers — Kubernetes itself +//! distinguishes them from digests for exactly this reason — so a tag-only +//! reference is rejected, not just `:latest`. +//! +//! v1 ships no baked default (there is no published `ghcr.io/block/buzz-sprig` +//! image yet, so a compile-time digest would be a placeholder). `image` is +//! therefore required, and its absence fails closed with a named field. + +/// A validated, digest-qualified image reference. +/// +/// The inner string is always in canonical tagless form `name@sha256:`: +/// `name:tag@sha256:…` normalizes by dropping the tag, because the tag is +/// decorative once a digest pins the content, and leaving it in would make +/// two references to identical bytes produce different create-intent +/// fingerprints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ImageRef(String); + +impl ImageRef { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ImageRef { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// Parse and normalize a user-supplied image reference. +pub fn parse(raw: &str) -> Result { + let reference = raw.trim(); + if reference.is_empty() { + return Err("provider_config.image is required: v1 ships no default \ + image, so the digest-pinned image to run must be given \ + explicitly" + .to_string()); + } + + let mut parts = reference.split('@'); + let name_and_tag = parts.next().unwrap_or_default(); + let digest = match (parts.next(), parts.next()) { + (Some(d), None) => d, + (None, _) => { + return Err(format!( + "provider_config.image {reference:?} is not digest-pinned: a \ + tag is a mutable pointer, and this object runs with the \ + agent's private key. Use name@sha256:<64 hex chars>" + )) + } + (Some(_), Some(_)) => { + return Err(format!( + "provider_config.image {reference:?} contains more than one '@'" + )) + } + }; + + let hex = digest.strip_prefix("sha256:").ok_or_else(|| { + format!("provider_config.image digest {digest:?} must start with 'sha256:'") + })?; + // Lowercase only: OCI canonicalizes digest hex, and accepting uppercase + // would let two spellings of one digest produce two fingerprints. + if hex.len() != 64 + || !hex + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err(format!( + "provider_config.image digest {digest:?} must be exactly 64 \ + lowercase hex characters" + )); + } + + // Drop any tag: `name:tag@sha256:…` and `name@sha256:…` name the same + // bytes and must fingerprint identically. Only a *final* colon segment + // that isn't a port counts as a tag — `host:5000/name` has no tag. + let name = match name_and_tag.rfind(':') { + Some(colon) if !name_and_tag[colon + 1..].contains('/') => &name_and_tag[..colon], + _ => name_and_tag, + }; + if name.is_empty() { + return Err(format!( + "provider_config.image {reference:?} has no repository name" + )); + } + + Ok(ImageRef(format!("{name}@sha256:{hex}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_digest_pinned_reference() { + let d = "a".repeat(64); + let r = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("ghcr.io/block/buzz-sprig@sha256:{d}")); + } + + /// The normalization that keeps the fingerprint stable: two spellings of + /// the same bytes must produce one reference. + #[test] + fn strips_tag_from_tag_plus_digest_form() { + let d = "b".repeat(64); + let tagged = parse(&format!("ghcr.io/block/buzz-sprig:v1.2@sha256:{d}")).unwrap(); + let plain = parse(&format!("ghcr.io/block/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(tagged, plain); + } + + /// A registry port is not a tag. `host:5000/name` must keep its port. + #[test] + fn registry_port_is_not_mistaken_for_a_tag() { + let d = "c".repeat(64); + let r = parse(&format!("localhost:5000/buzz-sprig@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}")); + } + + #[test] + fn port_and_tag_together_drops_only_the_tag() { + let d = "d".repeat(64); + let r = parse(&format!("localhost:5000/buzz-sprig:dev@sha256:{d}")).unwrap(); + assert_eq!(r.as_str(), format!("localhost:5000/buzz-sprig@sha256:{d}")); + } + + /// Wren's amendment: *every* tag-only reference is rejected, not just + /// `:latest`. A `sha-` tag is traceable but still movable. + #[test] + fn rejects_every_tag_only_reference() { + for bad in [ + "ghcr.io/block/buzz-sprig:latest", + "ghcr.io/block/buzz-sprig:v1.2.3", + "ghcr.io/block/buzz-sprig:sha-abc1234", + "ghcr.io/block/buzz-sprig", + "localhost:5000/buzz-sprig", + ] { + let err = parse(bad).unwrap_err(); + assert!(err.contains("digest-pinned"), "for {bad:?} got: {err}"); + } + } + + /// Uppercase hex is a second spelling of one digest; accepting it would + /// let the same image fingerprint two ways. + #[test] + fn rejects_uppercase_digest_hex() { + let d = "A".repeat(64); + assert!(parse(&format!("img@sha256:{d}")).is_err()); + } + + #[test] + fn rejects_malformed_digests() { + let short = "a".repeat(63); + let long = "a".repeat(65); + let ok = "a".repeat(64); + for bad in [ + format!("img@sha256:{short}"), + format!("img@sha256:{long}"), + format!("img@sha512:{ok}"), + format!("img@{ok}"), + format!("img@sha256:{}", "g".repeat(64)), + format!("img@sha256:{ok}@sha256:{ok}"), + format!("@sha256:{ok}"), + ] { + assert!(parse(&bad).is_err(), "accepted {bad:?}"); + } + } + + /// v1 has no baked default, so an absent image is an error that names the + /// field rather than a silent fallback. + #[test] + fn empty_reference_names_the_field() { + for empty in ["", " "] { + let err = parse(empty).unwrap_err(); + assert!(err.contains("provider_config.image"), "got: {err}"); + } + } +} diff --git a/crates/buzz-backend-kubernetes/src/intent.rs b/crates/buzz-backend-kubernetes/src/intent.rs new file mode 100644 index 00000000000..73218f83bff --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/intent.rs @@ -0,0 +1,309 @@ +//! The create-intent fingerprint (spec §Deploy State Machine, create-intent +//! fingerprint, `docs/remote-agents.md:796-828`). +//! +//! The fingerprint is an unkeyed SHA-256 over a canonical serialization of the +//! provider's non-secret create-intent template. A plain hash is safe *only* +//! because of the scope rule: the input covers exactly the provider-controlled +//! fields that can affect scheduling or container creation, and **never Secret +//! data or attempt identity**. Hashing low-entropy secrets into a +//! world-readable annotation would be a dictionary oracle. +//! +//! That rule is enforced structurally rather than remembered. [`IntentTemplate`] +//! is a *pre-binding* type: it has no field that can hold Secret material or a +//! generation token, so there is no expression that hashes one. The +//! per-attempt Secret name never appears — the pod's `envFrom` is represented +//! by the fixed [`SECRET_PLACEHOLDER`], because otherwise every attempt would +//! diverge from every other by construction. +//! +//! Server- and admission-produced output (UID, `resourceVersion`, timestamps, +//! defaulted fields, the annotation itself) is excluded the same way: the +//! serializer is only ever handed this template, never a live `Pod`, so the +//! exclusion is checkable by inspection. + +use crate::image::ImageRef; +use serde::Serialize; +use sha2::{Digest, Sha256}; + +/// Stands in for the per-attempt Secret name in the `envFrom` position. +/// A real generation token here would make every attempt diverge. +const SECRET_PLACEHOLDER: &str = ""; + +/// The recorded/computed create intent: a hex SHA-256 digest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fingerprint(String); + +impl Fingerprint { + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Read a fingerprint off a pod annotation. Any recorded string is + /// accepted verbatim: comparison is equality against a freshly computed + /// value, so a malformed annotation simply reads as divergence — which is + /// the correct outcome for a pod this provider version did not write. + pub fn from_annotation(value: &str) -> Self { + Self(value.to_string()) + } + + #[cfg(test)] + pub fn for_test(seed: &str) -> Self { + Self(format!("test-{seed}")) + } +} + +impl std::fmt::Display for Fingerprint { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// The non-secret, pre-binding description of the pod this deploy would +/// create. Every field is provider-controlled and scheduling-relevant; there +/// is deliberately no field for env values, Secret data, or the generation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IntentTemplate { + /// Schema version of the template itself. Bumping it re-fingerprints every + /// pod, which is the intended way to roll out a pod-shape change. + pub template_version: u32, + pub namespace: String, + /// Normalized, digest-qualified image reference. + pub image: String, + pub cpu_request: String, + pub memory_request: String, + pub cpu_limit: String, + pub memory_limit: String, + pub service_account: Option, + pub restart_policy: &'static str, + pub termination_grace_period_seconds: i64, + /// Env *keys* only, sorted. Keys are pod-shape (a renamed key changes the + /// container's contract); values are Secret material and must not be here. + pub env_keys: Vec, + /// Fixed placeholder for the per-attempt Secret in `envFrom`. + pub env_from_secret: &'static str, + pub workspace_mount_path: String, + pub run_as_user: i64, + pub run_as_group: i64, +} + +/// Current template schema version. +pub const TEMPLATE_VERSION: u32 = 1; + +impl IntentTemplate { + /// Compute the fingerprint. `serde_json` on a struct with declared field + /// order plus pre-sorted `env_keys` is a canonical serialization: the same + /// template always produces the same bytes. + pub fn fingerprint(&self) -> Fingerprint { + let canonical = serde_json::to_vec(self).expect("intent template is plain data"); + Fingerprint(hex::encode(Sha256::digest(&canonical))) + } + + /// Build from resolved pod-shape inputs. `env_keys` is sorted here rather + /// than at the call site so key ordering can never leak into the digest. + /// + /// The fixed pod-shape constants are read from [`crate::config`] rather + /// than passed in: `pod::build_pod` stamps the pod from those same + /// constants, so the fingerprint cannot describe a pod shape different + /// from the one actually created. Threading them through as arguments + /// would make that agreement a thing to test instead of a thing that holds. + pub fn new( + namespace: &str, + image: &ImageRef, + resources: &crate::config::Resources, + service_account: Option<&str>, + env_keys: impl IntoIterator, + ) -> Self { + let mut env_keys: Vec = env_keys.into_iter().collect(); + env_keys.sort(); + Self { + template_version: TEMPLATE_VERSION, + namespace: namespace.to_string(), + image: image.as_str().to_string(), + cpu_request: resources.cpu_request.clone(), + memory_request: resources.memory_request.clone(), + cpu_limit: resources.cpu_limit.clone(), + memory_limit: resources.memory_limit.clone(), + service_account: service_account.map(str::to_string), + restart_policy: crate::config::RESTART_POLICY, + termination_grace_period_seconds: crate::config::TERMINATION_GRACE_SECONDS, + env_keys, + env_from_secret: SECRET_PLACEHOLDER, + workspace_mount_path: crate::config::WORKSPACE_PATH.to_string(), + run_as_user: crate::config::RUN_AS_UID, + run_as_group: crate::config::RUN_AS_GID, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Resources; + + fn image(byte: char) -> ImageRef { + crate::image::parse(&format!( + "ghcr.io/block/buzz-sprig@sha256:{}", + byte.to_string().repeat(64) + )) + .unwrap() + } + + fn template() -> IntentTemplate { + IntentTemplate::new( + "buzz-agents", + &image('a'), + &Resources::default(), + None, + ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()], + ) + } + + #[test] + fn fingerprint_is_deterministic() { + assert_eq!(template().fingerprint(), template().fingerprint()); + } + + #[test] + fn fingerprint_is_hex_sha256() { + let fp = template().fingerprint(); + assert_eq!(fp.as_str().len(), 64); + assert!(fp.as_str().chars().all(|c| c.is_ascii_hexdigit())); + } + + /// Key *order* must not reach the digest, or two identical environments + /// built in different orders would look like a config change. + #[test] + fn env_key_order_does_not_affect_the_digest() { + let a = IntentTemplate::new( + "ns", + &image('a'), + &Resources::default(), + None, + ["A".to_string(), "B".to_string(), "C".to_string()], + ); + let b = IntentTemplate::new( + "ns", + &image('a'), + &Resources::default(), + None, + ["C".to_string(), "A".to_string(), "B".to_string()], + ); + assert_eq!(a.fingerprint(), b.fingerprint()); + } + + /// A mutation applied to a fresh template clone, named for its assertion + /// message. + type Mutation = (&'static str, Box); + + /// Every scheduling-relevant knob must move the digest — this is the + /// wedge escape (§Deploy State Machine never-started recoverable row). + /// Exhaustive by construction: each mutation is applied to a fresh clone. + #[test] + fn every_scheduling_field_changes_the_digest() { + let base = template(); + let baseline = base.fingerprint(); + + let mutations: Vec = vec![ + ( + "template_version", + Box::new(|t: &mut IntentTemplate| t.template_version += 1), + ), + ( + "namespace", + Box::new(|t: &mut IntentTemplate| t.namespace = "other".into()), + ), + ( + "image", + Box::new(|t: &mut IntentTemplate| t.image = image('b').as_str().into()), + ), + ( + "cpu_request", + Box::new(|t: &mut IntentTemplate| t.cpu_request = "4".into()), + ), + ( + "memory_request", + Box::new(|t: &mut IntentTemplate| t.memory_request = "8Gi".into()), + ), + ( + "cpu_limit", + Box::new(|t: &mut IntentTemplate| t.cpu_limit = "8".into()), + ), + ( + "memory_limit", + Box::new(|t: &mut IntentTemplate| t.memory_limit = "16Gi".into()), + ), + ( + "service_account", + Box::new(|t: &mut IntentTemplate| t.service_account = Some("sa".into())), + ), + ( + "restart_policy", + Box::new(|t: &mut IntentTemplate| t.restart_policy = "OnFailure"), + ), + ( + "grace_period", + Box::new(|t: &mut IntentTemplate| t.termination_grace_period_seconds = 30), + ), + ( + "env_keys", + Box::new(|t: &mut IntentTemplate| t.env_keys.push("NEW_KEY".into())), + ), + ( + "workspace_mount_path", + Box::new(|t: &mut IntentTemplate| t.workspace_mount_path = "/w".into()), + ), + ( + "run_as_user", + Box::new(|t: &mut IntentTemplate| t.run_as_user = 2000), + ), + ( + "run_as_group", + Box::new(|t: &mut IntentTemplate| t.run_as_group = 2000), + ), + ]; + + for (name, mutate) in mutations { + let mut t = base.clone(); + mutate(&mut t); + assert_ne!( + t.fingerprint(), + baseline, + "{name} did not affect the digest" + ); + } + } + + /// The scope rule, asserted on the bytes: no Secret value and no + /// generation token can appear in the serialization, because the type has + /// nowhere to put them. The placeholder is what `envFrom` contributes. + #[test] + fn serialization_contains_no_secret_material_or_attempt_identity() { + let json = serde_json::to_string(&template()).unwrap(); + for forbidden in ["nsec1", "SPOOFED", "wss://", "gen0001"] { + assert!( + !json.contains(forbidden), + "template leaked {forbidden}: {json}" + ); + } + assert!(json.contains(SECRET_PLACEHOLDER)); + } + + /// Two attempts for the same agent differ only in generation, which is + /// absent from the template — so their fingerprints must be equal, or the + /// divergence discriminator would fire on every single deploy. + #[test] + fn attempts_differing_only_by_generation_do_not_diverge() { + // There is no generation input to pass; that *is* the property. The + // test states it explicitly so a future field addition breaks here. + assert_eq!(template().fingerprint(), template().fingerprint()); + let json = serde_json::to_string(&template()).unwrap(); + assert_eq!(json.matches(SECRET_PLACEHOLDER).count(), 1); + } + + /// A recorded annotation this provider version did not write reads as + /// divergence rather than an error. + #[test] + fn unrecognized_annotation_reads_as_divergence() { + let recorded = Fingerprint::from_annotation("not-a-digest"); + assert_ne!(recorded, template().fingerprint()); + } +} diff --git a/crates/buzz-backend-kubernetes/src/main.rs b/crates/buzz-backend-kubernetes/src/main.rs new file mode 100644 index 00000000000..5d521a9d372 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/main.rs @@ -0,0 +1,199 @@ +//! Kubernetes backend provider for Buzz remote agents +//! (spec `docs/remote-agents.md`). +//! +//! One process per operation: read exactly one JSON request from stdin, write +//! exactly one JSON response to stdout, exit. The exit code carries exactly +//! one bit — 0 for a response that was produced, 1 for a failure to produce +//! one. Everything a caller needs to distinguish is *inside* the response's +//! `ok` field, because a provider that encoded outcomes in exit codes would +//! have a second, redundant error channel to keep in sync (§Provider Protocol). + +mod classify; +mod client; +mod cluster; +mod config; +mod env; +mod gc; +mod image; +mod intent; +mod naming; +mod observe; +mod pod; +mod reconcile; +mod wire; + +use std::io::Read; +use wire::{Request, Response}; + +/// The provider a shared-compute agent resolves to. Refused here as the +/// spec's backstop: a mesh agent runs on the relay's compute, so deploying it +/// as a pod would create a second, contending consumer of the same agent +/// identity (`:214-219`). +const RELAY_MESH_PROVIDER: &str = "relay-mesh"; + +fn main() { + // rustls needs a process-level provider before the first TLS connection. + // The release build compiles every sidecar in one cargo invocation, which + // unifies the `ring` and `aws-lc-rs` features and leaves rustls unable to + // auto-select — so this is an explicit install, not a default. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let mut input = String::new(); + if let Err(e) = std::io::stdin().read_to_string(&mut input) { + // No request means no request_id and no response contract to honor. + // This is the one path that exits nonzero. + eprintln!("could not read the request from stdin: {e}"); + std::process::exit(1); + } + + let response = respond(&input); + println!( + "{}", + serde_json::to_string(&response).unwrap_or_else(|e| { + // The response types are plain data; this cannot fail in practice, + // and a hand-built object is still a conforming response. + format!(r#"{{"ok":false,"error":"could not serialize a response: {e}"}}"#) + }) + ); +} + +/// Produce the single response for one request. Separated from `main` so the +/// whole dispatch is testable without a process. +fn respond(input: &str) -> Response { + // Parsed as raw JSON first: the relay-mesh refusal below MUST see the wire + // value, and `AgentPayload` deliberately does not carry `provider`. + let raw: serde_json::Value = match serde_json::from_str(input) { + Ok(value) => value, + Err(e) => return Response::error(format!("request is not valid JSON: {e}")), + }; + + if let Some(refusal) = refuse_relay_mesh(&raw) { + return Response::error(refusal); + } + + let request: Request = match serde_json::from_value(raw) { + Ok(request) => request, + Err(e) => return Response::error(format!("could not understand the request: {e}")), + }; + + match request { + Request::Info => Response::info(), + Request::Deploy(deploy) => { + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(e) => return Response::error(format!("could not start the runtime: {e}")), + }; + match runtime.block_on(deploy_agent(&deploy)) { + Ok(agent_id) => Response::deployed(agent_id), + Err(e) => Response::error(e), + } + } + } +} + +/// Refuse a shared-compute agent, reading the **raw wire value**. +/// +/// Trimmed before comparing: the desktop's own layers disagree about padding +/// (`relay_mesh.rs:17` and `effective_config/mod.rs:46` trim; the deploy guard +/// at `agents_deploy.rs:116` did not), and `non_blank` preserves surrounding +/// whitespace on a non-blank value. A backstop that shares its bypass with the +/// layer it backs is not a backstop. +fn refuse_relay_mesh(raw: &serde_json::Value) -> Option { + let provider = raw.get("agent")?.get("provider")?.as_str()?; + (provider.trim() == RELAY_MESH_PROVIDER).then(|| { + "deploy refused: this agent is configured for shared compute \ + (relay-mesh), which runs on the relay rather than in a pod. \ + Switch the agent to a local runtime before deploying it to \ + Kubernetes." + .to_string() + }) +} + +/// Run one deploy to a terminal outcome. +async fn deploy_agent(request: &wire::DeployRequest) -> Result { + let cfg = config::parse(&request.provider_config)?; + // Identity before any cluster contact: a malformed nsec is a refusal, not + // a failed connection (§Deploy State Machine step 0). + let identity = naming::AgentIdentity::from_nsec(&request.agent.private_key_nsec)?; + + // One generation for this operation's first attempt; the reconciler mints + // its own per attempt and restamps the correlator to match. + let env = env::build_env( + &request.agent, + env::AuthoritativeInputs { + generation: &naming::new_generation(), + inactivity_seconds: cfg.inactivity_seconds, + }, + )?; + + let client = client::connect(cfg.context.as_deref()).await?; + let substrate = cluster::Cluster::new(client, &cfg.namespace); + reconcile::deploy(&substrate, &identity, &cfg, env).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn error_of(response: &Response) -> String { + let json = serde_json::to_value(response).unwrap(); + assert_eq!(json["ok"], false, "expected a refusal: {json}"); + json["error"].as_str().unwrap().to_string() + } + + /// The spec's backstop for the relay-mesh MUST. The desktop refuses first + /// (`agents_deploy.rs:116`); this is the layer that owes the obligation. + #[test] + fn refuses_a_relay_mesh_agent() { + let request = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","provider":"relay-mesh"}, + "provider_config":{"namespace":"ns"}}"#; + assert!(error_of(&respond(request)).contains("relay-mesh")); + } + + /// Padding must not bypass the backstop. Reachable by construction: + /// `GlobalConfig.provider` is a bare `Option` with no trim on + /// write, and `non_blank` rejects whitespace-only while preserving + /// surrounding whitespace on everything else. + #[test] + fn refuses_a_padded_relay_mesh_agent() { + let request = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","provider":" relay-mesh "}, + "provider_config":{"namespace":"ns"}}"#; + assert!(error_of(&respond(request)).contains("relay-mesh")); + } + + /// The refusal must not fire on a normal agent — a guard that refuses + /// everything passes its own test and ships a provider that deploys + /// nothing. + #[test] + fn does_not_refuse_a_normal_provider() { + let raw: serde_json::Value = + serde_json::from_str(r#"{"agent":{"provider":"openai"}}"#).unwrap(); + assert!(refuse_relay_mesh(&raw).is_none()); + // …nor when the field is absent entirely, which is the common case: + // `AgentPayload` does not carry `provider`. + let bare: serde_json::Value = serde_json::from_str(r#"{"agent":{}}"#).unwrap(); + assert!(refuse_relay_mesh(&bare).is_none()); + } + + /// Malformed input still produces exactly one conforming response. + #[test] + fn malformed_input_is_an_in_band_error() { + assert!(error_of(&respond("not json")).contains("valid JSON")); + assert!(error_of(&respond(r#"{"op":"undeploy"}"#)).contains("understand")); + } + + /// `info` answers without touching a cluster — it is what the desktop + /// calls to render the config form, before any kubeconfig exists. + #[test] + fn info_answers_with_the_protocol_version_and_schema() { + let json = serde_json::to_value(respond(r#"{"op":"info"}"#)).unwrap(); + assert_eq!(json["ok"], true); + assert_eq!(json["protocol_version"], wire::PROTOCOL_VERSION); + assert!(json["config_schema"]["properties"]["namespace"].is_object()); + } +} diff --git a/crates/buzz-backend-kubernetes/src/naming.rs b/crates/buzz-backend-kubernetes/src/naming.rs new file mode 100644 index 00000000000..4b9d7ea0355 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/naming.rs @@ -0,0 +1,223 @@ +//! Identity derivation and the object-naming contract (spec §Pod shape). +//! +//! Every name, label, and annotation below is derived from the pubkey the +//! provider decoded itself from `private_key_nsec` — never from a +//! caller-supplied pubkey (§Deploy State Machine step 0). + +use nostr::nips::nip19::FromBech32; + +/// `app.kubernetes.io/managed-by` value: the management marker's identity half. +pub const MANAGED_BY: &str = "buzz-backend-kubernetes"; + +/// Label key carrying [`MANAGED_BY`]. +pub const LABEL_MANAGED_BY: &str = "app.kubernetes.io/managed-by"; + +/// Label key carrying [`BINDING_VERSION`] — the marker's schema half. +pub const LABEL_BINDING_VERSION: &str = "buzz.block.xyz/binding-version"; + +/// Schema version of the object layout this provider writes. Bumped when the +/// pod/Secret shape changes in a way a older provider would mis-handle. +pub const BINDING_VERSION: &str = "1"; + +/// Label key: truncated pubkey, the reconciliation and GC selector. +pub const LABEL_AGENT_PUBKEY: &str = "buzz.block.xyz/agent-pubkey"; + +/// Annotation key: full pubkey. Load-bearing — the truncated label is +/// collision-*resistant*, this is what makes it safe (§Deploy State Machine +/// step 1). +pub const ANNOTATION_PUBKEY_FULL: &str = "buzz.block.xyz/agent-pubkey-full"; + +/// Annotation key: the recorded create-intent fingerprint. +pub const ANNOTATION_CREATE_INTENT: &str = "buzz.block.xyz/create-intent"; + +/// Annotation key: the image reference this generation actually resolved to, +/// for post-hoc attribution (§Image). +pub const ANNOTATION_IMAGE: &str = "buzz.block.xyz/image"; + +/// An agent identity the provider derived itself, plus every name it implies. +/// +/// Constructing this type is the *only* way to obtain the names — so a +/// caller-supplied pubkey cannot reach a selector by any path. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AgentIdentity { + pubkey_hex: String, +} + +impl AgentIdentity { + /// Derive from the payload's `private_key_nsec`. + /// + /// Accepts bech32 `nsec1…`; a malformed or undecodable key is an + /// immediate error, before any substrate read or mutation + /// (§Deploy State Machine step 0). + pub fn from_nsec(nsec: &str) -> Result { + let secret = nostr::SecretKey::from_bech32(nsec.trim()) + .map_err(|_| "private_key_nsec is not a decodable nsec1 key".to_string())?; + let keys = nostr::Keys::new(secret); + Ok(Self { + pubkey_hex: keys.public_key().to_hex(), + }) + } + + /// Full 64-hex public key — the annotation value and the comparison + /// operand for candidate authentication. + pub fn pubkey_hex(&self) -> &str { + &self.pubkey_hex + } + + /// Selector label value: first 32 hex chars (128 bits). A full hex pubkey + /// is 64 chars and label values cap at 63, which is why this is truncated + /// and why the annotation check is normative rather than decorative. + pub fn label_pubkey(&self) -> &str { + &self.pubkey_hex[..32] + } + + /// Deterministic pod name, also the returned `agent_id`. + pub fn pod_name(&self) -> String { + format!("buzz-agent-{}", &self.pubkey_hex[..12]) + } + + /// Per-attempt Secret name. `generation` is a fresh random token per + /// create attempt — never reused — which is what makes payload and Secret + /// atomic at the pod-spec boundary (§K8s Secrets). + pub fn secret_name(&self, generation: &str) -> String { + format!("buzz-agent-{}-{}", &self.pubkey_hex[..12], generation) + } + + /// Label selector matching this identity's objects *and* our management + /// marker. Selecting on the marker as well as the identity means an + /// unmarked look-alike never even enters the candidate list. + pub fn selector(&self) -> String { + format!( + "{LABEL_AGENT_PUBKEY}={},{LABEL_MANAGED_BY}={MANAGED_BY}", + self.label_pubkey() + ) + } + + /// The label set stamped on every object this provider creates. + pub fn labels(&self) -> std::collections::BTreeMap { + [ + ( + LABEL_AGENT_PUBKEY.to_string(), + self.label_pubkey().to_string(), + ), + (LABEL_MANAGED_BY.to_string(), MANAGED_BY.to_string()), + ( + LABEL_BINDING_VERSION.to_string(), + BINDING_VERSION.to_string(), + ), + ] + .into_iter() + .collect() + } +} + +/// A fresh generation token: 8 lowercase hex chars from the OS RNG. +/// +/// Appears in the Secret name and as `BUZZ_MANAGED_AGENT_START_NONCE`, so the +/// Secret generation and the harness's lifecycle-frame correlator are one +/// identity (§Launch data tier 3). +pub fn new_generation() -> String { + use rand::RngExt; + let n: u32 = rand::rng().random(); + format!("{n:08x}") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fixed test key. Deriving the pubkey (rather than hardcoding both + /// halves) is the point: the test exercises the same derivation the + /// reconciler depends on. + fn identity() -> AgentIdentity { + let keys = nostr::Keys::generate(); + let nsec = { + use nostr::nips::nip19::ToBech32; + keys.secret_key().to_bech32().unwrap() + }; + let id = AgentIdentity::from_nsec(&nsec).unwrap(); + assert_eq!(id.pubkey_hex(), keys.public_key().to_hex()); + id + } + + #[test] + fn rejects_malformed_nsec() { + for bad in ["", "nsec1", "not-a-key", "npub1abc"] { + assert!( + AgentIdentity::from_nsec(bad).is_err(), + "accepted malformed key {bad:?}" + ); + } + } + + #[test] + fn tolerates_surrounding_whitespace() { + let keys = nostr::Keys::generate(); + use nostr::nips::nip19::ToBech32; + let nsec = keys.secret_key().to_bech32().unwrap(); + let padded = format!(" {nsec}\n"); + assert_eq!( + AgentIdentity::from_nsec(&padded).unwrap().pubkey_hex(), + keys.public_key().to_hex() + ); + } + + /// Kubernetes label *values* cap at 63 chars; a full hex pubkey is 64, + /// one over. That one-char overflow is the whole reason the selector is + /// truncated, so it gets an explicit test. + #[test] + fn label_value_fits_kubernetes_limit() { + let id = identity(); + assert_eq!(id.pubkey_hex().len(), 64); + assert_eq!(id.label_pubkey().len(), 32); + assert!(id.label_pubkey().len() <= 63); + } + + #[test] + fn pod_name_is_deterministic_and_dns_safe() { + let id = identity(); + assert_eq!(id.pod_name(), id.pod_name()); + assert_eq!( + id.pod_name(), + format!("buzz-agent-{}", &id.pubkey_hex()[..12]) + ); + assert!(id.pod_name().len() <= 253); + assert!(id + .pod_name() + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')); + } + + /// Two attempts must never share a Secret name — that uniqueness is what + /// stops a losing contender from overwriting the winner's identity. + #[test] + fn secret_names_are_per_attempt() { + let id = identity(); + let a = id.secret_name(&new_generation()); + let b = id.secret_name(&new_generation()); + assert_ne!(a, b); + assert!(a.starts_with(&id.pod_name())); + assert!(a.len() <= 253); + } + + #[test] + fn selector_requires_the_management_marker() { + let id = identity(); + let sel = id.selector(); + assert!(sel.contains(&format!("{LABEL_AGENT_PUBKEY}={}", id.label_pubkey()))); + assert!(sel.contains(&format!("{LABEL_MANAGED_BY}={MANAGED_BY}"))); + } + + #[test] + fn every_created_object_carries_the_marker() { + let labels = identity().labels(); + assert_eq!( + labels.get(LABEL_MANAGED_BY).map(String::as_str), + Some(MANAGED_BY) + ); + assert_eq!( + labels.get(LABEL_BINDING_VERSION).map(String::as_str), + Some(BINDING_VERSION) + ); + } +} diff --git a/crates/buzz-backend-kubernetes/src/observe.rs b/crates/buzz-backend-kubernetes/src/observe.rs new file mode 100644 index 00000000000..1c6c8835633 --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/observe.rs @@ -0,0 +1,592 @@ +//! Decoding API objects into verified observations (spec §Deploy State +//! Machine step 1). +//! +//! Pure: `Pod` in, [`VerifiedPod`] out. Keeping the decode here means the +//! conformance tests drive the *shipped* decoder with real API types rather +//! than a test-only stand-in, and it keeps `classify.rs` free of API types. +//! +//! Verification is the gate, not a filter: [`verify`] returns `None` for any +//! object whose full-pubkey annotation does not equal the derived pubkey or +//! that lacks the management marker, so an unverified object cannot reach +//! classification, deletion, or the returned `agent_id`. + +use crate::classify::{Fence, PullFailure, Startup, VerifiedPod}; +use crate::intent::Fingerprint; +use crate::naming::{ + AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, BINDING_VERSION, + LABEL_BINDING_VERSION, LABEL_MANAGED_BY, MANAGED_BY, +}; +use k8s_openapi::api::core::v1::{Pod, Secret}; + +/// Container name the provider creates; status is read from this container. +pub const CONTAINER_NAME: &str = "agent"; + +/// The startup state, or a state that cannot be settled without one more read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StartupObservation { + Resolved(Startup), + /// `CreateContainerConfigError` — recoverable *unless* the referenced + /// Secret is confirmed absent by a most-recent read. The kubelet's reason + /// string is a hint; the provider verifies before treating it as fatal + /// (§Deploy State Machine: "provably" means a verified absence, never a + /// reason string). + ConfigErrorPendingSecretCheck { + secret_name: String, + }, +} + +/// Does this object carry the management marker (§Pod shape)? +/// +/// Identity labels prove identity; the marker asserts protocol ownership. +/// Without it an object that merely matches our schema fails closed. +fn has_marker(labels: Option<&std::collections::BTreeMap>) -> bool { + let Some(labels) = labels else { return false }; + labels.get(LABEL_MANAGED_BY).map(String::as_str) == Some(MANAGED_BY) + && labels.get(LABEL_BINDING_VERSION).map(String::as_str) == Some(BINDING_VERSION) +} + +/// Does the full-pubkey annotation equal the derived pubkey? +/// +/// The 32-hex label is collision-*resistant*, not collision-free, which is +/// why this check is normative rather than decorative (`:1152-1166`). +fn annotation_matches( + annotations: Option<&std::collections::BTreeMap>, + identity: &AgentIdentity, +) -> bool { + annotations + .and_then(|a| a.get(ANNOTATION_PUBKEY_FULL)) + .map(|v| v == identity.pubkey_hex()) + .unwrap_or(false) +} + +/// Is this Secret ours and this identity's? The same fence GC applies before +/// deleting anything. +pub fn secret_is_ours(secret: &Secret, identity: &AgentIdentity) -> bool { + has_marker(secret.metadata.labels.as_ref()) + && annotation_matches(secret.metadata.annotations.as_ref(), identity) +} + +/// Decode a pod's startup state from its status. +/// +/// "Started" means `state.running` on our container — not pod phase. A pod can +/// sit in phase `Running` with a container that never started, and a pod being +/// gracefully deleted stays in phase `Running` for its whole grace period. +pub fn decode_startup(pod: &Pod) -> StartupObservation { + use StartupObservation::Resolved; + + let status = pod.status.as_ref(); + let phase = status.and_then(|s| s.phase.as_deref()); + + let container = status + .and_then(|s| s.container_statuses.as_ref()) + .and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME)); + + if let Some(state) = container.and_then(|c| c.state.as_ref()) { + if state.running.is_some() { + return Resolved(Startup::Started); + } + if state.terminated.is_some() { + return Resolved(Startup::Terminated); + } + if let Some(waiting) = state.waiting.as_ref() { + let reason = waiting.reason.as_deref().unwrap_or_default(); + let message = waiting.message.as_deref().unwrap_or_default(); + return match reason { + // Structurally invalid reference: no retry can fix it. + "InvalidImageName" => Resolved(Startup::NeverStartedProvablyBroken), + "ErrImagePull" | "ImagePullBackOff" => match classify_pull_failure(message) { + Some(failure) => Resolved(Startup::NeverStartedPullFailing(failure)), + None => Resolved(Startup::NeverStartedRecoverable), + }, + "CreateContainerConfigError" => match referenced_secret(pod) { + Some(secret_name) => { + StartupObservation::ConfigErrorPendingSecretCheck { secret_name } + } + None => Resolved(Startup::NeverStartedRecoverable), + }, + _ => Resolved(Startup::NeverStartedRecoverable), + }; + } + } + + // No container status yet (unscheduled, image pulling before the kubelet + // reports, quota-blocked). A terminal phase without container status still + // means the pod is done. + match phase { + Some("Succeeded") | Some("Failed") => Resolved(Startup::Terminated), + _ => Resolved(Startup::NeverStartedRecoverable), + } +} + +/// The Secret name this pod's `envFrom` references, if any. +pub fn referenced_secret(pod: &Pod) -> Option { + pod.spec + .as_ref()? + .containers + .iter() + .flat_map(|c| c.env_from.iter().flatten()) + .find_map(|source| source.secret_ref.as_ref().map(|r| r.name.clone())) +} + +/// Classify a pull failure from the kubelet's message. +/// +/// Reporting only — [`PullFailure`] is structurally excluded from +/// `Action::Delete`, so a wrong guess here can delay a report but can never +/// destroy anything. `None` means "no permanent cause recognized", which +/// leaves the pod on the ordinary observational path. +fn classify_pull_failure(message: &str) -> Option { + let m = message.to_ascii_lowercase(); + if m.contains("401") + || m.contains("unauthorized") + || m.contains("403") + || m.contains("denied") + || m.contains("authentication required") + { + return Some(PullFailure::Unauthorized); + } + if m.contains("manifest unknown") + || m.contains("not found") + || m.contains("manifest_unknown") + || m.contains("repository does not exist") + { + return Some(PullFailure::ManifestUnknown); + } + if m.contains("no match for platform") || m.contains("no matching manifest") { + return Some(PullFailure::ArchMismatch); + } + None +} + +/// The redacted, actionable condition text for a pull failure. +/// +/// Names the registry and the immutable reference — never credentials, and +/// never the kubelet's raw message, which can echo a registry token. +pub fn pull_failure_message(failure: PullFailure, image: &str) -> String { + let registry = image.split('/').next().unwrap_or(image); + match failure { + PullFailure::Unauthorized => format!( + "the cluster is not authorized to pull {image} from {registry}. \ + This pull retries indefinitely and will not succeed on its own: \ + grant the cluster's nodes access to that registry." + ), + PullFailure::ManifestUnknown => { + format!("{registry} has no image at {image}. Check the digest and repository.") + } + PullFailure::ArchMismatch => format!( + "{image} has no variant for the architecture of the nodes it was \ + scheduled on." + ), + } +} + +/// The latest actionable condition for a pod that has not started, redacted. +/// +/// Two sources, deliberately treated differently: +/// +/// * The container's waiting **reason** is included; its **message** is not. +/// Waiting messages are kubelet-composed and echo the thing that failed — +/// for a pull that is the registry request, which can carry credential +/// material. The reason token alone (`ImagePullBackOff`, +/// `CreateContainerConfigError`) is the diagnostic; the message adds +/// exposure, not information the user can act on. +/// * Pod-condition messages **are** included. They are scheduler- and +/// kubelet-composed from the pod's own spec and cluster capacity +/// ("0/3 nodes are available: Insufficient memory"), which is precisely the +/// actionable half and contains nothing derived from Secret data. +pub fn condition(pod: &Pod) -> Option { + let status = pod.status.as_ref()?; + + if let Some(state) = status + .container_statuses + .as_ref() + .and_then(|cs| cs.iter().find(|c| c.name == CONTAINER_NAME)) + .and_then(|c| c.state.as_ref()) + { + if let Some(waiting) = state.waiting.as_ref() { + if let Some(reason) = waiting.reason.as_deref() { + return Some(format!("the container is waiting, reason {reason}")); + } + } + // Exit code and reason only — the terminated `message` is + // process-composed output and falls under the same redaction rule as + // waiting messages. + if let Some(terminated) = state.terminated.as_ref() { + return Some(match terminated.reason.as_deref() { + Some(reason) => format!( + "the container exited with code {} ({reason})", + terminated.exit_code + ), + None => format!("the container exited with code {}", terminated.exit_code), + }); + } + } + + if let Some((type_, reason, message)) = status.conditions.as_ref().and_then(|cs| { + cs.iter().find(|c| c.status == "False").map(|c| { + ( + c.type_.clone(), + c.reason.clone().unwrap_or_default(), + c.message.clone().unwrap_or_default(), + ) + }) + }) { + let detail = [reason, message] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join(": "); + return Some(if detail.is_empty() { + format!("pod condition {type_} is false") + } else { + format!("pod condition {type_} is false: {detail}") + }); + } + + status.phase.as_deref().map(|p| format!("the pod is {p}")) +} + +/// Verify a label-selected pod and decode it, or reject it. +/// +/// `startup` is supplied by the caller because settling +/// `CreateContainerConfigError` needs a most-recent Secret read the pure layer +/// must not perform. +pub fn verify(pod: &Pod, identity: &AgentIdentity, startup: Startup) -> Option { + if !has_marker(pod.metadata.labels.as_ref()) { + return None; + } + if !annotation_matches(pod.metadata.annotations.as_ref(), identity) { + return None; + } + Some(VerifiedPod { + name: pod.metadata.name.clone()?, + fence: Fence { + uid: pod.metadata.uid.clone()?, + resource_version: pod.metadata.resource_version.clone()?, + }, + deletion_marked: pod.metadata.deletion_timestamp.is_some(), + startup, + recorded_intent: pod + .metadata + .annotations + .as_ref() + .and_then(|a| a.get(ANNOTATION_CREATE_INTENT)) + .map(|v| Fingerprint::from_annotation(v)), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use k8s_openapi::api::core::v1::{ + ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting, + ContainerStatus, PodStatus, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}; + use std::collections::BTreeMap; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn base_pod(id: &AgentIdentity) -> Pod { + Pod { + metadata: ObjectMeta { + name: Some(id.pod_name()), + uid: Some("uid-1".into()), + resource_version: Some("100".into()), + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into_iter() + .collect::>(), + ), + ..Default::default() + }, + ..Default::default() + } + } + + fn with_container_state(mut pod: Pod, state: ContainerState) -> Pod { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: CONTAINER_NAME.into(), + state: Some(state), + ..Default::default() + }]), + ..Default::default() + }); + pod + } + + fn waiting(reason: &str, message: &str) -> ContainerState { + ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some(reason.into()), + message: Some(message.into()), + }), + ..Default::default() + } + } + + #[test] + fn running_container_is_started() { + let id = identity(); + let pod = with_container_state( + base_pod(&id), + ContainerState { + running: Some(ContainerStateRunning::default()), + ..Default::default() + }, + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Started) + ); + } + + /// Pod phase is not the criterion. A pod in phase `Running` whose + /// container never started must NOT read as started, or the reconciler + /// no-ops on a pod that will never serve. + #[test] + fn phase_running_with_waiting_container_is_not_started() { + let id = identity(); + let pod = with_container_state(base_pod(&id), waiting("ContainerCreating", "")); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedRecoverable) + ); + } + + #[test] + fn terminated_container_is_terminated() { + let id = identity(); + let pod = with_container_state( + base_pod(&id), + ContainerState { + terminated: Some(ContainerStateTerminated { + exit_code: 0, + ..Default::default() + }), + ..Default::default() + }, + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Terminated) + ); + } + + /// A terminal phase with no container status (evicted before the kubelet + /// reported) is still terminated — otherwise the residue is never GC'd. + #[test] + fn terminal_phase_without_container_status_is_terminated() { + let id = identity(); + for phase in ["Succeeded", "Failed"] { + let mut pod = base_pod(&id); + pod.status = Some(PodStatus { + phase: Some(phase.into()), + ..Default::default() + }); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::Terminated), + "phase {phase}" + ); + } + } + + #[test] + fn invalid_image_name_is_provably_broken() { + let id = identity(); + let pod = with_container_state(base_pod(&id), waiting("InvalidImageName", "bad ref")); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedProvablyBroken) + ); + } + + /// Permanent pull failures are recognized from the message; anything + /// unrecognized stays on the ordinary observational path rather than + /// being guessed at. + #[test] + fn permanent_pull_failures_are_classified() { + let id = identity(); + let cases = [ + ("401 Unauthorized", PullFailure::Unauthorized), + ("pull access denied", PullFailure::Unauthorized), + ("manifest unknown", PullFailure::ManifestUnknown), + ( + "no match for platform in manifest", + PullFailure::ArchMismatch, + ), + ]; + for (message, expected) in cases { + let pod = with_container_state(base_pod(&id), waiting("ErrImagePull", message)); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedPullFailing(expected)), + "message {message:?}" + ); + } + + let pod = with_container_state( + base_pod(&id), + waiting("ImagePullBackOff", "dial tcp: i/o timeout"), + ); + assert_eq!( + decode_startup(&pod), + StartupObservation::Resolved(Startup::NeverStartedRecoverable), + "a transient network failure must not be reported as permanent" + ); + } + + /// The kubelet's reason string is a hint, not proof: a config error defers + /// to a most-recent Secret read before anything is called broken. + #[test] + fn config_error_defers_to_a_secret_read() { + let id = identity(); + let mut pod = + with_container_state(base_pod(&id), waiting("CreateContainerConfigError", "")); + pod.spec = Some(k8s_openapi::api::core::v1::PodSpec { + containers: vec![k8s_openapi::api::core::v1::Container { + name: CONTAINER_NAME.into(), + env_from: Some(vec![k8s_openapi::api::core::v1::EnvFromSource { + secret_ref: Some(k8s_openapi::api::core::v1::SecretEnvSource { + name: "buzz-agent-abc-gen1".into(), + optional: Some(false), + }), + ..Default::default() + }]), + ..Default::default() + }], + ..Default::default() + }); + assert_eq!( + decode_startup(&pod), + StartupObservation::ConfigErrorPendingSecretCheck { + secret_name: "buzz-agent-abc-gen1".into() + } + ); + } + + /// The auto-repair fence: an object that matches our schema but lacks the + /// marker, or carries someone else's pubkey, is never verified — so it can + /// never be no-op'd against, deleted, or returned as an `agent_id`. + #[test] + fn unmarked_or_mismatched_objects_fail_verification() { + let id = identity(); + let other = identity(); + + let mut unmarked = base_pod(&id); + unmarked.metadata.labels = Some(BTreeMap::new()); + assert!( + verify(&unmarked, &id, Startup::Started).is_none(), + "unmarked pod verified" + ); + + let mut wrong_version = base_pod(&id); + let mut labels = id.labels(); + labels.insert(LABEL_BINDING_VERSION.to_string(), "999".to_string()); + wrong_version.metadata.labels = Some(labels); + assert!(verify(&wrong_version, &id, Startup::Started).is_none()); + + let mut mismatched = base_pod(&id); + mismatched.metadata.annotations = Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ); + assert!( + verify(&mismatched, &id, Startup::Started).is_none(), + "collision verified" + ); + + let mut missing = base_pod(&id); + missing.metadata.annotations = Some(BTreeMap::new()); + assert!(verify(&missing, &id, Startup::Started).is_none()); + + assert!( + verify(&base_pod(&id), &id, Startup::Started).is_some(), + "own pod rejected" + ); + } + + /// The fence must come from the observed object, and the deletion mark + /// must be read even though the phase says `Running`. + #[test] + fn verified_pod_carries_the_fence_and_deletion_mark() { + let id = identity(); + let mut pod = base_pod(&id); + pod.metadata.deletion_timestamp = Some(Time(chrono::Utc::now())); + let verified = verify(&pod, &id, Startup::Started).unwrap(); + assert_eq!(verified.fence.uid, "uid-1"); + assert_eq!(verified.fence.resource_version, "100"); + assert!(verified.deletion_marked); + } + + /// A pod with no recorded intent reads as `None`, which the classifier + /// groups with divergence. + #[test] + fn missing_intent_annotation_decodes_as_none() { + let id = identity(); + assert!(verify(&base_pod(&id), &id, Startup::Started) + .unwrap() + .recorded_intent + .is_none()); + } + + /// A pull-failure report must name the registry and the immutable + /// reference and nothing else — never the kubelet's raw message, which + /// can echo a registry token. + #[test] + fn pull_failure_messages_are_actionable_and_redacted() { + let image = format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)); + for failure in [ + PullFailure::Unauthorized, + PullFailure::ManifestUnknown, + PullFailure::ArchMismatch, + ] { + let msg = pull_failure_message(failure, &image); + assert!(msg.contains("ghcr.io"), "{msg}"); + assert!(msg.contains(&image), "{msg}"); + for secret in ["Bearer", "password", "nsec1", "token"] { + assert!(!msg.contains(secret), "leaked {secret}: {msg}"); + } + } + } + + #[test] + fn secret_ownership_requires_marker_and_annotation() { + let id = identity(); + let other = identity(); + let ours = Secret { + metadata: ObjectMeta { + labels: Some(id.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + id.pubkey_hex().to_string(), + )] + .into(), + ), + ..Default::default() + }, + ..Default::default() + }; + assert!(secret_is_ours(&ours, &id)); + assert!(!secret_is_ours(&ours, &other)); + + let mut unmarked = ours.clone(); + unmarked.metadata.labels = Some(BTreeMap::new()); + assert!(!secret_is_ours(&unmarked, &id)); + } +} diff --git a/crates/buzz-backend-kubernetes/src/pod.rs b/crates/buzz-backend-kubernetes/src/pod.rs new file mode 100644 index 00000000000..725f98fc7dd --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/pod.rs @@ -0,0 +1,446 @@ +//! Pod and Secret construction (spec §Pod shape, §K8s Secrets). +//! +//! The builder is pure: it turns resolved inputs into API objects and performs +//! no I/O, so every normative field is a unit assertion. + +use crate::config::{ + ProviderConfig, RESTART_POLICY, RUN_AS_GID, RUN_AS_UID, TERMINATION_GRACE_SECONDS, + WORKSPACE_PATH, +}; +use crate::intent::{Fingerprint, IntentTemplate}; +use crate::naming::{ + AgentIdentity, ANNOTATION_CREATE_INTENT, ANNOTATION_IMAGE, ANNOTATION_PUBKEY_FULL, +}; +use k8s_openapi::api::core::v1::{ + Capabilities, Container, EmptyDirVolumeSource, EnvFromSource, Pod, PodSecurityContext, PodSpec, + ResourceRequirements, SeccompProfile, Secret, SecretEnvSource, SecurityContext, Volume, + VolumeMount, +}; +use k8s_openapi::apimachinery::pkg::api::resource::Quantity; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use std::collections::BTreeMap; + +/// Volume name for the agent's writable workspace. +const WORKSPACE_VOLUME: &str = "workspace"; + +/// The container name. Fixed: log and exec tooling addresses it by name. +const CONTAINER_NAME: &str = "agent"; + +/// Build the per-attempt Secret holding the resolved environment. +/// +/// `immutable: true` — the Secret is written once per attempt and never +/// updated, which is what lets the pod's `envFrom` reference be treated as an +/// atomic binding to this exact payload (§K8s Secrets). +pub fn build_secret( + identity: &AgentIdentity, + namespace: &str, + generation: &str, + env: BTreeMap, +) -> Secret { + Secret { + metadata: ObjectMeta { + name: Some(identity.secret_name(generation)), + namespace: Some(namespace.to_string()), + labels: Some(identity.labels()), + annotations: Some( + [( + ANNOTATION_PUBKEY_FULL.to_string(), + identity.pubkey_hex().to_string(), + )] + .into_iter() + .collect(), + ), + ..Default::default() + }, + string_data: Some(env), + immutable: Some(true), + ..Default::default() + } +} + +/// Build the pod for one create attempt. +/// +/// The `fingerprint` is computed from [`intent_template`] over a type that +/// cannot contain the generation or any Secret value, so it is stable across +/// attempts of the same configuration. +pub fn build_pod( + identity: &AgentIdentity, + cfg: &ProviderConfig, + generation: &str, + fingerprint: &Fingerprint, +) -> Pod { + let annotations: BTreeMap = [ + ( + ANNOTATION_PUBKEY_FULL.to_string(), + identity.pubkey_hex().to_string(), + ), + ( + ANNOTATION_CREATE_INTENT.to_string(), + fingerprint.as_str().to_string(), + ), + (ANNOTATION_IMAGE.to_string(), cfg.image.as_str().to_string()), + ] + .into_iter() + .collect(); + + let requests: BTreeMap = [ + ( + "cpu".to_string(), + Quantity(cfg.resources.cpu_request.clone()), + ), + ( + "memory".to_string(), + Quantity(cfg.resources.memory_request.clone()), + ), + ] + .into_iter() + .collect(); + let limits: BTreeMap = [ + ("cpu".to_string(), Quantity(cfg.resources.cpu_limit.clone())), + ( + "memory".to_string(), + Quantity(cfg.resources.memory_limit.clone()), + ), + ] + .into_iter() + .collect(); + + let container = Container { + name: CONTAINER_NAME.to_string(), + image: Some(cfg.image.as_str().to_string()), + // No `command`/`args`: the image's entrypoint execs the harness as + // PID 1 (§Entrypoint). Overriding it here would be how a provider + // accidentally puts a shell in front of the signal receiver. + env_from: Some(vec![EnvFromSource { + secret_ref: Some(SecretEnvSource { + name: identity.secret_name(generation), + optional: Some(false), + }), + ..Default::default() + }]), + resources: Some(ResourceRequirements { + requests: Some(requests), + limits: Some(limits), + ..Default::default() + }), + volume_mounts: Some(vec![VolumeMount { + name: WORKSPACE_VOLUME.to_string(), + mount_path: WORKSPACE_PATH.to_string(), + ..Default::default() + }]), + security_context: Some(SecurityContext { + allow_privilege_escalation: Some(false), + capabilities: Some(Capabilities { + drop: Some(vec!["ALL".to_string()]), + ..Default::default() + }), + // `readOnlyRootFilesystem` is deliberately unset: the sprig + // toolchain writes outside the workspace mount (§Pod shape). + ..Default::default() + }), + ..Default::default() + }; + + Pod { + metadata: ObjectMeta { + name: Some(identity.pod_name()), + namespace: Some(cfg.namespace.clone()), + labels: Some(identity.labels()), + annotations: Some(annotations), + ..Default::default() + }, + spec: Some(PodSpec { + containers: vec![container], + restart_policy: Some(RESTART_POLICY.to_string()), + termination_grace_period_seconds: Some(TERMINATION_GRACE_SECONDS), + // The agent runs prompted, untrusted code while holding an nsec; + // an ambient ServiceAccount token would be an API-stealable + // credential it never needs (§Pod shape hardening). Naming a + // service account selects a scheduling/RBAC identity and MUST NOT + // re-enable token mounting. + automount_service_account_token: Some(false), + service_account_name: cfg.service_account.clone(), + security_context: Some(PodSecurityContext { + run_as_non_root: Some(true), + run_as_user: Some(RUN_AS_UID), + run_as_group: Some(RUN_AS_GID), + fs_group: Some(RUN_AS_GID), + seccomp_profile: Some(SeccompProfile { + type_: "RuntimeDefault".to_string(), + ..Default::default() + }), + ..Default::default() + }), + volumes: Some(vec![Volume { + name: WORKSPACE_VOLUME.to_string(), + empty_dir: Some(EmptyDirVolumeSource::default()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + } +} + +/// The create-intent template for this configuration (§Deploy State Machine). +pub fn intent_template( + cfg: &ProviderConfig, + env_keys: impl IntoIterator, +) -> IntentTemplate { + IntentTemplate::new( + &cfg.namespace, + &cfg.image, + &cfg.resources, + cfg.service_account.as_deref(), + env_keys, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config; + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn provider_config() -> ProviderConfig { + config::parse(&serde_json::json!({ + "namespace": "buzz-agents-test", + "image": format!("ghcr.io/block/buzz-sprig@sha256:{}", "a".repeat(64)), + })) + .unwrap() + } + + fn pod() -> Pod { + let cfg = provider_config(); + build_pod( + &identity(), + &cfg, + "gen00001", + &intent_template(&cfg, ["BUZZ_RELAY_URL".to_string()]).fingerprint(), + ) + } + + fn spec(pod: &Pod) -> &PodSpec { + pod.spec.as_ref().unwrap() + } + + /// Every hardening default from §Pod shape, asserted individually so a + /// dropped one names itself. + #[test] + fn hardening_defaults_are_all_present() { + let pod = pod(); + let spec = spec(&pod); + assert_eq!(spec.automount_service_account_token, Some(false)); + + let sc = spec + .security_context + .as_ref() + .expect("pod security context"); + assert_eq!(sc.run_as_non_root, Some(true)); + assert_eq!(sc.run_as_user, Some(RUN_AS_UID)); + assert_ne!(sc.run_as_user, Some(0), "root UID"); + assert_eq!(sc.run_as_group, Some(RUN_AS_GID)); + assert_eq!( + sc.seccomp_profile.as_ref().map(|p| p.type_.as_str()), + Some("RuntimeDefault") + ); + + let csc = spec.containers[0] + .security_context + .as_ref() + .expect("container sc"); + assert_eq!(csc.allow_privilege_escalation, Some(false)); + assert_eq!( + csc.capabilities.as_ref().and_then(|c| c.drop.clone()), + Some(vec!["ALL".to_string()]) + ); + assert_ne!(csc.privileged, Some(true)); + } + + /// The forbidden host-namespace and hostPath escapes, asserted as absence. + #[test] + fn never_uses_host_namespaces_or_host_paths() { + let pod = pod(); + let spec = spec(&pod); + assert!(spec.host_pid.is_none() || spec.host_pid == Some(false)); + assert!(spec.host_network.is_none() || spec.host_network == Some(false)); + assert!(spec.host_ipc.is_none() || spec.host_ipc == Some(false)); + for volume in spec.volumes.as_ref().unwrap() { + assert!( + volume.host_path.is_none(), + "hostPath volume {}", + volume.name + ); + } + } + + /// `Never` only. `OnFailure` is gated on the harness exit-code contract + /// *and* a crash-loop classification row (`:1121-1139`); the config layer + /// refuses `inactivity_seconds: 0` so this arm is unreachable, and the + /// assertion keeps it that way. + #[test] + fn restart_policy_is_never() { + assert_eq!(spec(&pod()).restart_policy.as_deref(), Some("Never")); + } + + /// 60s, not Kubernetes' default 30s — which would SIGKILL the harness + /// mid-drain and leave presence stale-online (§Pod shape). + #[test] + fn declares_the_sixty_second_grace_budget() { + assert_eq!(spec(&pod()).termination_grace_period_seconds, Some(60)); + } + + /// The pod must not override the image's entrypoint: the image execs the + /// harness as PID 1, and a `command` here is how a shell ends up in front + /// of the signal receiver (§Entrypoint). + #[test] + fn does_not_override_the_image_entrypoint() { + let pod = pod(); + let container = &spec(&pod).containers[0]; + assert!(container.command.is_none(), "overrode the entrypoint"); + assert!(container.args.is_none()); + } + + #[test] + fn workspace_is_an_emptydir_mounted_at_home() { + let pod = pod(); + let spec = spec(&pod); + let volume = &spec.volumes.as_ref().unwrap()[0]; + assert!(volume.empty_dir.is_some()); + assert!(volume.persistent_volume_claim.is_none()); + let mount = &spec.containers[0].volume_mounts.as_ref().unwrap()[0]; + assert_eq!(mount.name, volume.name); + assert_eq!(mount.mount_path, WORKSPACE_PATH); + } + + #[test] + fn resources_carry_the_configured_requests_and_limits() { + let mut cfg = provider_config(); + cfg.resources.cpu_limit = "4".into(); + let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f")); + let r = spec(&pod).containers[0].resources.as_ref().unwrap(); + assert_eq!(r.requests.as_ref().unwrap()["cpu"], Quantity("1".into())); + assert_eq!( + r.requests.as_ref().unwrap()["memory"], + Quantity("2Gi".into()) + ); + assert_eq!(r.limits.as_ref().unwrap()["cpu"], Quantity("4".into())); + assert_eq!(r.limits.as_ref().unwrap()["memory"], Quantity("4Gi".into())); + } + + /// `envFrom` must point at this attempt's Secret and must NOT be optional: + /// an optional reference starts the container with no identity at all, + /// turning a missing-Secret bug into an agent that silently cannot + /// authenticate. + #[test] + fn env_from_references_this_attempts_secret_and_is_required() { + let id = identity(); + let cfg = provider_config(); + let pod = build_pod(&id, &cfg, "gen00042", &Fingerprint::from_annotation("f")); + let source = &spec(&pod).containers[0].env_from.as_ref().unwrap()[0]; + let secret_ref = source.secret_ref.as_ref().unwrap(); + assert_eq!(secret_ref.name, id.secret_name("gen00042")); + assert_eq!(secret_ref.optional, Some(false)); + assert!(source.config_map_ref.is_none()); + } + + /// Identity, ownership marker, and the recorded intent all travel on the + /// pod — the GC and reconciliation fences read exactly these. + #[test] + fn pod_carries_identity_marker_and_recorded_intent() { + let id = identity(); + let cfg = provider_config(); + let fp = intent_template(&cfg, ["A".to_string()]).fingerprint(); + let pod = build_pod(&id, &cfg, "g", &fp); + let meta = &pod.metadata; + assert_eq!(meta.name.as_deref(), Some(id.pod_name().as_str())); + assert_eq!(meta.namespace.as_deref(), Some("buzz-agents-test")); + assert_eq!(meta.labels.as_ref().unwrap(), &id.labels()); + let ann = meta.annotations.as_ref().unwrap(); + assert_eq!(ann[ANNOTATION_PUBKEY_FULL], id.pubkey_hex()); + assert_eq!(ann[ANNOTATION_CREATE_INTENT], fp.as_str()); + assert_eq!(ann[ANNOTATION_IMAGE], cfg.image.as_str()); + } + + /// The Secret is immutable and marker-bearing: immutability is what makes + /// the pod's `envFrom` an atomic binding, and the marker is what GC + /// requires before it will delete anything. + #[test] + fn secret_is_immutable_marked_and_holds_the_env() { + let id = identity(); + let env: BTreeMap = + [("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())].into(); + let secret = build_secret(&id, "ns", "gen1", env.clone()); + assert_eq!(secret.immutable, Some(true)); + assert_eq!(secret.string_data.as_ref().unwrap(), &env); + assert_eq!( + secret.metadata.name.as_deref(), + Some(id.secret_name("gen1").as_str()) + ); + assert_eq!(secret.metadata.labels.as_ref().unwrap(), &id.labels()); + assert_eq!( + secret.metadata.annotations.as_ref().unwrap()[ANNOTATION_PUBKEY_FULL], + id.pubkey_hex() + ); + // `data` must stay unset — setting both is an apiserver rejection. + assert!(secret.data.is_none()); + } + + /// Naming a service account selects a scheduling identity; it must not + /// re-enable token mounting (§Pod shape hardening, `:1221-1225`). + #[test] + fn service_account_does_not_re_enable_token_mounting() { + let mut cfg = provider_config(); + cfg.service_account = Some("agent-sa".into()); + let pod = build_pod(&identity(), &cfg, "g", &Fingerprint::from_annotation("f")); + assert_eq!(spec(&pod).service_account_name.as_deref(), Some("agent-sa")); + assert_eq!(spec(&pod).automount_service_account_token, Some(false)); + } + + /// The fingerprint recorded on the pod is the one the classifier will + /// recompute — pinned end-to-end so a builder change that forgets to feed + /// the template a field cannot pass silently. + #[test] + fn recorded_fingerprint_matches_a_fresh_computation() { + let cfg = provider_config(); + let keys = ["BUZZ_RELAY_URL".to_string(), "GOOSE_MODE".to_string()]; + let fp = intent_template(&cfg, keys.clone()).fingerprint(); + let pod = build_pod(&identity(), &cfg, "gen-a", &fp); + let recorded = Fingerprint::from_annotation( + &pod.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT], + ); + assert_eq!(recorded, intent_template(&cfg, keys).fingerprint()); + } + + /// Two attempts differing only in generation must record the *same* + /// fingerprint, or the divergence discriminator fires on every deploy and + /// the never-started row deletes healthy pending pods. + #[test] + fn generation_does_not_change_the_recorded_fingerprint() { + let cfg = provider_config(); + let keys = ["BUZZ_RELAY_URL".to_string()]; + let a = intent_template(&cfg, keys.clone()).fingerprint(); + let b = intent_template(&cfg, keys).fingerprint(); + let id = identity(); + let pod_a = build_pod(&id, &cfg, "gen-1", &a); + let pod_b = build_pod(&id, &cfg, "gen-2", &b); + let read = + |p: &Pod| p.metadata.annotations.as_ref().unwrap()[ANNOTATION_CREATE_INTENT].clone(); + assert_eq!(read(&pod_a), read(&pod_b)); + // ...while the Secret they reference differs. + let secret_of = |p: &Pod| { + spec(p).containers[0].env_from.as_ref().unwrap()[0] + .secret_ref + .as_ref() + .unwrap() + .name + .clone() + }; + assert_ne!(secret_of(&pod_a), secret_of(&pod_b)); + } +} diff --git a/crates/buzz-backend-kubernetes/src/reconcile.rs b/crates/buzz-backend-kubernetes/src/reconcile.rs new file mode 100644 index 00000000000..df2f99789ff --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/reconcile.rs @@ -0,0 +1,1585 @@ +//! The deploy loop: executes [`crate::classify`]'s actions against a substrate +//! and re-enters (spec §Deploy State Machine). +//! +//! The substrate is a trait so the conformance tests drive this exact +//! reconciler — the shipped code path, not a test-only reimplementation — with +//! a fake cluster and a fake clock. +//! +//! Two shapes are worth naming up front, because they are what keep the loop +//! terminating: +//! +//! * **Success means the harness container started** (`:696-699`). There is no +//! "deployed but not confirmed" success: `deploy` returns an `agent_id` or an +//! in-band error carrying the latest condition. The wire has no third form. +//! * **A create-conflict loser never repairs.** It verifies the winner, drops +//! its own Secret, and *observes* until the winner starts. Applying the +//! divergence row to the pod that just beat it is exactly the ping-pong the +//! spec forbids (`:845-850`), and the escape it names is the *next* deploy, +//! not this one. + +use crate::classify::{self, Action, Fence, Startup, VerifiedPod}; +use crate::config::ProviderConfig; +use crate::gc; +use crate::naming::AgentIdentity; +use crate::observe::{self, StartupObservation}; +use chrono::{DateTime, Utc}; +use k8s_openapi::api::core::v1::{Pod, Secret}; +use std::collections::BTreeMap; +use std::time::Duration; + +/// Outcome of a create against the deterministic pod name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateOutcome { + Created, + /// 409 with `Status.reason: AlreadyExists` — a concurrent attempt won. + /// Discriminated on the typed reason, never on the HTTP code alone: 409 is + /// also `Conflict`, which means a failed precondition (`:780-794`). + AlreadyExists, +} + +/// Outcome of a fenced delete. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteOutcome { + Accepted, + /// Already gone. Delete-not-found is success (`:842`). + NotFound, + /// 409 with `Status.reason: Conflict` — the object changed since the + /// observation that authorized this delete. Neither an error nor + /// permission to retry: re-enter and classify what exists now + /// (`:775-777`). + PreconditionFailed, +} + +/// The cluster operations the reconciler needs. Everything here is I/O; +/// everything that decides is pure and lives in `classify`/`gc`. +#[allow(async_fn_in_trait)] +pub trait Substrate { + /// Create the namespace if absent. On RBAC denial the error MUST name the + /// literal `kubectl create namespace ` command and MUST NOT fall + /// back to `default` (`:1002-1005`). + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String>; + + /// Most-recent read of the pods matching this identity's selector, plus + /// the apiserver's clock from the same call's HTTP `Date` header. `None` + /// clock means the header was absent or unparseable, which makes the + /// orphan-Secret sweep skip (`:1321-1335`). + async fn list_pods(&self, selector: &str) -> Result<(Vec, Option>), String>; + + async fn list_secrets(&self, selector: &str) -> Result, String>; + + /// Most-recent existence check (`resourceVersion` explicitly unset, not + /// `"0"`): the classifier treats a confirmed absence as proof, so a + /// possibly-stale cache read would be proof of nothing (`:761-769`). + async fn secret_exists(&self, name: &str) -> Result; + + async fn create_secret(&self, secret: &Secret) -> Result<(), String>; + + async fn create_pod(&self, pod: &Pod) -> Result; + + /// Compare-and-delete against the fence from the authorizing observation. + /// Uses the object's own grace period — never `grace_period_seconds: 0`, + /// which is a force-kill that discards the declared 60s shutdown budget + /// (`:1185-1189`). + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result; + + /// Best-effort: the Secret may already be gone, which is success. + async fn delete_secret(&self, name: &str) -> Result<(), String>; + + /// Read one pod by name, most-recent. `None` is a confirmed absence. + async fn get_pod(&self, name: &str) -> Result, String>; + + async fn sleep(&self, duration: Duration); + + /// Monotonic elapsed time since the operation began. Fake-clock driven in + /// tests; the deadline must not depend on wall-clock adjustments. + fn elapsed(&self) -> Duration; +} + +/// Interval between reconciler polls. Short enough that a fast start is +/// reported promptly, long enough not to hammer the apiserver for 600s. +const POLL_INTERVAL: Duration = Duration::from_secs(2); + +/// The deploy operation deadline (spec §Deploy: `timeout: 600s`). +const DEADLINE: Duration = Duration::from_secs(gc::OPERATION_DEADLINE_SECS as u64); + +/// Settle a pod's startup state, performing the one most-recent Secret read +/// that `CreateContainerConfigError` requires. +async fn settle(substrate: &impl Substrate, pod: &Pod) -> Result { + Ok(match observe::decode_startup(pod) { + StartupObservation::Resolved(startup) => startup, + StartupObservation::ConfigErrorPendingSecretCheck { secret_name } => { + // A confirmed absence is proof; anything else stays recoverable, + // because the kubelet's reason string alone is not evidence. + if substrate.secret_exists(&secret_name).await? { + Startup::NeverStartedRecoverable + } else { + Startup::NeverStartedProvablyBroken + } + } + }) +} + +/// Observe the single pod owned by this identity, verified. +/// +/// `Ok(None)` conflates "absent" with "present but not ours" on purpose *here* +/// — both mean the classifier has nothing it may act on. The create path +/// separates them, because only there is the difference actionable. +async fn observe_pod( + substrate: &impl Substrate, + identity: &AgentIdentity, +) -> Result, String> { + let Some(pod) = substrate.get_pod(&identity.pod_name()).await? else { + return Ok(None); + }; + let startup = settle(substrate, &pod).await?; + Ok(observe::verify(&pod, identity, startup)) +} + +/// The latest condition to report, read fresh at the moment of reporting. +/// +/// Reading it here rather than threading it through every loop iteration is +/// what "the *latest* redacted condition" (`:689`) asks for, and it costs a +/// read only on the paths that are already failing. +async fn latest_condition(substrate: &impl Substrate, identity: &AgentIdentity) -> String { + match substrate.get_pod(&identity.pod_name()).await { + Ok(Some(pod)) => observe::condition(&pod) + .unwrap_or_else(|| "no condition reported by the cluster".to_string()), + Ok(None) => "the pod no longer exists".to_string(), + Err(e) => format!("the pod's condition could not be read: {e}"), + } +} + +/// Preflight GC (§K8s GC). Failures are logged and swallowed: GC is hygiene, +/// and a deploy must not fail because a stale object could not be listed or +/// removed. A denial that actually blocks this deploy resurfaces at create, +/// where the message names the operation the user was denied. +async fn preflight_gc(substrate: &impl Substrate, identity: &AgentIdentity) { + if let Err(e) = try_preflight_gc(substrate, identity).await { + eprintln!("gc: preflight pass skipped: {e}"); + } +} + +async fn try_preflight_gc( + substrate: &impl Substrate, + identity: &AgentIdentity, +) -> Result<(), String> { + let selector = identity.selector(); + let (pods, server_now) = substrate.list_pods(&selector).await?; + let secrets = substrate.list_secrets(&selector).await?; + + let mut terminated: Vec = Vec::new(); + for pod in &pods { + if matches!(settle(substrate, pod).await?, Startup::Terminated) { + if let Some(name) = pod.metadata.name.clone() { + terminated.push(name); + } + } + } + + let plan = gc::plan( + identity, + &pods, + &secrets, + |pod| { + pod.metadata + .name + .as_deref() + .map(|n| terminated.iter().any(|t| t == n)) + .unwrap_or(false) + }, + server_now, + ); + + for name in &plan.pods { + // Re-read to fence the delete against the object we just observed; a + // pod that changed since the list is simply skipped this pass. + let Some(pod) = substrate.get_pod(name).await? else { + continue; + }; + let (Some(uid), Some(rv)) = ( + pod.metadata.uid.clone(), + pod.metadata.resource_version.clone(), + ) else { + continue; + }; + if !matches!(settle(substrate, &pod).await?, Startup::Terminated) { + continue; + } + let fence = Fence { + uid, + resource_version: rv, + }; + if let Err(e) = substrate.delete_pod(name, &fence).await { + eprintln!("gc: could not delete terminated pod {name}: {e}"); + } + } + for name in &plan.secrets { + if let Err(e) = substrate.delete_secret(name).await { + eprintln!("gc: could not delete secret {name}: {e}"); + } + } + Ok(()) +} + +/// Wait for a pod to actually disappear. +/// +/// Mandatory before recreating: `DELETE` returns success while the object +/// still exists, and the deterministic name stays taken for the whole grace +/// period (`:1177-1195`). +async fn await_disappearance(substrate: &impl Substrate, name: &str) -> Result<(), String> { + while substrate.elapsed() < DEADLINE { + if substrate.get_pod(name).await?.is_none() { + return Ok(()); + } + substrate.sleep(POLL_INTERVAL).await; + } + Err(format!( + "timed out after {}s waiting for {name} to finish terminating", + DEADLINE.as_secs() + )) +} + +/// Is `secret` referenced by any pod that currently exists under this +/// identity's selector? +/// +/// Protection deliberately spans *all* our pods, not just the winner: an +/// `envFrom` reference from a pod still pulling its image is exactly as +/// load-bearing as one from a running pod (`:1261-1264`). +async fn secret_is_referenced( + substrate: &impl Substrate, + identity: &AgentIdentity, + secret: &str, +) -> Result { + let (pods, _) = substrate.list_pods(&identity.selector()).await?; + Ok(pods + .iter() + .filter_map(observe::referenced_secret) + .any(|name| name == secret)) +} + +/// Drop this attempt's own Secret once nothing references it. +/// +/// Only ever called with a name this process generated, and gated on the +/// reference check: "never the winner's, never any Secret referenced by an +/// existing pod" (`:1259-1264`). Failure is logged, not fatal — a leaked +/// Secret is collected by the age-gated sweep. +async fn drop_own_secret(substrate: &impl Substrate, identity: &AgentIdentity, secret: &str) { + match secret_is_referenced(substrate, identity, secret).await { + Ok(false) => { + if let Err(e) = substrate.delete_secret(secret).await { + eprintln!("could not clean up own unreferenced secret {secret}: {e}"); + } + } + Ok(true) => {} + Err(e) => eprintln!("could not check whether {secret} is still referenced: {e}"), + } +} + +/// Lost the create race: adopt the elected winner. +/// +/// Observe-only by construction — this function has no delete edge for the +/// pod. A winner that is terminated or provably broken is reported, not +/// repaired; the spec's escape is "a *subsequent* deploy that walks in and +/// observes that never-started divergent winner replaces it normally" +/// (`:849-850`). +async fn adopt_winner( + substrate: &impl Substrate, + identity: &AgentIdentity, + own_secret: &str, +) -> Result { + let name = identity.pod_name(); + + // Verify before adopting. A pod under our deterministic name that fails + // the marker/annotation check is not ours to adopt, wait for, or touch — + // and it will never become ours, so this is terminal rather than a retry. + match substrate.get_pod(&name).await? { + None => {} + Some(pod) => { + let startup = settle(substrate, &pod).await?; + if observe::verify(&pod, identity, startup).is_none() { + drop_own_secret(substrate, identity, own_secret).await; + return Err(format!( + "a pod named {name} already exists in this namespace but is not \ + managed by this provider for this agent (it lacks the management \ + marker or carries a different agent identity). Remove it, or \ + deploy this agent to a different namespace." + )); + } + } + } + + drop_own_secret(substrate, identity, own_secret).await; + + // Then wait for the winner exactly as we would wait for our own pod: + // success still means the harness container started. + loop { + if substrate.elapsed() >= DEADLINE { + return Err(format!( + "startup not confirmed within {}s for {name} (another deploy of this \ + agent created it): {}", + DEADLINE.as_secs(), + latest_condition(substrate, identity).await + )); + } + match observe_pod(substrate, identity).await? { + Some(pod) if matches!(pod.startup, Startup::Started) => return Ok(pod.name), + Some(pod) if pod.deletion_marked => { + return Err(format!( + "{name} was created by another deploy of this agent and is already \ + being deleted; try again" + )) + } + Some(pod) + if matches!( + pod.startup, + Startup::Terminated | Startup::NeverStartedProvablyBroken + ) => + { + return Err(format!( + "{name} was created by another deploy of this agent and did not \ + start: {}", + latest_condition(substrate, identity).await + )) + } + // Gone again, or still coming up: keep observing under this + // operation's deadline. + _ => substrate.sleep(POLL_INTERVAL).await, + } + } +} + +/// Run the deploy state machine to a terminal outcome: the started pod's name, +/// or an in-band error carrying the latest condition. +pub async fn deploy( + substrate: &impl Substrate, + identity: &AgentIdentity, + cfg: &ProviderConfig, + env: BTreeMap, +) -> Result { + substrate.ensure_namespace(&cfg.namespace).await?; + preflight_gc(substrate, identity).await; + + let desired = crate::pod::intent_template(cfg, env.keys().cloned()).fingerprint(); + + // Has THIS call created a pod? Set once its create lands. The replacement + // rows below are for residue from a previous life; once this call has made + // its own attempt, a replace-classification means that attempt failed — + // and startup verification is part of create, so the failure is reported + // in-band rather than retried. Without this bound a deterministic startup + // failure (the harness starts, rejects its configuration, exits) is + // delete-recreated every poll for the whole deadline, minting an immutable + // Secret per cycle — measured live at 107 Secrets in one 600s call, every + // one younger than the orphan sweep's age gate. + let mut created_this_call = false; + + loop { + if substrate.elapsed() >= DEADLINE { + return Err(format!( + "startup not confirmed within {}s for {}: {}", + DEADLINE.as_secs(), + identity.pod_name(), + latest_condition(substrate, identity).await + )); + } + + let observed = observe_pod(substrate, identity).await?; + match classify::classify(observed.as_ref(), &desired) { + // The only success edge: the harness container is running. + Action::NoOp { agent_id } => return Ok(agent_id), + + // Self-healing states. Never delete, on this call or any later one + // — what replaces a never-started pod is a config change, never a + // deadline (`:717-729`). + Action::Observe { .. } => substrate.sleep(POLL_INTERVAL).await, + + // A pull that will not self-heal: report now rather than spend the + // remaining deadline on it. Still no delete authority. + Action::Report { name, failure } => { + return Err(format!( + "{name} did not start: {}", + observe::pull_failure_message(failure, cfg.image.as_str()) + )) + } + + Action::AwaitDisappearance { name } => await_disappearance(substrate, &name).await?, + + Action::Delete { name, fence } => { + // This call already made its own attempt, and that attempt is + // what the classification wants replaced: it terminated (the + // deterministic startup failure — the harness starts, rejects + // its configuration, exits) or was proven broken. Replacing it + // here retries the identical configuration against the same + // cluster: a hot delete/mint/create cycle every poll for the + // whole deadline, an immutable Secret per cycle — measured + // live at 107 Secrets in one 600s call, all younger than the + // orphan sweep's age gate. Report in-band instead. The residue + // is deliberate: the next Start's preflight GC collects the + // terminated pod and its referenced Secret together, so retry + // is gated on fresh owner intent and litter stays bounded at + // one pod + one Secret per press. + if created_this_call { + return Err(format!( + "{name} was created by this deploy and did not stay \ + running: {}. Not retrying in this call — an immediate \ + exit recurs until its cause is fixed. Check the \ + agent's configuration and press Start to try again.", + latest_condition(substrate, identity).await + )); + } + match substrate.delete_pod(&name, &fence).await? { + // Accepted or already gone: both need the disappearance + // poll before the name is free again. + DeleteOutcome::Accepted | DeleteOutcome::NotFound => { + await_disappearance(substrate, &name).await? + } + // The object changed since the observation that authorized + // this delete. Discard the action and re-classify — never + // retry with a fresher fence, which would delete something + // we never examined. Sleep before re-entering: the losing + // race is against another writer, and re-reading at full + // speed is a busy-retry with no better odds than a paced + // one. + DeleteOutcome::PreconditionFailed => substrate.sleep(POLL_INTERVAL).await, + } + } + + Action::Create => { + let generation = crate::naming::new_generation(); + let secret_name = identity.secret_name(&generation); + + // The generation is minted *per attempt*, and it is two things + // at once: the Secret's name suffix and the lifecycle + // correlator the harness reports. `build_env` stamped the + // caller's generation, so on any attempt after the first the + // two would name different generations — pod logs correlating + // to a Secret that is not the one mounted. Restamp so there is + // exactly one generation per attempt (§K8s Secrets). + let mut env = env.clone(); + env.insert(crate::env::START_NONCE_KEY.to_string(), generation.clone()); + + // Secret first: the pod's spec references this exact name, so + // payload and Secret are atomic at the pod-spec boundary. + let secret = crate::pod::build_secret(identity, &cfg.namespace, &generation, env); + substrate.create_secret(&secret).await?; + + let pod = crate::pod::build_pod(identity, cfg, &generation, &desired); + match substrate.create_pod(&pod).await? { + // Re-enter rather than wait inline: the next iteration + // observes what we just created and runs the same rows + // every other state runs through. One loop, one table. + // + // Sleep first. A just-created pod cannot already be + // started, so the immediate observation has no outcome but + // "still coming up" — and if it ever came back + // unverifiable, re-entering without advancing the clock + // would hot-spin creates against the apiserver for the + // whole deadline. + CreateOutcome::Created => { + created_this_call = true; + substrate.sleep(POLL_INTERVAL).await + } + CreateOutcome::AlreadyExists => { + return adopt_winner(substrate, identity, &secret_name).await + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Resources; + use crate::naming::{ANNOTATION_CREATE_INTENT, ANNOTATION_PUBKEY_FULL, LABEL_MANAGED_BY}; + use k8s_openapi::api::core::v1::{ + ContainerState, ContainerStateRunning, ContainerStateTerminated, ContainerStateWaiting, + ContainerStatus, PodStatus, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time; + use std::cell::RefCell; + use std::future::Future; + use std::pin::pin; + use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker}; + + /// Mutates the pod map after a poll, so a test can script a pod that + /// starts (or vanishes) partway through an observation loop. + type PollHook = Box)>; + + /// A scripted cluster. Single-threaded on purpose: the reconciler is one + /// process per operation, and `RefCell` keeps the assertions readable. + /// + /// This drives the *shipped* `deploy` — the point of the `Substrate` seam. + /// Every fake here answers with real `k8s_openapi` objects, so the decode + /// and verification layers under test are the ones that run in a cluster. + #[derive(Default)] + struct Fake { + pods: RefCell>, + secrets: RefCell>, + /// Server clock for the GC age gate; `None` models a missing `Date`. + server_now: Option>, + /// Elapsed time, advanced only by `sleep` — a fake clock, so a 600s + /// deadline test runs instantly and cannot flake on a slow machine. + elapsed: RefCell, + /// Queued create outcomes; the default is `Created`. + create_outcomes: RefCell>, + /// Installed when a create loses the race. A winner must be *absent* + /// at the observation that decides to create and *present* by the time + /// the create lands — pre-seeding it instead makes the loop no-op + /// before it ever reaches the create edge. + winner: RefCell>, + /// Every Secret ever created, retained across deletion. + created_secrets: RefCell>, + /// Queued delete outcomes; the default is `Accepted`. + delete_outcomes: RefCell>, + /// Every mutating call, in order — the anti-mutation assertions read + /// this rather than guessing from final state. + calls: RefCell>, + /// Applied to the pod map after each poll, so a test can script a pod + /// that starts (or vanishes) partway through an observation loop. + on_poll: RefCell>, + /// `ensure_namespace` fails with this, if set. + namespace_error: Option, + } + + impl Fake { + fn with_pod(self, pod: Pod) -> Self { + self.pods + .borrow_mut() + .insert(pod.metadata.name.clone().unwrap(), pod); + self + } + fn log(&self, entry: impl Into) { + self.calls.borrow_mut().push(entry.into()); + } + fn mutations(&self) -> Vec { + self.calls.borrow().clone() + } + } + + impl Substrate for Fake { + async fn ensure_namespace(&self, namespace: &str) -> Result<(), String> { + match &self.namespace_error { + Some(e) => Err(e.clone()), + None => { + self.log(format!("ensure_namespace {namespace}")); + Ok(()) + } + } + } + + async fn list_pods( + &self, + _selector: &str, + ) -> Result<(Vec, Option>), String> { + Ok(( + self.pods.borrow().values().cloned().collect(), + self.server_now, + )) + } + + async fn list_secrets(&self, _selector: &str) -> Result, String> { + Ok(self.secrets.borrow().clone()) + } + + async fn secret_exists(&self, name: &str) -> Result { + Ok(self + .secrets + .borrow() + .iter() + .any(|s| s.metadata.name.as_deref() == Some(name))) + } + + async fn create_secret(&self, secret: &Secret) -> Result<(), String> { + let name = secret.metadata.name.clone().unwrap(); + self.log(format!("create_secret {name}")); + self.secrets.borrow_mut().push(secret.clone()); + // Kept even after the Secret is deleted: assertions about what an + // attempt *wrote* must not be silently vacuous once cleanup runs. + self.created_secrets.borrow_mut().push(secret.clone()); + Ok(()) + } + + async fn create_pod(&self, pod: &Pod) -> Result { + let name = pod.metadata.name.clone().unwrap(); + self.log(format!("create_pod {name}")); + let outcome = if self.create_outcomes.borrow().is_empty() { + CreateOutcome::Created + } else { + self.create_outcomes.borrow_mut().remove(0) + }; + if outcome == CreateOutcome::Created { + // The apiserver stamps these on admission; a builder never + // carries them. Without them the pod fails `verify`'s fence + // extraction and the loop can never see what it just created. + let mut pod = pod.clone(); + pod.metadata.uid = Some(format!("uid-created-{}", self.calls.borrow().len())); + pod.metadata.resource_version = Some("1".into()); + self.pods.borrow_mut().insert(name, pod); + } else if let Some(winner) = self.winner.borrow_mut().take() { + // The concurrent attempt's pod becomes visible exactly when our + // create is rejected — the ordering a real race produces. + self.pods.borrow_mut().insert(name, winner); + } + Ok(outcome) + } + + async fn delete_pod(&self, name: &str, fence: &Fence) -> Result { + self.log(format!( + "delete_pod {name} uid={} rv={}", + fence.uid, fence.resource_version + )); + let outcome = if self.delete_outcomes.borrow().is_empty() { + DeleteOutcome::Accepted + } else { + self.delete_outcomes.borrow_mut().remove(0) + }; + if outcome == DeleteOutcome::Accepted { + self.pods.borrow_mut().remove(name); + } + Ok(outcome) + } + + async fn delete_secret(&self, name: &str) -> Result<(), String> { + self.log(format!("delete_secret {name}")); + self.secrets + .borrow_mut() + .retain(|s| s.metadata.name.as_deref() != Some(name)); + Ok(()) + } + + async fn get_pod(&self, name: &str) -> Result, String> { + Ok(self.pods.borrow().get(name).cloned()) + } + + async fn sleep(&self, duration: Duration) { + *self.elapsed.borrow_mut() += duration; + let hooks = self.on_poll.borrow(); + let mut pods = self.pods.borrow_mut(); + for hook in hooks.iter() { + hook(&mut pods); + } + } + + fn elapsed(&self) -> Duration { + *self.elapsed.borrow() + } + } + + fn identity() -> AgentIdentity { + use nostr::nips::nip19::ToBech32; + let keys = nostr::Keys::generate(); + AgentIdentity::from_nsec(&keys.secret_key().to_bech32().unwrap()).unwrap() + } + + fn config() -> ProviderConfig { + ProviderConfig { + context: None, + namespace: "buzz-agents-test".into(), + image: crate::image::parse(&format!( + "ghcr.io/block/buzz-sprig@sha256:{}", + "a".repeat(64) + )) + .unwrap(), + resources: Resources::default(), + inactivity_seconds: Some(7200), + service_account: None, + } + } + + fn env() -> BTreeMap { + [("BUZZ_RELAY_URL".to_string(), "wss://r".to_string())] + .into_iter() + .collect() + } + + /// A pod exactly as this provider would have created it — same builder the + /// reconciler uses, so verification is exercised rather than bypassed. + fn our_pod(id: &AgentIdentity, cfg: &ProviderConfig, state: Option) -> Pod { + let fp = crate::pod::intent_template(cfg, env().keys().cloned()).fingerprint(); + let mut pod = crate::pod::build_pod(id, cfg, "gen-existing", &fp); + pod.metadata.uid = Some("uid-1".into()); + pod.metadata.resource_version = Some("100".into()); + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: state.map(|s| { + vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(s), + ..Default::default() + }] + }), + ..Default::default() + }); + pod + } + + fn running() -> ContainerState { + ContainerState { + running: Some(ContainerStateRunning::default()), + ..Default::default() + } + } + + fn terminated() -> ContainerState { + ContainerState { + terminated: Some(ContainerStateTerminated::default()), + ..Default::default() + } + } + + fn waiting(reason: &str) -> ContainerState { + ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some(reason.into()), + message: None, + }), + ..Default::default() + } + } + + fn run(fake: &Fake, id: &AgentIdentity, cfg: &ProviderConfig) -> Result { + block_on(deploy(fake, id, cfg, env())) + } + + /// Minimal executor: the fake never yields to a reactor (no timers, no + /// I/O — `sleep` just advances a counter), so polling to completion is + /// sufficient and avoids pulling a runtime into the unit job. + fn block_on(fut: impl Future) -> T { + fn noop_waker() -> Waker { + fn nop(_: *const ()) {} + fn clone(_: *const ()) -> RawWaker { + RawWaker::new(std::ptr::null(), &VTABLE) + } + static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, nop, nop, nop); + unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) } + } + + let waker = noop_waker(); + let mut cx = Context::from_waker(&waker); + let mut fut = pin!(fut); + match fut.as_mut().poll(&mut cx) { + Poll::Ready(v) => v, + Poll::Pending => panic!("fake substrate future parked — it has no reactor"), + } + } + + // ---- the state machine's rows, end to end ------------------------------- + + /// First deploy: Secret before pod, and the returned id is the pod name. + #[test] + fn creates_secret_then_pod_and_returns_the_pod_name() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + + let calls = fake.mutations(); + let secret_at = calls + .iter() + .position(|c| c.starts_with("create_secret")) + .unwrap(); + let pod_at = calls + .iter() + .position(|c| c.starts_with("create_pod")) + .unwrap(); + assert!( + secret_at < pod_at, + "pod created before its Secret: {calls:?}" + ); + } + + /// The strict no-op row: a started pod returns its id having mutated + /// nothing at all. Asserted on the *call log*, not on final state — a + /// delete-then-recreate would leave identical final state. + #[test] + fn started_pod_is_a_zero_mutation_no_op() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(running()))); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert_eq!( + fake.mutations(), + [format!("ensure_namespace {}", cfg.namespace)], + "the no-op row mutated something" + ); + } + + /// ...including when the desired intent has diverged. Edits reach a + /// started pod only via the next generation (`:861-865`). + #[test] + fn started_pod_no_ops_under_divergent_intent() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, Some(running())); + pod.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_CREATE_INTENT.to_string(), + "stale-fingerprint".into(), + ); + let fake = Fake::default().with_pod(pod); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "divergence deleted a started pod" + ); + } + + /// Terminated → fenced delete → disappearance → recreate. The normal + /// restart path, and the fence must carry the observed uid/rv. + #[test] + fn terminated_pod_is_replaced_with_a_fenced_delete() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + let calls = fake.mutations(); + assert!( + calls + .iter() + .any(|c| c == &format!("delete_pod {} uid=uid-1 rv=100", id.pod_name())), + "delete was not fenced to the observed uid+resourceVersion: {calls:?}" + ); + assert!(calls.iter().any(|c| c.starts_with("create_pod"))); + } + + /// A failed precondition is neither an error nor permission to retry the + /// delete: re-enter and classify what exists now (`:775-777`). Here the + /// object has become a *started* pod, so the correct outcome is the no-op + /// row — never a second delete with a fresher fence. + #[test] + fn precondition_failure_reclassifies_instead_of_retrying() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + fake.delete_outcomes + .borrow_mut() + .push(DeleteOutcome::PreconditionFailed); + // The writer we lost the race to: between our observation and our + // delete, the pod under this name became a *started* one with a new + // resourceVersion. Installed on the poll that follows the failed + // precondition, so the sequence is observe → delete → lose → re-observe. + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + let started = { + let mut p = our_pod(&id, &cfg, Some(running())); + p.metadata.resource_version = Some("200".into()); + p + }; + Box::new(move |pods: &mut BTreeMap| { + pods.insert(name.clone(), started.clone()); + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + let deletes: Vec<_> = fake + .mutations() + .into_iter() + .filter(|c| c.starts_with("delete_pod")) + .collect(); + // Two call sites legitimately target a terminated pod: preflight GC's + // sweep (which is the one that loses the precondition here) and the + // state machine's terminated row. Both are fenced on their own + // observation. What must never happen is a *third* — a retry of the + // failed delete — so the invariant is the fence, not the count: every + // delete carries rv=100, the version we observed. A retry would carry + // the racing writer's rv=200. + assert_eq!(deletes.len(), 2, "unexpected delete traffic: {deletes:?}"); + assert!( + deletes.iter().all(|d| d.contains("rv=100")), + "retried with a fresher fence: {deletes:?}" + ); + } + + /// The anti-livelock rule, at the loop level: a recoverable pod whose + /// intent matches is observed until the deadline and **never** deleted — + /// the case that would otherwise reset the pod age Cluster Autoscaler + /// keys on (`:689`). + #[test] + fn recoverable_pod_with_matching_intent_is_never_deleted() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("Unschedulable")))); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("startup not confirmed"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a recoverable pod: {:?}", + fake.mutations() + ); + assert!(fake.elapsed() >= DEADLINE, "gave up before the deadline"); + } + + /// The same pod, provisioned late: the observation loop must *succeed* + /// when the autoscaler eventually lands the node, not merely avoid + /// deleting. + #[test] + fn recoverable_pod_that_starts_late_succeeds() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("Unschedulable")))); + let polls = RefCell::new(0); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + *polls.borrow_mut() += 1; + if *polls.borrow() >= 5 { + if let Some(pod) = pods.get_mut(&name) { + pod.status.as_mut().unwrap().container_statuses = + Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.elapsed() < DEADLINE); + } + + /// A permanent pull failure reports immediately rather than burning the + /// deadline — and still never deletes. + #[test] + fn permanent_pull_failure_reports_without_deleting() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, None); + pod.status = Some(PodStatus { + phase: Some("Pending".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some("ImagePullBackOff".into()), + message: Some("manifest unknown".into()), + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + let fake = Fake::default().with_pod(pod); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("no image at"), "got: {err}"); + assert!( + fake.elapsed() < DEADLINE, + "burned the deadline on a permanent failure" + ); + assert!(!fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + /// The kubelet's pull *message* can echo a registry request; only the + /// classified outcome and the image reference reach the user. + #[test] + fn pull_failure_message_is_not_echoed_verbatim() { + let id = identity(); + let cfg = config(); + let mut pod = our_pod(&id, &cfg, None); + pod.status = Some(PodStatus { + phase: Some("Pending".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + waiting: Some(ContainerStateWaiting { + reason: Some("ErrImagePull".into()), + message: Some( + "unauthorized: authentication required, token=SUPERSECRET".into(), + ), + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + let fake = Fake::default().with_pod(pod); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!( + !err.contains("SUPERSECRET"), + "kubelet message echoed: {err}" + ); + } + + /// A pod being gracefully deleted stays in phase `Running` for its whole + /// grace period. The reconciler must wait it out and recreate, not return + /// an id that evaporates. + #[test] + fn deletion_marked_pod_is_awaited_then_recreated() { + let id = identity(); + let cfg = config(); + let mut dying = our_pod(&id, &cfg, Some(running())); + dying.metadata.deletion_timestamp = Some(Time(Utc::now())); + let fake = Fake::default().with_pod(dying); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + match pods + .get(&name) + .map(|p| p.metadata.deletion_timestamp.is_some()) + { + // The grace period elapses: the object disappears. + Some(true) => { + pods.remove(&name); + } + // The replacement we create then starts. + Some(false) => { + let pod = pods.get_mut(&name).unwrap(); + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + None => {} + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a pod that was already terminating" + ); + } + + /// `CreateContainerConfigError` is settled by a most-recent Secret read, + /// never by the reason string: Secret present → recoverable (observe). + #[test] + fn config_error_with_a_present_secret_is_recoverable() { + let id = identity(); + let cfg = config(); + let pod = our_pod(&id, &cfg, Some(waiting("CreateContainerConfigError"))); + let referenced = crate::observe::referenced_secret(&pod).unwrap(); + let fake = Fake::default().with_pod(pod); + fake.secrets.borrow_mut().push(crate::pod::build_secret( + &id, + &cfg.namespace, + "gen-existing", + env(), + )); + assert_eq!(referenced, id.secret_name("gen-existing")); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("startup not confirmed"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted a pod whose Secret exists" + ); + } + + /// ...and Secret confirmed absent → provably broken → fenced replace. + #[test] + fn config_error_with_a_confirmed_absent_secret_is_replaced() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod( + &id, + &cfg, + Some(waiting("CreateContainerConfigError")), + )); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Running".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]), + ..Default::default() + }); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + /// The generation is the Secret's name suffix *and* the lifecycle + /// correlator inside it. They must name the same generation, or pod logs + /// point at a Secret that was never mounted. The caller stamps one + /// generation into the env once; each create attempt mints its own, so + /// only a restamp keeps the pair together across a retry. + #[test] + fn each_attempt_stamps_its_own_generation_into_its_own_secret() { + let id = identity(); + let cfg = config(); + // First attempt loses the create race and is cleaned up; the operation + // then adopts. Two creates, two generations. + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(our_pod(&id, &cfg, Some(running()))); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + run(&fake, &id, &cfg).unwrap(); + + let created = fake.created_secrets.borrow(); + // Guard the guard: this assertion is over a list that cleanup empties, + // so an empty list would make every check below vacuously true. + assert_eq!(created.len(), 1, "expected one create attempt"); + for secret in created.iter() { + let name = secret.metadata.name.as_deref().unwrap(); + let nonce = secret + .string_data + .as_ref() + .and_then(|d| d.get(crate::env::START_NONCE_KEY)) + .expect("no lifecycle correlator in the Secret"); + assert!( + name.ends_with(nonce.as_str()), + "secret {name} carries a correlator for a different generation ({nonce})" + ); + } + } + + // ---- bounded replacement ------------------------------------------------ + + /// Installs a hook that makes every pod under `name` crash-exit before the + /// next poll — a deterministic startup failure (the harness starts, rejects + /// its configuration, and exits) as observed live: the container reaches + /// `state.terminated` with a nonzero exit code. + fn crash_exits_immediately(fake: &Fake, name: String) { + fake.on_poll + .borrow_mut() + .push(Box::new(move |pods: &mut BTreeMap| { + if let Some(pod) = pods.get_mut(&name) { + if pod + .status + .as_ref() + .and_then(|s| s.container_statuses.as_ref()) + .is_none() + { + pod.status = Some(PodStatus { + phase: Some("Failed".into()), + container_statuses: Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(ContainerState { + terminated: Some(ContainerStateTerminated { + exit_code: 1, + reason: Some("Error".into()), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + }]), + ..Default::default() + }); + } + } + })); + } + + /// A pod that this call created and that crash-exits deterministically is + /// reported in-band after exactly ONE attempt — never hot-replaced until + /// the deadline. The live failure this pins: one delete/create cycle every + /// ~4s minted 107 immutable Secrets in a single 600s deploy call, all + /// younger than the orphan sweep's age gate. + #[test] + fn a_deterministic_crash_exit_is_reported_not_hot_replaced() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + crash_exits_immediately(&fake, id.pod_name()); + + let err = run(&fake, &id, &cfg).unwrap_err(); + + let creates = fake + .mutations() + .iter() + .filter(|c| c.starts_with("create_secret")) + .count(); + assert_eq!( + creates, 1, + "hot replacement loop: {creates} Secrets minted in one deploy call" + ); + assert!( + err.contains("exited with code 1"), + "error does not carry the exit: {err}" + ); + // The failed attempt is left in place as evidence — no cleanup on the + // error path. Its Secret stays referenced by the terminated pod, so + // the next deploy's preflight GC collects both together. + assert!( + !fake + .mutations() + .iter() + .any(|c| c.starts_with("delete_pod") || c.starts_with("delete_secret")), + "the failed attempt was cleaned up on the error path: {:?}", + fake.mutations() + ); + assert!( + fake.elapsed() < DEADLINE, + "burned the whole deadline on a deterministic failure" + ); + } + + /// The revive path is untouched: terminated residue from a *previous* + /// life is still replaced — the bound is on pods this call created, not + /// on the row. + #[test] + fn pre_existing_terminated_residue_is_still_replaced_once() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(terminated()))); + crash_exits_immediately(&fake, id.pod_name()); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("exited with code 1"), "got: {err}"); + + let calls = fake.mutations(); + let deletes = calls.iter().filter(|c| c.starts_with("delete_pod")).count(); + let creates = calls + .iter() + .filter(|c| c.starts_with("create_secret")) + .count(); + // Exactly one residue delete (the normal restart path) and one fresh + // attempt — then report, not another cycle. + assert_eq!(deletes, 1, "unexpected delete traffic: {calls:?}"); + assert_eq!(creates, 1, "unexpected create traffic: {calls:?}"); + } + + /// Retry is gated on fresh owner intent: the *next* deploy call clears + /// the crashed attempt (preflight GC collects the terminated pod and its + /// referenced Secret together) and makes exactly one new attempt — total + /// litter stays one pod + one Secret however many times Start is pressed. + #[test] + fn the_next_deploy_collects_the_crashed_attempt_before_its_own_attempt() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + crash_exits_immediately(&fake, id.pod_name()); + + run(&fake, &id, &cfg).unwrap_err(); + // Second Start: fresh call, fresh clock. + *fake.elapsed.borrow_mut() = Duration::ZERO; + run(&fake, &id, &cfg).unwrap_err(); + + assert_eq!( + fake.secrets.borrow().len(), + 1, + "crashed attempts accumulated Secrets across calls" + ); + assert_eq!(fake.pods.borrow().len(), 1, "crashed pods accumulated"); + } + + // ---- the auto-repair fence ---------------------------------------------- + + /// An object under our deterministic name that lacks the management + /// marker is not ours. The reconciler must not adopt it, delete it, or + /// return its name — it fails closed to the operator (`:1156-1162`). + #[test] + fn an_unmarked_look_alike_is_never_touched_or_adopted() { + let id = identity(); + let cfg = config(); + let mut look_alike = our_pod(&id, &cfg, Some(running())); + look_alike + .metadata + .labels + .as_mut() + .unwrap() + .remove(LABEL_MANAGED_BY); + let fake = Fake::default(); + // Our create loses to the object already sitting on the name. + *fake.winner.borrow_mut() = Some(look_alike); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("not managed by this provider"), "got: {err}"); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "deleted an object we do not own" + ); + } + + /// Same fence, other direction: the marker is present but the annotation + /// carries a different agent's pubkey. The 32-hex label is + /// collision-resistant, not collision-free (`:1152-1155`). + #[test] + fn a_pubkey_mismatch_is_never_adopted() { + let id = identity(); + let other = identity(); + let cfg = config(); + let mut foreign = our_pod(&id, &cfg, Some(running())); + foreign.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_PUBKEY_FULL.to_string(), + other.pubkey_hex().to_string(), + ); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(foreign); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("different agent identity"), "got: {err}"); + assert!(!fake.mutations().iter().any(|c| c.starts_with("delete_pod"))); + } + + // ---- create-conflict convergence --------------------------------------- + + /// The loser adopts the winner, returns the winner's id, and deletes + /// **only its own** Secret — never the winner's (`:1259-1264`). + #[test] + fn create_loser_adopts_the_winner_and_drops_only_its_own_secret() { + let id = identity(); + let cfg = config(); + let winner = our_pod(&id, &cfg, Some(running())); + let winners_secret = crate::observe::referenced_secret(&winner).unwrap(); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(winner); + fake.secrets.borrow_mut().push(crate::pod::build_secret( + &id, + &cfg.namespace, + "gen-existing", + env(), + )); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + + let deleted: Vec<_> = fake + .mutations() + .into_iter() + .filter(|c| c.starts_with("delete_secret")) + .collect(); + assert_eq!( + deleted.len(), + 1, + "expected exactly our own Secret dropped: {deleted:?}" + ); + assert!( + !deleted[0].contains(&winners_secret), + "deleted the winner's Secret: {deleted:?}" + ); + assert!( + fake.secrets + .borrow() + .iter() + .any(|s| s.metadata.name.as_deref() == Some(winners_secret.as_str())), + "the winner's Secret is gone" + ); + } + + /// The loser must not apply the divergence row to the pod that just beat + /// it — that is the ping-pong the spec forbids (`:845-850`). A divergent + /// *started* winner is adopted as-is. + #[test] + fn create_loser_does_not_replace_a_divergent_winner() { + let id = identity(); + let cfg = config(); + let mut winner = our_pod(&id, &cfg, Some(running())); + winner.metadata.annotations.as_mut().unwrap().insert( + ANNOTATION_CREATE_INTENT.to_string(), + "a-different-intent".into(), + ); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(winner); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!( + !fake.mutations().iter().any(|c| c.starts_with("delete_pod")), + "the create loser deleted the winner" + ); + } + + /// The loser waits for the winner to *start* — adopting a not-yet-started + /// winner as success would report an agent that is not running. + #[test] + fn create_loser_waits_for_the_winner_to_start() { + let id = identity(); + let cfg = config(); + let fake = Fake::default(); + *fake.winner.borrow_mut() = Some(our_pod(&id, &cfg, Some(waiting("ContainerCreating")))); + fake.create_outcomes + .borrow_mut() + .push(CreateOutcome::AlreadyExists); + let polls = RefCell::new(0); + fake.on_poll.borrow_mut().push({ + let name = id.pod_name(); + Box::new(move |pods: &mut BTreeMap| { + *polls.borrow_mut() += 1; + if *polls.borrow() >= 3 { + if let Some(pod) = pods.get_mut(&name) { + pod.status.as_mut().unwrap().container_statuses = + Some(vec![ContainerStatus { + name: crate::observe::CONTAINER_NAME.into(), + state: Some(running()), + ..Default::default() + }]); + } + } + }) + }); + + assert_eq!(run(&fake, &id, &cfg).unwrap(), id.pod_name()); + assert!(fake.elapsed() > Duration::ZERO, "did not wait at all"); + } + + // ---- deadline and namespace -------------------------------------------- + + /// Deadline expiry reports the *latest* condition, not a generic timeout + /// (`:699-701`), and triggers no cleanup (`:720-724`). + #[test] + fn deadline_expiry_reports_the_condition_and_cleans_up_nothing() { + let id = identity(); + let cfg = config(); + let fake = Fake::default().with_pod(our_pod(&id, &cfg, Some(waiting("ContainerCreating")))); + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("ContainerCreating"), "generic timeout: {err}"); + let calls = fake.mutations(); + assert!( + !calls.iter().any(|c| c.starts_with("delete_")), + "deadline expiry triggered cleanup: {calls:?}" + ); + } + + /// An RBAC denial on namespace create fails the deploy with the literal + /// command to run, before any Secret is written (`:1002-1005`). + #[test] + fn namespace_denial_fails_before_writing_any_secret() { + let id = identity(); + let cfg = config(); + let fake = Fake { + namespace_error: Some(format!( + "not authorized to create namespaces: run `kubectl create namespace {}`", + cfg.namespace + )), + ..Default::default() + }; + + let err = run(&fake, &id, &cfg).unwrap_err(); + assert!(err.contains("kubectl create namespace"), "got: {err}"); + assert!( + fake.mutations().is_empty(), + "wrote something after a namespace denial: {:?}", + fake.mutations() + ); + } + + /// GC failures are hygiene, not deploy failures: a list the user cannot + /// perform must not block a deploy they can. + #[test] + fn a_gc_failure_does_not_fail_the_deploy() { + struct GcDenied(Fake); + impl Substrate for GcDenied { + async fn ensure_namespace(&self, ns: &str) -> Result<(), String> { + self.0.ensure_namespace(ns).await + } + async fn list_pods( + &self, + _s: &str, + ) -> Result<(Vec, Option>), String> { + Err("forbidden: cannot list pods".into()) + } + async fn list_secrets(&self, _s: &str) -> Result, String> { + Err("forbidden: cannot list secrets".into()) + } + async fn secret_exists(&self, n: &str) -> Result { + self.0.secret_exists(n).await + } + async fn create_secret(&self, s: &Secret) -> Result<(), String> { + self.0.create_secret(s).await + } + async fn create_pod(&self, p: &Pod) -> Result { + self.0.create_pod(p).await + } + async fn delete_pod(&self, n: &str, f: &Fence) -> Result { + self.0.delete_pod(n, f).await + } + async fn delete_secret(&self, n: &str) -> Result<(), String> { + self.0.delete_secret(n).await + } + async fn get_pod(&self, n: &str) -> Result, String> { + self.0.get_pod(n).await + } + async fn sleep(&self, d: Duration) { + self.0.sleep(d).await + } + fn elapsed(&self) -> Duration { + self.0.elapsed() + } + } + + let id = identity(); + let cfg = config(); + let inner = Fake::default().with_pod(our_pod(&id, &cfg, Some(running()))); + let fake = GcDenied(inner); + + assert_eq!( + block_on(deploy(&fake, &id, &cfg, env())).unwrap(), + id.pod_name() + ); + } +} diff --git a/crates/buzz-backend-kubernetes/src/wire.rs b/crates/buzz-backend-kubernetes/src/wire.rs new file mode 100644 index 00000000000..89b78b364df --- /dev/null +++ b/crates/buzz-backend-kubernetes/src/wire.rs @@ -0,0 +1,250 @@ +//! The stdin/stdout JSON protocol (spec §Provider Protocol). +//! +//! One process per operation: one JSON object in, one JSON object out. +//! These types are this provider's view of the contract; the golden fixtures +//! in `tests/fixtures/provider-wire/` are the arbiter shared with the desktop. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// The wire-contract version this provider speaks (spec §Info). +pub const PROTOCOL_VERSION: u32 = 1; + +/// Request envelope. `op` discriminates; unknown ops are an in-band error. +/// +/// `request_id` is deliberately absent from every variant. The desktop sends +/// it, but the exchange is one request and one response per process, so there +/// is nothing to correlate and nothing in the response schema to echo it into +/// (`:387`, `:416` — neither response shape carries it). Serde ignores it on +/// the way in, so a caller that sends it is accepted; typing it would only +/// create a field nothing reads. +#[derive(Debug, Deserialize)] +#[serde(tag = "op", rename_all = "lowercase")] +pub enum Request { + Info, + Deploy(Box), +} + +#[derive(Debug, Deserialize)] +pub struct DeployRequest { + pub agent: AgentPayload, + #[serde(default)] + pub provider_config: serde_json::Value, +} + +/// The agent payload (spec §Deploy). +/// +/// Only the fields this binding actually consumes are typed. `name` (display +/// name), `model`, `provider`, and `turn_timeout_seconds` are deliberately +/// absent: object names derive from the pubkey, not the display name +/// (`:1150-1152`), the model/provider pair arrives already resolved inside +/// `launch`, and the timeout is ignored upstream — typing any of them would +/// invite a provider-side remap the spec forbids. +#[derive(Debug, Deserialize)] +pub struct AgentPayload { + pub relay_url: String, + pub private_key_nsec: String, + #[serde(default)] + pub auth_tag: Option, + #[serde(default)] + pub respond_to: Option, + #[serde(default)] + pub respond_to_allowlist: Option>, + /// User env, already merged global < persona < agent by the desktop and + /// already stripped of reserved keys. Superseded by `launch.env` when + /// `launch` is present — a provider MUST NOT re-merge it on top + /// (§Launch data, precedence tier 2). + #[serde(default)] + pub env_vars: BTreeMap, + /// The desktop-resolved launch contract. Absent only from a desktop + /// predating Known Defect 3's fix. + #[serde(default)] + pub launch: Option, +} + +/// Desktop-resolved launch data (spec §Launch data). +#[derive(Debug, Default, Deserialize)] +pub struct LaunchBlock { + /// Command *name*, resolved against the image's PATH — never a host path. + #[serde(default)] + pub command: Option, + #[serde(default)] + pub args: Vec, + /// Layered env: baked → runtime metadata → definition → global → persona + /// → agent. Precedence tier 2. + #[serde(default)] + pub env: BTreeMap, + /// Overridable behavior defaults. Precedence tier 1 — user env beats + /// these, matching the local spawn. + #[serde(default)] + pub policy_env: BTreeMap, + /// Resolved workspace owner (hex). The respond-to gate's one + /// irreducible input; without it or `auth_tag` the harness cannot match + /// `!shutdown`. + #[serde(default)] + pub owner_pubkey: Option, +} + +/// Response envelope. Serialized flat — `{"ok": true, …}` — because the +/// desktop reads `ok`, `error`, and `agent_id` off the top level. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub enum Response { + Info(InfoResponse), + Deploy(DeployResponse), + Error(ErrorResponse), +} + +#[derive(Debug, Serialize)] +pub struct InfoResponse { + pub ok: bool, + pub name: &'static str, + pub version: &'static str, + pub protocol_version: u32, + pub description: &'static str, + pub config_schema: serde_json::Value, +} + +#[derive(Debug, Serialize)] +pub struct DeployResponse { + pub ok: bool, + pub agent_id: String, +} + +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub ok: bool, + pub error: String, +} + +impl Response { + pub fn error(message: impl Into) -> Self { + Response::Error(ErrorResponse { + ok: false, + error: message.into(), + }) + } + + /// The provider's self-description (spec §Info). Pure — no cluster + /// contact — because the desktop calls it to render the config form + /// before a kubeconfig is known to exist. + pub fn info() -> Self { + Response::Info(InfoResponse { + ok: true, + name: "kubernetes", + version: env!("CARGO_PKG_VERSION"), + protocol_version: PROTOCOL_VERSION, + description: "Runs agents as pods in a Kubernetes cluster", + config_schema: crate::config::config_schema(), + }) + } + + pub fn deployed(agent_id: impl Into) -> Self { + Response::Deploy(DeployResponse { + ok: true, + agent_id: agent_id.into(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_info_request() { + let r: Request = serde_json::from_str(r#"{"op":"info","request_id":"abc"}"#).unwrap(); + assert!(matches!(r, Request::Info)); + } + + /// The desktop sends `request_id` on every call, but the exchange is 1:1 + /// per process — a provider that hard-required it would fail a + /// conforming-but-minimal caller for no safety gain. + #[test] + fn request_id_is_optional() { + let r: Request = serde_json::from_str(r#"{"op":"info"}"#).unwrap(); + assert!(matches!(r, Request::Info)); + } + + #[test] + fn rejects_unknown_op() { + assert!(serde_json::from_str::(r#"{"op":"undeploy"}"#).is_err()); + } + + /// Payload fields this binding does not consume must not break parsing: + /// the desktop sends `model`, `provider`, `system_prompt` and more, and a + /// provider that rejected them would break on every real deploy. + #[test] + fn ignores_unconsumed_payload_fields() { + let json = r#"{ + "op":"deploy","request_id":"r1", + "agent":{ + "name":"a","relay_url":"wss://r","private_key_nsec":"nsec1x", + "model":"gpt-5","provider":"openai","system_prompt":"hi", + "turn_timeout_seconds":30,"parallelism":10, + "agent_command":"goose","agent_args":[] + }, + "provider_config":{"namespace":"ns"} + }"#; + let r: Request = serde_json::from_str(json).unwrap(); + let Request::Deploy(d) = r else { + panic!("wrong op") + }; + assert_eq!(d.agent.relay_url, "wss://r"); + assert!(d.agent.launch.is_none()); + } + + #[test] + fn parses_launch_block() { + let json = r#"{ + "op":"deploy", + "agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x", + "launch":{ + "command":"goose","args":["run","--x"], + "env":{"GOOSE_MODEL":"m"}, + "policy_env":{"GOOSE_MODE":"auto"}, + "owner_pubkey":"deadbeef" + } + } + }"#; + let Request::Deploy(d) = serde_json::from_str::(json).unwrap() else { + panic!("wrong op") + }; + let l = d.agent.launch.unwrap(); + assert_eq!(l.command.as_deref(), Some("goose")); + assert_eq!(l.args, ["run", "--x"]); + assert_eq!(l.env["GOOSE_MODEL"], "m"); + assert_eq!(l.policy_env["GOOSE_MODE"], "auto"); + assert_eq!(l.owner_pubkey.as_deref(), Some("deadbeef")); + } + + /// A null `owner_pubkey`/`auth_tag` must parse (the refusal is a policy + /// decision made later, with a specific message), not fail as a type error. + #[test] + fn null_owner_fields_parse() { + let json = r#"{"op":"deploy","agent":{ + "relay_url":"wss://r","private_key_nsec":"nsec1x","auth_tag":null, + "launch":{"owner_pubkey":null} + }}"#; + let Request::Deploy(d) = serde_json::from_str::(json).unwrap() else { + panic!("wrong op") + }; + assert!(d.agent.auth_tag.is_none()); + assert!(d.agent.launch.unwrap().owner_pubkey.is_none()); + } + + /// The desktop reads `ok`/`error`/`agent_id` off the top level, so the + /// enum must serialize flat with no variant tag. + #[test] + fn responses_serialize_flat() { + let v = serde_json::to_value(Response::deployed("buzz-agent-abc")).unwrap(); + assert_eq!(v["ok"], true); + assert_eq!(v["agent_id"], "buzz-agent-abc"); + assert!(v.get("Deploy").is_none()); + + let v = serde_json::to_value(Response::error("boom")).unwrap(); + assert_eq!(v["ok"], false); + assert_eq!(v["error"], "boom"); + } +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md new file mode 100644 index 00000000000..56a972f8942 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/README.md @@ -0,0 +1,39 @@ +# Provider wire fixtures + +The shared arbiter for the stdin/stdout contract between the desktop +(`agents_deploy.rs`) and this provider (spec §Provider Protocol). + +Each `*.request.json` is a request the desktop can emit; each matching +`*.response.json` is the exact response this provider produces for it. The +provider side is asserted by `tests/wire_fixtures.rs`; the desktop side should +assert that its emitted payloads parse as the corresponding request. + +Three rules keep these useful rather than decorative: + +* **Requests are recorded, not invented.** A fixture that no caller emits + tests a contract nobody has. "Recorded" means *executed and transcribed* — + `deploy-full-launch.request.json` is the output of the desktop's real + `build_launch_block` → `deploy_payload_json` path, not a shape derived by + reading those functions. Deriving it is how this fixture acquired four + impossible values at once: a `respond_to` that was a pubkey where the + desktop serializes a kebab-case `RespondTo` enum, allowlist and owner + values failing `validate_respond_to_allowlist`'s 64-hex rule + (`types.rs:897`), an invented `BUZZ_ACP_PARALLELISM` where the emitter + writes `BUZZ_ACP_AGENTS` (`runtime.rs:729`), and a `launch.env` key from + no layer of `resolve_effective_harness_descriptor`. +* **The provider cannot police this file, so the desktop must.** Every field + above is one this provider is deliberately indifferent to — `respond_to` is + an opaque `Option`, the allowlist an opaque `Vec`, + `policy_env` an arbitrary map — so `the_full_desktop_payload_is_accepted` + passes on invented data exactly as happily as on recorded data. The + enforcement is the desktop's whole-object equality test, which *builds* the + payload and compares it to this file. A completeness guard (the case-list + directory scan in `wire_fixtures.rs`) stops a case from going missing; it + cannot tell you a case is false. +* **Responses are byte-compared after key-sorted re-serialization**, so a + field rename or a type change fails here rather than in a desktop that + silently reads `undefined`. + +`deploy-*` fixtures cover only responses reachable without a cluster — +refusals and malformed input. A successful deploy needs an apiserver and is +covered by the conformance suite, not by a static fixture. diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json new file mode 100644 index 00000000000..28fe6ce90e1 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json @@ -0,0 +1,52 @@ +{ + "op": "deploy", + "request_id": "req-6", + "agent": { + "agent_args": [], + "agent_command": "goose", + "auth_tag": "tag-1", + "env_vars": { + "USER_KEY": "user-value" + }, + "idle_timeout_seconds": null, + "launch": { + "args": [ + "acp" + ], + "command": "goose", + "env": { + "GOOSE_MODEL": "gpt-5", + "GOOSE_PROVIDER": "openai", + "USER_KEY": "user-value" + }, + "owner_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "policy_env": { + "BUZZ_ACP_AGENTS": "10", + "BUZZ_ACP_LAZY_POOL": "true", + "BUZZ_ACP_MODEL": "gpt-5", + "BUZZ_ACP_RELAY_OBSERVER": "true", + "BUZZ_ACP_SESSION_TITLE": "worker", + "GOOSE_MODE": "auto" + } + }, + "max_turn_duration_seconds": null, + "model": "gpt-5", + "name": "worker", + "parallelism": 10, + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "provider": "openai", + "relay_url": "wss://relay.example", + "respond_to": "allowlist", + "respond_to_allowlist": [ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + ], + "system_prompt": null, + "turn_timeout_seconds": 300 + }, + "provider_config": { + "namespace": "buzz-agents-test", + "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "inactivity_seconds": 3600 + } +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json new file mode 100644 index 00000000000..4f56082bbbc --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.request.json @@ -0,0 +1,9 @@ +{ + "op": "deploy", + "request_id": "req-5", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json new file mode 100644 index 00000000000..c6011b899ab --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-no-owner.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: neither auth_tag nor launch.owner_pubkey resolved — without an owner the agent cannot honor !shutdown"} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json new file mode 100644 index 00000000000..2160909b8f8 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.request.json @@ -0,0 +1,11 @@ +{ + "op": "deploy", + "request_id": "req-3", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1", + "provider": " relay-mesh " + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json new file mode 100644 index 00000000000..0f0c995fd8c --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh-padded.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json new file mode 100644 index 00000000000..71ce9f067e0 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.request.json @@ -0,0 +1,12 @@ +{ + "op": "deploy", + "request_id": "req-2", + "agent": { + "name": "mesh-agent", + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1", + "provider": "relay-mesh" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json new file mode 100644 index 00000000000..0f0c995fd8c --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-relay-mesh.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"deploy refused: this agent is configured for shared compute (relay-mesh), which runs on the relay rather than in a pod. Switch the agent to a local runtime before deploying it to Kubernetes."} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json new file mode 100644 index 00000000000..232d9dcf436 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.request.json @@ -0,0 +1,10 @@ +{ + "op": "deploy", + "request_id": "req-4", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "tag-1" + }, + "provider_config": {"namespace": "buzz-agents-test", "image": "ghcr.io/block/buzz-sprig:latest"} +} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json new file mode 100644 index 00000000000..e67fa6a47fb --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-tag-image.response.json @@ -0,0 +1 @@ +{"ok":false,"error":"provider_config.image \"ghcr.io/block/buzz-sprig:latest\" is not digest-pinned: a tag is a mutable pointer, and this object runs with the agent's private key. Use name@sha256:<64 hex chars>"} diff --git a/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json new file mode 100644 index 00000000000..db99c86c00e --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/info.request.json @@ -0,0 +1 @@ +{"op":"info","request_id":"req-1"} diff --git a/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs new file mode 100644 index 00000000000..2c3af82b1f2 --- /dev/null +++ b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs @@ -0,0 +1,214 @@ +//! Golden wire fixtures (spec §Provider Protocol). +//! +//! These drive the **built binary** over a real pipe rather than calling an +//! in-process function: the contract the desktop depends on is +//! `stdin → one JSON object on stdout → exit code`, and an in-process test +//! would assert the shape of a value while skipping the three things that +//! actually break — the process writing nothing, writing two objects, or +//! signalling the outcome through the exit code. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +fn fixtures() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/provider-wire") +} + +/// Feed one request to the binary; return `(stdout, exit code)`. +fn run(request: &str) -> (String, i32) { + let mut child = Command::new(env!("CARGO_BIN_EXE_buzz-backend-kubernetes")) + // A kubeconfig that does not exist, so a fixture that accidentally + // reaches the cluster fails loudly here instead of depending on + // whatever cluster the developer is pointed at. + .env("KUBECONFIG", "/nonexistent/kubeconfig-for-fixture-tests") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("could not run the provider binary"); + child + .stdin + .take() + .expect("no stdin") + .write_all(request.as_bytes()) + .expect("could not write the request"); + let out = child.wait_with_output().expect("provider did not exit"); + ( + String::from_utf8(out.stdout).expect("stdout was not UTF-8"), + out.status.code().unwrap_or(-1), + ) +} + +fn read(name: &str) -> String { + std::fs::read_to_string(fixtures().join(name)) + .unwrap_or_else(|e| panic!("could not read fixture {name}: {e}")) +} + +/// Every response fixture, byte-compared after key-sorted re-serialization so +/// a field rename fails here rather than in a desktop reading `undefined`. +#[test] +fn responses_match_their_fixtures() { + let cases = [ + "deploy-relay-mesh", + "deploy-relay-mesh-padded", + "deploy-tag-image", + "deploy-no-owner", + ]; + // The list must cover every response fixture on disk. A literal array is + // never empty, so `!is_empty()` would assert nothing; what can actually go + // wrong is a fixture added to the directory and never added here, which + // reads as a passing suite that exercises one case fewer than it appears to. + let mut on_disk: Vec = std::fs::read_dir(fixtures()) + .expect("could not read the fixture directory") + .filter_map(|entry| entry.ok()?.file_name().into_string().ok()) + .filter_map(|name| Some(name.strip_suffix(".response.json")?.to_string())) + .collect(); + on_disk.sort(); + let mut listed: Vec = cases.iter().map(|c| c.to_string()).collect(); + listed.sort(); + assert_eq!(on_disk, listed, "response fixtures and cases disagree"); + + for case in cases { + let (stdout, code) = run(&read(&format!("{case}.request.json"))); + assert_eq!(code, 0, "{case}: a produced response must exit 0"); + + // Exactly one object, terminated by exactly one newline. Two responses + // would leave the desktop's reader holding a second one forever. + assert_eq!( + stdout.matches('\n').count(), + 1, + "{case}: expected exactly one line, got {stdout:?}" + ); + + let actual: serde_json::Value = + serde_json::from_str(&stdout).unwrap_or_else(|e| panic!("{case}: {e}: {stdout:?}")); + let expected: serde_json::Value = + serde_json::from_str(&read(&format!("{case}.response.json"))).unwrap(); + assert_eq!( + actual, expected, + "{case}: response drifted from its fixture" + ); + } +} + +/// `info` is checked on the fields the desktop reads rather than byte-for-byte: +/// the namespace default is randomly generated per call (§K8s Namespace), so a +/// golden copy of it would be a test that fails every run. +#[test] +fn info_response_carries_the_contract_fields() { + let (stdout, code) = run(&read("info.request.json")); + assert_eq!(code, 0); + let info: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(info["ok"], true); + assert_eq!(info["protocol_version"], 1); + assert_eq!(info["name"], "kubernetes"); + let schema = &info["config_schema"]; + assert_eq!( + schema["required"], + serde_json::json!(["namespace", "image"]) + ); + let default = schema["properties"]["namespace"]["default"] + .as_str() + .expect("no generated namespace default"); + assert!( + default.starts_with("buzz-agents-"), + "unexpected namespace default: {default}" + ); +} + +/// The desktop's richest payload must parse. No response fixture: this one +/// reaches the cluster, so its outcome depends on a kubeconfig. What it +/// guards is that every field the desktop sends is *accepted* — a payload the +/// provider rejects at parse time is a deploy that never starts. +#[test] +fn the_full_desktop_payload_is_accepted() { + let (stdout, code) = run(&read("deploy-full-launch.request.json")); + assert_eq!(code, 0); + let response: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let error = response["error"].as_str().unwrap_or_default(); + // It fails — there is no cluster — but it must fail at the *connection*, + // having accepted every field above it. + assert!( + error.contains("kubeconfig"), + "the full payload was rejected before reaching the cluster: {error}" + ); +} + +/// Sami's pre-registered respond-to matrix, driven through the built binary. +/// +/// `build_env` runs at `main.rs:124`, `client::connect` at `:132`, so under a +/// kubeconfig that cannot exist the error string *is* the ordering assertion: +/// "kubeconfig" means the gate passed and we reached the cluster, anything +/// else means we refused before writing a Secret. A test asserting only +/// `ok: false` would pass on the connection error and prove nothing. +/// +/// The cases are applied to the real full-launch request so each one differs +/// from a known-good deploy in exactly the field under test. +#[test] +fn the_respond_to_gate_matches_the_harness_acceptance_surface() { + let key_a = "a".repeat(64); + let padded_upper = format!(" {} ", "A".repeat(64)); + // (name, respond_to, allowlist, must reach the cluster) + let cases: Vec<(&str, &str, Option>, bool)> = vec![ + ("allowlist + []", "allowlist", Some(vec![]), false), + ("allowlist + absent", "allowlist", None, false), + ( + "allowlist + junk", + "allowlist", + Some(vec!["beefcafe".into()]), + false, + ), + ("unparseable mode", "npub1abc", None, false), + ("padded mode", " allowlist ", None, false), + ( + "allowlist + two valid", + "allowlist", + Some(vec![key_a.clone(), "b".repeat(64)]), + true, + ), + ( + "owner-only + junk list", + "owner-only", + Some(vec!["beefcafe".into()]), + true, + ), + ( + "allowlist + padded upper", + "allowlist", + Some(vec![padded_upper]), + true, + ), + ("nobody", "nobody", None, true), + ("anyone", "anyone", None, true), + ]; + + let base: serde_json::Value = + serde_json::from_str(&read("deploy-full-launch.request.json")).unwrap(); + + for (name, mode, allowlist, reaches_cluster) in cases { + let mut request = base.clone(); + let agent = &mut request["agent"]; + agent["respond_to"] = serde_json::json!(mode); + agent["respond_to_allowlist"] = match &allowlist { + Some(list) => serde_json::json!(list), + None => serde_json::Value::Null, + }; + + let (stdout, code) = run(&request.to_string()); + assert_eq!(code, 0, "{name}: provider did not exit cleanly"); + let response: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + let error = response["error"].as_str().unwrap_or_default(); + let reached = error.contains("kubeconfig"); + + assert_eq!( + reached, reaches_cluster, + "{name}: expected reaches_cluster={reaches_cluster}, got error: {error}" + ); + if !reaches_cluster { + assert!( + error.contains("deploy refused"), + "{name}: refused, but not by the gate: {error}" + ); + } + } +} diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs index 389d18aec5d..d1cd8181eb9 100644 --- a/desktop/scripts/build-release-config.mjs +++ b/desktop/scripts/build-release-config.mjs @@ -52,6 +52,15 @@ const releaseConfig = { }, }; +// Tauri applies --config after platform-specific config using RFC 7396. +// Any externalBin value here would therefore replace the platform sidecar list, +// while null would silently delete it. This delta must never own that key. +if (Object.hasOwn(releaseConfig.bundle, "externalBin")) { + throw new Error( + "Release config must not define bundle.externalBin; sidecars are platform-specific", + ); +} + console.log(`Updater enabled -> ${updaterEndpoint}`); writeFileSync(outputConfigPath, `${JSON.stringify(releaseConfig, null, 2)}\n`); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 00d3fba3b59..cf024b22847 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1106,6 +1106,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-window-state", + "tauri-utils", "tempfile", "tokio", "tokio-tungstenite 0.29.0", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b80684f9554..0b556347761 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -138,6 +138,7 @@ strip-ansi-escapes = "0.2" tracing = "0.1" [dev-dependencies] +tauri-utils = "2" # `test-util` enables tokio's paused-clock (`start_paused`) so the relay # admission gate tests can assert exact wait durations without real sleeps. tokio = { version = "1", features = ["test-util"] } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 0758fc3aac5..3b114b0474f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1362,7 +1362,7 @@ use deploy::build_deploy_payload; #[cfg(test)] use deploy::deploy_payload_json; #[cfg(test)] -pub(crate) use deploy::resolve_deploy_model_provider; +use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; #[path = "agents_profile.rs"] mod profile; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index af785711d57..9bb0f6230dc 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -1,6 +1,8 @@ //! Provider deploy payload construction, split from `agents.rs` (file-size -//! guard). `build_deploy_payload` gathers live state; `deploy_payload_json` -//! is the pure serialization half so payload completeness stays testable. +//! guard). The launch block is derived from the same effective descriptor and +//! policy helpers as local spawn so remote execution does not reimplement them. + +use std::collections::BTreeMap; use tauri::AppHandle; @@ -13,17 +15,6 @@ use crate::{ }; /// Resolve the deploy-specific structured model/provider for a managed agent. -/// -/// Delegates to the single effective-config resolver which enforces -/// definition-authoritative semantics for linked instances: -/// - **Linked:** definition → global. Stale record bytes are never consulted. -/// - **Definition-less:** instance → global. -/// - **Orphaned:** returns `(None, None)` — spawn is blocked elsewhere. -/// -/// Both local spawn and deploy now use the same resolver, so they can never -/// disagree on what model/provider an agent runs with. -/// -/// Exported `pub(crate)` for unit testing. #[cfg(test)] pub(crate) fn resolve_deploy_model_provider( record: &ManagedAgentRecord, @@ -36,58 +27,116 @@ pub(crate) fn resolve_deploy_model_provider( .unwrap_or((None, None)) } -/// Build the standard agent JSON payload for provider deploy calls. -/// -/// Like local spawn, provider deploy re-reads live persona env vars and -/// structured model/provider so remote agents receive current credentials -/// and the same authoritative values that local spawn derives from -/// `runtime_metadata_env_vars`. The only field still pinned is -/// `agent_command`/`agent_args` — those were captured at create time. -/// The only read-time resolution is `relay_url`: a blank pin resolves to -/// the active workspace relay here, matching the create-path contract. +/// Serialize the portable launch contract shared with provider-backed agents. /// -/// Fails closed when the private key is unavailable (keyring outage leaves -/// it empty after hydration): without this guard a provider deploy would -/// serialize `"private_key_nsec": ""` and launch the agent with no -/// identity — the same hazard the local spawn path refuses via -/// `spawn_key_refusal`. +/// `descriptor.env` is the authoritative six-layer environment. Policy values +/// are deliberately separate because providers apply them below that layered +/// environment, preserving the local spawn's power-user override semantics. +pub(super) fn build_launch_block( + record: &ManagedAgentRecord, + descriptor: &crate::managed_agents::readiness::EffectiveHarnessDescriptor, + teams: &[crate::managed_agents::TeamRecord], + effective_prompt: Option<&str>, + effective_model: Option<&str>, + owner_pubkey: &str, +) -> serde_json::Value { + use crate::managed_agents::{known_acp_runtime, resolve_session_title, SESSION_TITLE_ENV_VAR}; + + let runtime = known_acp_runtime(&descriptor.command); + let mut policy_env = BTreeMap::new(); + + if let Some(runtime) = runtime { + policy_env.extend( + runtime + .default_env + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())), + ); + if runtime.mcp_hooks { + policy_env.insert("MCP_HOOK_SERVERS".into(), "*".into()); + } + } + policy_env.insert("BUZZ_ACP_RELAY_OBSERVER".into(), "true".into()); + policy_env.insert("BUZZ_ACP_LAZY_POOL".into(), "true".into()); + policy_env.insert("BUZZ_ACP_AGENTS".into(), record.parallelism.to_string()); + + if let Some(value) = effective_prompt { + policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); + } + if let Some(value) = effective_model { + policy_env.insert("BUZZ_ACP_MODEL".into(), value.to_string()); + } + if let Some(value) = record.idle_timeout_seconds { + policy_env.insert("BUZZ_ACP_IDLE_TIMEOUT".into(), value.to_string()); + } + if let Some(value) = record.max_turn_duration_seconds { + policy_env.insert("BUZZ_ACP_MAX_TURN_DURATION".into(), value.to_string()); + } + if let Some(value) = resolve_session_title(record.display_name.as_deref(), &record.name) { + policy_env.insert(SESSION_TITLE_ENV_VAR.into(), value); + } + if let Some(value) = + crate::managed_agents::spawn_hash::effective_team_instructions(record, teams) + { + policy_env.insert("BUZZ_ACP_TEAM_INSTRUCTIONS".into(), value); + } + + serde_json::json!({ + "command": descriptor.command, + "args": descriptor.args, + "env": descriptor.env, + "policy_env": policy_env, + "owner_pubkey": owner_pubkey, + }) +} + +pub(super) fn ensure_remote_provider_supported(provider: Option<&str>) -> Result<(), String> { + if provider.map(str::trim) == Some(crate::managed_agents::RELAY_MESH_PROVIDER_ID) { + return Err( + "shared-compute agents cannot be deployed remotely because the mesh endpoint is local to the desktop" + .to_string(), + ); + } + Ok(()) +} + +/// Build the standard agent JSON payload for provider deploy calls. pub(super) fn build_deploy_payload( app: &AppHandle, state: &AppState, record: &ManagedAgentRecord, ) -> Result { - // Fails closed when the private key is unavailable — same guard as local - // spawn. Without this, a keyring outage would serialize `"private_key_nsec": ""` - // and launch the agent with no identity. if let Some(err) = crate::managed_agents::spawn_key_refusal(record) { return Err(err); } - // Merge global + persona + agent env_vars for provider deploy — the same - // live-persona-under-overrides semantics as local spawn. Global env vars - // are the lowest user-settable layer: global < persona < agent (last-wins - // on key collision). Without this, provider-backed agents wouldn't receive - // credentials saved on the persona or the agent itself. - let global_config = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); - let global_env = global_config.env_vars.clone(); - let persona_env = - crate::managed_agents::resolve_persona_env(app, record.persona_id.as_deref())?; - // Merge: global < persona (persona wins over global). - let global_persona_merged = crate::managed_agents::merged_user_env(&global_env, &persona_env); - // Merge: global+persona < agent (agent wins over everything). - let merged_env = - crate::managed_agents::merged_user_env(&global_persona_merged, &record.env_vars); - + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let personas = load_personas(app).unwrap_or_default(); - let cfg = crate::managed_agents::effective_config::resolve_effective_config( - record, - &personas, - &global_config, + let teams = crate::managed_agents::load_teams(app).unwrap_or_default(); + let persona_env = + crate::managed_agents::live_persona_env(&personas, record.persona_id.as_deref()); + let global_persona_env = crate::managed_agents::merged_user_env(&global.env_vars, &persona_env); + let merged_user_env = + crate::managed_agents::merged_user_env(&global_persona_env, &record.env_vars); + let effective = crate::managed_agents::effective_config::resolve_effective_config( + record, &personas, &global, ) .require_resolved()?; - let effective_model = cfg.model.value; - let effective_provider = cfg.provider.value; - let effective_prompt = cfg.system_prompt.value; + + ensure_remote_provider_supported(effective.provider.value.as_deref())?; + + let descriptor = + crate::managed_agents::resolve_effective_harness_descriptor(record, &personas, &global) + .map_err(|error| crate::managed_agents::user_facing_harness_error(&error))?; + let owner_pubkey = super::workspace_owner_hex(state)?; + let launch = build_launch_block( + record, + &descriptor, + &teams, + effective.system_prompt.value.as_deref(), + effective.model.value.as_deref(), + &owner_pubkey, + ); Ok(deploy_payload_json( record, @@ -95,23 +144,24 @@ pub(super) fn build_deploy_payload( &record.relay_url, &relay_ws_url_with_override(state), ), - effective_model, - effective_provider, - effective_prompt, - merged_env, + effective.model.value, + effective.provider.value, + effective.system_prompt.value, + merged_user_env, + launch, )) } -/// Pure serialization half of [`build_deploy_payload`] — every field the -/// provider harness receives is deliberately listed here, so payload -/// completeness is testable without an `AppHandle`. +/// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields +/// remain for display/bookkeeping; providers execute the resolved `launch` block. pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, effective_model: Option, effective_provider: Option, effective_prompt: Option, - merged_env: std::collections::BTreeMap, + merged_env: BTreeMap, + launch: serde_json::Value, ) -> serde_json::Value { serde_json::json!({ "name": &record.name, @@ -130,5 +180,81 @@ pub(super) fn deploy_payload_json( "respond_to": record.respond_to, "respond_to_allowlist": &record.respond_to_allowlist, "env_vars": merged_env, + "launch": launch, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::{readiness::EffectiveHarnessDescriptor, RespondTo, TeamRecord}; + + fn record() -> ManagedAgentRecord { + serde_json::from_value(serde_json::json!({ + "pubkey": "abcd1234", + "name": "agent-handle", + "display_name": "Agent\u{0000} Name", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://relay.example", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "idle_timeout_seconds": 17, + "max_turn_duration_seconds": 23, + "parallelism": 4, + "respond_to": RespondTo::OwnerOnly, + "respond_to_allowlist": [], + "team_id": "team-1", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .unwrap() + } + + #[test] + fn launch_block_preserves_descriptor_and_spawn_policy() { + let record = record(); + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec!["acp".into()], + env: BTreeMap::from([ + ("GOOSE_MODE".into(), "custom".into()), + ("SECRET_FROM_PERSONA".into(), "secret".into()), + ]), + }; + let teams: Vec = serde_json::from_value(serde_json::json!([{ + "id": "team-1", "name": "Team", "instructions": "Coordinate", "persona_ids": [], "created_at": "2026-01-01T00:00:00Z", "updated_at": "2026-01-01T00:00:00Z" + }])).unwrap(); + + let launch = build_launch_block( + &record, + &descriptor, + &teams, + Some("prompt"), + Some("model"), + "owner-hex", + ); + + assert_eq!(launch["command"], "goose"); + assert_eq!(launch["args"], serde_json::json!(["acp"])); + assert_eq!(launch["env"]["GOOSE_MODE"], "custom"); + // policy_env is applied first, so this default remains separate from + // the descriptor value that wins in launch.env. + assert_eq!(launch["policy_env"]["GOOSE_MODE"], "auto"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_LAZY_POOL"], "true"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_RELAY_OBSERVER"], "true"); + assert_eq!( + launch["policy_env"]["BUZZ_ACP_TEAM_INSTRUCTIONS"], + "Coordinate" + ); + assert_eq!(launch["policy_env"]["BUZZ_ACP_SESSION_TITLE"], "Agent Name"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_SYSTEM_PROMPT"], "prompt"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_MODEL"], "model"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_IDLE_TIMEOUT"], "17"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_MAX_TURN_DURATION"], "23"); + assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); + assert_eq!(launch["owner_pubkey"], "owner-hex"); + } +} diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 03389d1d18b..df135298c4f 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -263,6 +263,19 @@ fn normalize_relay_mesh_trims_and_preserves_valid_config() { ); } +#[test] +fn deploy_refuses_resolved_relay_mesh_provider_with_padding() { + let record = bare_agent_record(Some("p1"), None, None); + let personas = vec![persona_record("p1", None, Some(" relay-mesh "))]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let (_, provider) = resolve_deploy_model_provider(&record, &personas, &global); + let error = ensure_remote_provider_supported(provider.as_deref()) + .expect_err("resolved shared-compute provider must not deploy remotely"); + + assert!(error.contains("cannot be deployed remotely"), "{error}"); +} + #[test] fn created_avatar_prefers_explicit_input() { let resolved = resolve_created_avatar_url( @@ -398,50 +411,93 @@ fn legacy_avatar_empty_when_nothing_resolves() { // ── Provider deploy payload completeness ───────────────────────────────────── -/// Regression (PR #1667 review, Thufir): the provider deploy payload must -/// carry every behavioral field the local spawn path applies — a field -/// missing here silently strips it from provider-backed agents. +/// The shared provider fixture is the contract arbiter: it must be the exact +/// richest deploy request produced by the real desktop serializers. #[test] -fn deploy_payload_carries_the_full_behavioral_quad() { - let allow = "a".repeat(64); - let record: ManagedAgentRecord = serde_json::from_str(&format!( - r#"{{ - "pubkey": "abcd1234", - "name": "test-agent", - "private_key_nsec": "nsec1fake", - "relay_url": "wss://localhost:3000", - "acp_command": "buzz-acp", - "agent_command": "goose", - "agent_args": [], - "mcp_command": "", - "turn_timeout_seconds": 320, - "system_prompt": null, - "parallelism": 4, - "respond_to": "allowlist", - "respond_to_allowlist": ["{allow}"], - "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 record"); - - let payload = deploy_payload_json( +fn deploy_payload_matches_the_shared_full_launch_fixture() { + let fixture_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join( + "../../crates/buzz-backend-kubernetes/tests/fixtures/provider-wire/deploy-full-launch.request.json", + ); + let fixture: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(&fixture_path) + .unwrap_or_else(|error| panic!("read {}: {error}", fixture_path.display())), + ) + .expect("parse shared provider fixture"); + let record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "abcd1234", + "name": "worker", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "relay_url": "wss://localhost:3000", + "auth_tag": "tag-1", + "acp_command": "buzz-acp", + "agent_command": "goose", + "runtime": "goose", + "model": "gpt-5", + "provider": "openai", + "env_vars": {"USER_KEY": "user-value"}, + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 300, + "system_prompt": null, + "idle_timeout_seconds": null, + "max_turn_duration_seconds": null, + "parallelism": 10, + "respond_to": "allowlist", + "respond_to_allowlist": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"], + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + })) + .expect("fixture source record"); + let descriptor = crate::managed_agents::resolve_effective_harness_descriptor( + &record, + &[], + &crate::managed_agents::GlobalAgentConfig::default(), + ) + .expect("resolve fixture source record descriptor"); + let launch = super::deploy::build_launch_block( &record, - "wss://relay.example".to_string(), - Some("gpt-x".to_string()), - Some("openai".to_string()), + &descriptor, + &[], None, - std::collections::BTreeMap::new(), + Some("gpt-5"), + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ); + let agent = deploy_payload_json( + &record, + "wss://relay.example".into(), + Some("gpt-5".into()), + Some("openai".into()), + None, + std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]), + launch, ); - assert_eq!(payload["parallelism"], 4); - assert_eq!(payload["respond_to"], "allowlist"); - assert_eq!(payload["respond_to_allowlist"][0], "a".repeat(64)); - assert_eq!(payload["model"], "gpt-x"); - assert_eq!(payload["provider"], "openai"); - assert_eq!(payload["relay_url"], "wss://relay.example"); + assert_eq!( + agent, fixture["agent"], + "desktop payload drifted from the shared provider fixture" + ); +} + +#[test] +fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() { + use tauri_utils::{config::parse::read_from, platform::Target}; + + let config_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + for (target, expected) in [ + (Target::MacOS, true), + (Target::Linux, true), + (Target::Windows, false), + ] { + let (config, paths) = read_from(target, config_root).expect("read Tauri config"); + let external_bins = config["bundle"]["externalBin"] + .as_array() + .expect("bundle.externalBin array"); + let has_kubernetes = external_bins + .iter() + .any(|value| value == "binaries/buzz-backend-kubernetes"); + assert_eq!( + has_kubernetes, expected, + "unexpected Kubernetes externalBin for {target}; merged {paths:?}" + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/backend.rs b/desktop/src-tauri/src/managed_agents/backend.rs index 5debae41cbf..84dd7e99da4 100644 --- a/desktop/src-tauri/src/managed_agents/backend.rs +++ b/desktop/src-tauri/src/managed_agents/backend.rs @@ -1,3 +1,4 @@ +use sha2::{Digest, Sha256}; use std::io::{BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::sync::mpsc; @@ -7,6 +8,61 @@ const STDERR_CAP: usize = 65536; /// Provider responses should be small JSON objects. Cap stdout to prevent a /// buggy or malicious provider from OOM-ing the desktop process. const STDOUT_CAP: usize = 1_048_576; // 1 MB +const PROVIDER_PROTOCOL_VERSION: u64 = 1; + +fn validate_provider_info(info: &serde_json::Value) -> Result<(), String> { + let object = info + .as_object() + .ok_or_else(|| "provider info response must be a JSON object".to_string())?; + let actual_version = object + .get("protocol_version") + .and_then(serde_json::Value::as_u64); + if actual_version != Some(PROVIDER_PROTOCOL_VERSION) { + return Err(match actual_version { + Some(version) => format!( + "unsupported provider protocol version {version}; desktop requires {PROVIDER_PROTOCOL_VERSION}" + ), + None => "provider info response missing integer protocol_version".to_string(), + }); + } + if object.get("ok") != Some(&serde_json::Value::Bool(true)) { + return Err("provider info response must contain ok: true".to_string()); + } + for field in ["name", "version", "description"] { + if object + .get(field) + .is_none_or(|value| value.as_str().is_none_or(str::is_empty)) + { + return Err(format!( + "provider info response missing non-empty string {field}" + )); + } + } + if !object + .get("config_schema") + .is_some_and(serde_json::Value::is_object) + { + return Err("provider info response missing object config_schema".to_string()); + } + + const FIELDS: &[&str] = &[ + "ok", + "name", + "version", + "protocol_version", + "description", + "config_schema", + ]; + if let Some(field) = object + .keys() + .find(|field| !FIELDS.contains(&field.as_str())) + { + return Err(format!( + "provider info response contains unknown field {field}" + )); + } + Ok(()) +} /// Invoke a provider binary: write JSON to stdin, read JSON from stdout. /// @@ -333,23 +389,29 @@ pub(crate) fn redact_secrets_with(s: &str, extras: &[&str]) -> String { result } -/// Collect string values from `request["agent"]["env_vars"]` (if present) -/// to feed into [`redact_secrets_with`]. Returns an empty Vec if the -/// request shape doesn't match, which is fine — falls back to the default -/// prefix-based scrubbing. +/// Collect string values from every environment map a deploy request can +/// carry. Providers may echo any of these values in diagnostics, including +/// definition/baked values that exist only in the resolved launch block. fn env_secrets_from_request(request: &serde_json::Value) -> Vec { - request - .get("agent") - .and_then(|a| a.get("env_vars")) - .and_then(|e| e.as_object()) - .map(|obj| { - obj.values() - .filter_map(|v| v.as_str()) - .filter(|s| !s.is_empty()) - .map(String::from) - .collect() - }) - .unwrap_or_default() + let agent = request.get("agent"); + let maps = [ + agent.and_then(|value| value.get("env_vars")), + agent + .and_then(|value| value.get("launch")) + .and_then(|value| value.get("env")), + agent + .and_then(|value| value.get("launch")) + .and_then(|value| value.get("policy_env")), + ]; + + maps.into_iter() + .flatten() + .filter_map(serde_json::Value::as_object) + .flat_map(|map| map.values()) + .filter_map(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(String::from) + .collect() } /// Public-in-crate helper: redact every non-empty value from `env` (plus @@ -368,22 +430,102 @@ pub(crate) fn redact_env_values_in( redact_secrets_with(s, &values) } -/// Deploy an agent via provider binary. Returns the provider-assigned agent_id. -/// -/// `request_id` is included for provider-side logging/correlation but is not -/// validated in the response — the stdin→stdout exchange is 1:1 per process. +/// Copy a resolved provider into a private staging directory while hashing +/// exactly the bytes copied. The staged file becomes non-writable before either +/// invocation, closing the path replacement and in-place rewrite races. +fn stage_provider( + binary: &Path, +) -> Result<(tempfile::TempDir, PathBuf, String, std::fs::File), String> { + let directory = tempfile::Builder::new() + .prefix("buzz-provider-") + .tempdir() + .map_err(|error| format!("failed to create provider staging directory: {error}"))?; + let suffix = if cfg!(windows) { ".exe" } else { "" }; + let staged_path = directory.path().join(format!("provider{suffix}")); + let mut source = std::fs::File::open(binary) + .map_err(|error| format!("failed to open provider for staging: {error}"))?; + let mut staged = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged_path) + .map_err(|error| format!("failed to create staged provider: {error}"))?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = source + .read(&mut buffer) + .map_err(|error| format!("failed to read provider for staging: {error}"))?; + if count == 0 { + break; + } + staged + .write_all(&buffer[..count]) + .map_err(|error| format!("failed to write staged provider: {error}"))?; + hasher.update(&buffer[..count]); + } + staged + .sync_all() + .map_err(|error| format!("failed to sync staged provider: {error}"))?; + + let mut permissions = staged + .metadata() + .map_err(|error| format!("failed to inspect staged provider: {error}"))? + .permissions(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o500); + } + #[cfg(not(unix))] + permissions.set_readonly(true); + std::fs::set_permissions(&staged_path, permissions) + .map_err(|error| format!("failed to protect staged provider: {error}"))?; + drop(staged); + + #[cfg(windows)] + let execution_guard = { + use std::os::windows::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + // Permit CreateProcess to read the image while denying replacement, + // writes, and deletion until both invocations finish. + .share_mode(windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ) + .open(&staged_path) + }; + #[cfg(not(windows))] + let execution_guard = std::fs::File::open(&staged_path); + let execution_guard = execution_guard + .map_err(|error| format!("failed to lock staged provider for execution: {error}"))?; + Ok(( + directory, + staged_path, + hex::encode(hasher.finalize()), + execution_guard, + )) +} + +/// Deploy through one immutable staged copy: negotiate protocol v1 before the +/// secret-bearing request, then invoke deploy on those exact same bytes. pub fn provider_deploy( binary: &Path, agent: &serde_json::Value, provider_config: &serde_json::Value, ) -> Result { + let (_directory, staged, _digest, _execution_guard) = stage_provider(binary)?; + let info_request = serde_json::json!({ + "op": "info", + "request_id": uuid::Uuid::new_v4().to_string(), + }); + let info = invoke_provider(&staged, &info_request, Duration::from_secs(10))?; + validate_provider_info(&info)?; + let request = serde_json::json!({ "op": "deploy", "request_id": uuid::Uuid::new_v4().to_string(), "agent": agent, "provider_config": provider_config, }); - let resp = invoke_provider(binary, &request, Duration::from_secs(600))?; + let resp = invoke_provider(&staged, &request, Duration::from_secs(600))?; resp["agent_id"] .as_str() .map(String::from) @@ -423,6 +565,24 @@ pub fn validate_provider_config(config: &serde_json::Value) -> Result<(), String Ok(()) } +/// Derive a provider id from the filename Tauri stages at runtime. Tauri +/// removes its target-triple suffix while copying an external binary, but on +/// Windows leaves the executable/script extension, which is not part of the +/// provider id. +fn provider_id_from_filename(name: &str) -> Option<&str> { + let raw = name.strip_prefix("buzz-backend-")?; + let id = [".exe", ".bat", ".cmd"] + .into_iter() + .find_map(|extension| { + raw.get(raw.len().saturating_sub(extension.len())..) + .filter(|suffix| suffix.eq_ignore_ascii_case(extension)) + .map(|_| &raw[..raw.len() - extension.len()]) + }) + .unwrap_or(raw); + + (!id.is_empty()).then_some(id) +} + /// Enumerate PATH for buzz-backend-* executables. Returns (id, path) pairs. /// Only includes files that are executable. Does NOT execute any binaries. /// @@ -464,10 +624,12 @@ pub fn discover_provider_candidates() -> Vec<(String, PathBuf)> { }; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); - if let Some(id) = name.strip_prefix(prefix) { - if !id.is_empty() && !seen.contains(&name) && is_executable(&entry.path()) { - seen.insert(name.clone()); - results.push((id.to_string(), entry.path())); + if name.starts_with(prefix) { + if let Some(id) = provider_id_from_filename(&name) { + if !seen.contains(&name) && is_executable(&entry.path()) { + seen.insert(name.clone()); + results.push((id.to_string(), entry.path())); + } } } } @@ -538,203 +700,5 @@ pub struct BackendProviderInfo { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn redact_secrets_replaces_nsec() { - let s = "key=nsec1abc123def456 other"; - let r = redact_secrets(s); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains("nsec1abc123def456")); - } - - #[test] - fn redact_secrets_replaces_token() { - let s = r#"{"token":"sprt_tok_xyz789"}"#; - let r = redact_secrets(s); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains("sprt_tok_xyz789")); - } - - #[test] - fn redact_secrets_with_extras_scrubs_user_env_values() { - // If a provider echoes back a user-supplied API key in its error - // output, the desktop must not surface that secret unredacted via - // `last_error`. We scrub the literal values that came from the - // request's `agent.env_vars`. - let secret = "sk-ant-api03-abc123def456"; - let stderr = format!("auth failed with key {secret} on host api.anthropic.com"); - let r = redact_secrets_with(&stderr, &[secret]); - assert!(r.contains("[REDACTED]")); - assert!(!r.contains(secret)); - } - - #[test] - fn redact_secrets_with_extras_skips_short_values() { - // Don't scrub values shorter than 4 chars — too noisy. - let r = redact_secrets_with("error code: 42", &["42"]); - assert!(r.contains("42")); - } - - /// GitHub tokens are recognised by shape, so one that never passed through - /// our environment — embedded in a remote URL an installer echoes — is - /// still scrubbed. The scan runs to the next whitespace or quote, so the - /// rest of the URL goes with it; over-redaction is the safe direction. - #[test] - fn redact_secrets_with_scrubs_github_token_prefixes() { - for token in [ - "ghp_abcdefghij0123456789", - "gho_abcdefghij0123456789", - "ghu_abcdefghij0123456789", - "ghs_abcdefghij0123456789", - "ghr_abcdefghij0123456789", - "github_pat_abcdefghij0123456789", - ] { - let r = - redact_secrets_with(&format!("cloning https://{token}@github.com/o/r now"), &[]); - assert!(!r.contains(token), "leaked {token}: {r}"); - assert!(r.contains("[REDACTED]"), "got: {r}"); - assert!(r.contains("cloning"), "scan must stop at whitespace: {r}"); - assert!(r.ends_with(" now"), "scan must stop at whitespace: {r}"); - } - } - - #[test] - fn redact_secrets_with_extras_terminates_when_value_substring_of_marker() { - // Regression: an earlier impl used `while let Some(pos) = find(value)` - // which never terminates if the user's env value is a substring of - // the replacement marker `[REDACTED]` — each replacement - // reintroduces the same text. Now uses `str::replace` (single-pass). - for value in ["REDACTED", "EDACTE", "REDA", "ACTED"] { - let r = redact_secrets_with(&format!("leak={value}"), &[value]); - assert!(r.contains("[REDACTED]")); - } - } - - #[test] - fn redact_secrets_with_extras_handles_overlapping_secrets() { - // Longer entries get scrubbed first so the substring "abc12" isn't - // matched before "abc123" is consumed. - let s = "key1=abc123 key2=abc12"; - let r = redact_secrets_with(s, &["abc12", "abc123"]); - assert!(!r.contains("abc123")); - assert!(!r.contains("abc12 ")); - } - - #[test] - fn env_secrets_from_request_extracts_string_values() { - let req = serde_json::json!({ - "op": "deploy", - "agent": { - "env_vars": { - "ANTHROPIC_API_KEY": "sk-ant-test", - "EMPTY": "", - "NUMERIC": 42, - }, - }, - }); - let secrets = env_secrets_from_request(&req); - assert!(secrets.iter().any(|v| v == "sk-ant-test")); - // Empty and non-string values are filtered out. - assert_eq!(secrets.len(), 1); - } - - #[test] - fn env_secrets_from_request_handles_missing_shape() { - assert!(env_secrets_from_request(&serde_json::json!({})).is_empty()); - assert!(env_secrets_from_request(&serde_json::json!({"agent": {}})).is_empty()); - assert!( - env_secrets_from_request(&serde_json::json!({"agent": {"env_vars": null}})).is_empty() - ); - } - - #[test] - fn redact_env_values_in_scrubs_map_values() { - let mut env = std::collections::BTreeMap::new(); - env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-real".to_string()); - env.insert("EMPTY".to_string(), String::new()); - let stderr = "auth=sk-ant-real failed; other context"; - let r = redact_env_values_in(stderr, &env); - assert!(!r.contains("sk-ant-real")); - assert!(r.contains("[REDACTED]")); - } - - #[test] - fn validate_provider_config_rejects_secret_key() { - let cfg = serde_json::json!({"api_key": "val"}); - assert!(validate_provider_config(&cfg).is_err()); - } - - #[test] - fn validate_provider_config_rejects_nested() { - let cfg = serde_json::json!({"region": {"us": "east"}}); - assert!(validate_provider_config(&cfg).is_err()); - } - - #[test] - fn validate_provider_config_accepts_scalars() { - let cfg = serde_json::json!({"region": "us-east-1", "tier": "standard"}); - assert!(validate_provider_config(&cfg).is_ok()); - } - - #[test] - fn validate_provider_config_allows_key_as_substring() { - // "keyboard", "monkey" contain "key" as substring but not as a word segment. - let cfg = serde_json::json!({"keyboard_layout": "us", "monkey_wrench": "tight"}); - assert!(validate_provider_config(&cfg).is_ok()); - } - - #[test] - fn validate_provider_config_rejects_camel_case_secrets() { - assert!(validate_provider_config(&serde_json::json!({"apiKey": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"accessToken": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"clientSecret": "val"})).is_err()); - // ALL-CAPS variants - assert!(validate_provider_config(&serde_json::json!({"apiKEY": "val"})).is_err()); - assert!(validate_provider_config(&serde_json::json!({"accessTOKEN": "val"})).is_err()); - } - - #[test] - fn split_config_key_handles_all_styles() { - assert_eq!(split_config_key("apiKey"), vec!["api", "key"]); - assert_eq!(split_config_key("access_token"), vec!["access", "token"]); - assert_eq!(split_config_key("keyboard"), vec!["keyboard"]); - assert_eq!(split_config_key("client-secret"), vec!["client", "secret"]); - // Acronym runs stay together - assert_eq!(split_config_key("APIKey"), vec!["api", "key"]); - assert_eq!(split_config_key("apiKEY"), vec!["api", "key"]); - assert_eq!(split_config_key("accessTOKEN"), vec!["access", "token"]); - assert_eq!(split_config_key("MyAPIKey"), vec!["my", "api", "key"]); - } - - #[test] - fn resolve_provider_binary_rejects_invalid_ids() { - // Path traversal - assert!(resolve_provider_binary("../evil").is_err()); - // Empty - assert!(resolve_provider_binary("").is_err()); - // Uppercase - assert!(resolve_provider_binary("MyProvider").is_err()); - // Spaces - assert!(resolve_provider_binary("my provider").is_err()); - // Shell metacharacters - assert!(resolve_provider_binary("foo;rm -rf /").is_err()); - // Valid format but not on PATH — should fail with "not found" - assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); - } - - #[test] - fn resolve_provider_binary_accepts_valid_id_format() { - // Valid ID format should pass validation. If the binary happens to - // exist on PATH, Ok is returned; otherwise Err contains "not found" - // (not "invalid provider ID"). Either outcome proves validation passed. - match resolve_provider_binary("zzz-nonexistent-test-provider") { - Ok(_) => {} // unlikely but fine — binary exists - Err(e) => assert!( - e.contains("not found"), - "expected 'not found' error, got: {e}" - ), - } - } -} +#[path = "backend_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/backend_tests.rs b/desktop/src-tauri/src/managed_agents/backend_tests.rs new file mode 100644 index 00000000000..ce1f81466fc --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/backend_tests.rs @@ -0,0 +1,452 @@ +use super::*; + +#[test] +fn redact_secrets_replaces_nsec() { + let s = "key=nsec1abc123def456 other"; + let r = redact_secrets(s); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains("nsec1abc123def456")); +} + +#[test] +fn redact_secrets_replaces_token() { + let s = r#"{"token":"sprt_tok_xyz789"}"#; + let r = redact_secrets(s); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains("sprt_tok_xyz789")); +} + +#[test] +fn redact_secrets_with_extras_scrubs_user_env_values() { + // If a provider echoes back a user-supplied API key in its error + // output, the desktop must not surface that secret unredacted via + // `last_error`. We scrub the literal values that came from the + // request's `agent.env_vars`. + let secret = "sk-ant-api03-abc123def456"; + let stderr = format!("auth failed with key {secret} on host api.anthropic.com"); + let r = redact_secrets_with(&stderr, &[secret]); + assert!(r.contains("[REDACTED]")); + assert!(!r.contains(secret)); +} + +#[test] +fn redact_secrets_with_extras_skips_short_values() { + // Don't scrub values shorter than 4 chars — too noisy. + let r = redact_secrets_with("error code: 42", &["42"]); + assert!(r.contains("42")); +} + +/// GitHub tokens are recognised by shape, so one that never passed through +/// our environment — embedded in a remote URL an installer echoes — is +/// still scrubbed. The scan runs to the next whitespace or quote, so the +/// rest of the URL goes with it; over-redaction is the safe direction. +#[test] +fn redact_secrets_with_scrubs_github_token_prefixes() { + for token in [ + "ghp_abcdefghij0123456789", + "gho_abcdefghij0123456789", + "ghu_abcdefghij0123456789", + "ghs_abcdefghij0123456789", + "ghr_abcdefghij0123456789", + "github_pat_abcdefghij0123456789", + ] { + let r = redact_secrets_with(&format!("cloning https://{token}@github.com/o/r now"), &[]); + assert!(!r.contains(token), "leaked {token}: {r}"); + assert!(r.contains("[REDACTED]"), "got: {r}"); + assert!(r.contains("cloning"), "scan must stop at whitespace: {r}"); + assert!(r.ends_with(" now"), "scan must stop at whitespace: {r}"); + } +} + +#[test] +fn redact_secrets_with_extras_terminates_when_value_substring_of_marker() { + // Regression: an earlier impl used `while let Some(pos) = find(value)` + // which never terminates if the user's env value is a substring of + // the replacement marker `[REDACTED]` — each replacement + // reintroduces the same text. Now uses `str::replace` (single-pass). + for value in ["REDACTED", "EDACTE", "REDA", "ACTED"] { + let r = redact_secrets_with(&format!("leak={value}"), &[value]); + assert!(r.contains("[REDACTED]")); + } +} + +#[test] +fn redact_secrets_with_extras_handles_overlapping_secrets() { + // Longer entries get scrubbed first so the substring "abc12" isn't + // matched before "abc123" is consumed. + let s = "key1=abc123 key2=abc12"; + let r = redact_secrets_with(s, &["abc12", "abc123"]); + assert!(!r.contains("abc123")); + assert!(!r.contains("abc12 ")); +} + +#[test] +fn env_secrets_from_request_extracts_string_values() { + let req = serde_json::json!({ + "op": "deploy", + "agent": { + "env_vars": { + "ANTHROPIC_API_KEY": "sk-ant-test", + "EMPTY": "", + "NUMERIC": 42, + }, + }, + }); + let secrets = env_secrets_from_request(&req); + assert!(secrets.iter().any(|v| v == "sk-ant-test")); + // Empty and non-string values are filtered out. + assert_eq!(secrets.len(), 1); +} + +#[test] +fn env_secrets_from_request_handles_missing_shape() { + assert!(env_secrets_from_request(&serde_json::json!({})).is_empty()); + assert!(env_secrets_from_request(&serde_json::json!({"agent": {}})).is_empty()); + assert!(env_secrets_from_request(&serde_json::json!({"agent": {"env_vars": null}})).is_empty()); +} + +#[test] +fn redact_env_values_in_scrubs_map_values() { + let mut env = std::collections::BTreeMap::new(); + env.insert("ANTHROPIC_API_KEY".to_string(), "sk-ant-real".to_string()); + env.insert("EMPTY".to_string(), String::new()); + let stderr = "auth=sk-ant-real failed; other context"; + let r = redact_env_values_in(stderr, &env); + assert!(!r.contains("sk-ant-real")); + assert!(r.contains("[REDACTED]")); +} + +#[test] +fn env_secrets_from_request_includes_resolved_launch_maps() { + let req = serde_json::json!({ + "agent": { + "env_vars": {"LEGACY": "legacy-secret"}, + "launch": { + "env": {"PERSONA": "persona-secret"}, + "policy_env": {"POLICY": "policy-secret"} + } + } + }); + let secrets = env_secrets_from_request(&req); + assert_eq!(secrets.len(), 3); + for secret in ["legacy-secret", "persona-secret", "policy-secret"] { + assert!(secrets.iter().any(|candidate| candidate == secret)); + } +} + +#[cfg(unix)] +fn write_test_provider(path: &Path, body: &str) { + use std::os::unix::fs::PermissionsExt; + std::fs::write(path, format!("#!/bin/sh\nset -eu\n{body}\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_negotiates_and_deploys_the_same_staged_bytes() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let log = directory.path().join("invocations"); + let body = format!( + r#"read request +printf '%s\n' "$0" >> '{}' +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{{"ok":true,"name":"test","version":"1.0.0","protocol_version":1,"description":"test provider","config_schema":{{}}}}' ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"remote-1"}}' ;; +esac"#, + log.display() + ); + write_test_provider(&provider, &body); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("staged deploy"); + assert_eq!(id, "remote-1"); + let paths: Vec<_> = std::fs::read_to_string(log) + .unwrap() + .lines() + .map(str::to_owned) + .collect(); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], paths[1]); + assert_ne!(Path::new(&paths[0]), provider); + assert!( + !Path::new(&paths[0]).exists(), + "staging directory must be deleted" + ); +} + +#[cfg(unix)] +fn replacement_provider() -> &'static str { + r#"#!/bin/sh +set -eu +read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{"ok":true,"name":"replacement","version":"9.9.9","protocol_version":1,"description":"replacement provider","config_schema":{}}' ;; + *\"op\":\"deploy\"*) printf '%s\n' '{"ok":true,"agent_id":"replacement-bytes-ran"}' ;; +esac +"# +} + +#[cfg(unix)] +#[test] +fn provider_deploy_uses_staged_bytes_after_same_inode_source_rewrite() { + use std::os::unix::fs::MetadataExt; + + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let replacement = directory.path().join("replacement"); + std::fs::write(&replacement, replacement_provider()).unwrap(); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) + cat '{}' > '{}' + chmod 700 '{}' + printf '%s\n' '{{"ok":true,"name":"original","version":"1.0.0","protocol_version":1,"description":"original provider","config_schema":{{}}}}' + ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"original-staged-bytes"}}' ;; +esac"#, + replacement.display(), + provider.display(), + provider.display(), + ); + write_test_provider(&provider, &body); + let inode_before = std::fs::metadata(&provider).unwrap().ino(); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("deploy from immutable staged copy"); + + assert_eq!(id, "original-staged-bytes"); + assert_eq!( + std::fs::metadata(&provider).unwrap().ino(), + inode_before, + "test must rewrite the source binary in place" + ); + assert_eq!( + std::fs::read_to_string(&provider).unwrap(), + replacement_provider(), + "source pathname must contain replacement bytes before deploy" + ); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_uses_staged_bytes_after_source_pathname_replacement() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let replacement = directory.path().join("replacement"); + std::fs::write(&replacement, replacement_provider()).unwrap(); + std::fs::set_permissions(&replacement, std::fs::Permissions::from_mode(0o700)).unwrap(); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) + mv '{}' '{}' + printf '%s\n' '{{"ok":true,"name":"original","version":"1.0.0","protocol_version":1,"description":"original provider","config_schema":{{}}}}' + ;; + *\"op\":\"deploy\"*) printf '%s\n' '{{"ok":true,"agent_id":"original-staged-bytes"}}' ;; +esac"#, + replacement.display(), + provider.display(), + ); + write_test_provider(&provider, &body); + let inode_before = std::fs::metadata(&provider).unwrap().ino(); + + let id = provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})) + .expect("deploy from immutable staged copy"); + + assert_eq!(id, "original-staged-bytes"); + assert_ne!( + std::fs::metadata(&provider).unwrap().ino(), + inode_before, + "test must replace the source pathname with a different inode" + ); + assert_eq!( + std::fs::read_to_string(&provider).unwrap(), + replacement_provider(), + "source pathname must contain replacement bytes before deploy" + ); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_refuses_mismatch_before_sending_agent_secret() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + let marker = directory.path().join("deploy-received"); + let body = format!( + r#"read request +case "$request" in + *\"op\":\"info\"*) printf '%s\n' '{{"ok":true,"name":"test","version":"2.0.0","protocol_version":2,"description":"test provider","config_schema":{{}}}}' ;; + *\"op\":\"deploy\"*) touch '{}'; printf '%s\n' '{{"ok":true,"agent_id":"bad"}}' ;; +esac"#, + marker.display() + ); + write_test_provider(&provider, &body); + + let error = provider_deploy( + &provider, + &serde_json::json!({"private_key_nsec": "nsec1must-not-cross"}), + &serde_json::json!({}), + ) + .unwrap_err(); + assert!(error.contains("protocol version 2"), "{error}"); + assert!(!marker.exists()); + assert!(!error.contains("nsec1must-not-cross")); +} + +#[cfg(unix)] +#[test] +fn provider_deploy_requires_an_explicit_integer_protocol_version() { + let directory = tempfile::tempdir().unwrap(); + let provider = directory.path().join("provider"); + write_test_provider( + &provider, + r#"read request +printf '%s\n' '{"ok":true,"version":"1.0.0"}'"#, + ); + + let error = + provider_deploy(&provider, &serde_json::json!({}), &serde_json::json!({})).unwrap_err(); + assert!( + error.contains("missing integer protocol_version"), + "{error}" + ); +} + +#[test] +fn provider_info_requires_the_complete_flat_wire_shape() { + let complete = serde_json::json!({ + "ok": true, + "name": "kubernetes", + "version": "1.0.0", + "protocol_version": 1, + "description": "Kubernetes provider", + "config_schema": {} + }); + assert!(validate_provider_info(&complete).is_ok()); + + let mut missing = complete.clone(); + missing.as_object_mut().unwrap().remove("config_schema"); + assert!(validate_provider_info(&missing) + .unwrap_err() + .contains("config_schema")); + + let mut nested = complete; + nested.as_object_mut().unwrap().insert( + "provider".into(), + serde_json::json!({"protocol_version": 1}), + ); + assert!(validate_provider_info(&nested) + .unwrap_err() + .contains("unknown field provider")); +} + +#[test] +fn validate_provider_config_rejects_secret_key() { + let cfg = serde_json::json!({"api_key": "val"}); + assert!(validate_provider_config(&cfg).is_err()); +} + +#[test] +fn validate_provider_config_rejects_nested() { + let cfg = serde_json::json!({"region": {"us": "east"}}); + assert!(validate_provider_config(&cfg).is_err()); +} + +#[test] +fn validate_provider_config_accepts_scalars() { + let cfg = serde_json::json!({"region": "us-east-1", "tier": "standard"}); + assert!(validate_provider_config(&cfg).is_ok()); +} + +#[test] +fn validate_provider_config_allows_key_as_substring() { + // "keyboard", "monkey" contain "key" as substring but not as a word segment. + let cfg = serde_json::json!({"keyboard_layout": "us", "monkey_wrench": "tight"}); + assert!(validate_provider_config(&cfg).is_ok()); +} + +#[test] +fn validate_provider_config_rejects_camel_case_secrets() { + assert!(validate_provider_config(&serde_json::json!({"apiKey": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"accessToken": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"clientSecret": "val"})).is_err()); + // ALL-CAPS variants + assert!(validate_provider_config(&serde_json::json!({"apiKEY": "val"})).is_err()); + assert!(validate_provider_config(&serde_json::json!({"accessTOKEN": "val"})).is_err()); +} + +#[test] +fn split_config_key_handles_all_styles() { + assert_eq!(split_config_key("apiKey"), vec!["api", "key"]); + assert_eq!(split_config_key("access_token"), vec!["access", "token"]); + assert_eq!(split_config_key("keyboard"), vec!["keyboard"]); + assert_eq!(split_config_key("client-secret"), vec!["client", "secret"]); + // Acronym runs stay together + assert_eq!(split_config_key("APIKey"), vec!["api", "key"]); + assert_eq!(split_config_key("apiKEY"), vec!["api", "key"]); + assert_eq!(split_config_key("accessTOKEN"), vec!["access", "token"]); + assert_eq!(split_config_key("MyAPIKey"), vec!["my", "api", "key"]); +} + +#[test] +fn provider_filename_strips_the_windows_extension() { + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.exe"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.EXE"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.bat"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-kubernetes.CMD"), + Some("kubernetes") + ); + assert_eq!( + provider_id_from_filename("buzz-backend-my-provider"), + Some("my-provider") + ); + assert_eq!(provider_id_from_filename("other"), None); +} + +#[test] +fn resolve_provider_binary_rejects_invalid_ids() { + // Path traversal + assert!(resolve_provider_binary("../evil").is_err()); + // Empty + assert!(resolve_provider_binary("").is_err()); + // Uppercase + assert!(resolve_provider_binary("MyProvider").is_err()); + // Spaces + assert!(resolve_provider_binary("my provider").is_err()); + // Shell metacharacters + assert!(resolve_provider_binary("foo;rm -rf /").is_err()); + // Valid format but not on PATH — should fail with "not found" + assert!(resolve_provider_binary("nonexistent-test-id-12345").is_err()); +} + +#[test] +fn resolve_provider_binary_accepts_valid_id_format() { + // Valid ID format should pass validation. If the binary happens to + // exist on PATH, Ok is returned; otherwise Err contains "not found" + // (not "invalid provider ID"). Either outcome proves validation passed. + match resolve_provider_binary("zzz-nonexistent-test-provider") { + Ok(_) => {} // unlikely but fine — binary exists + Err(e) => assert!( + e.contains("not found"), + "expected 'not found' error, got: {e}" + ), + } +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 592a5cbbd92..1653371e7f0 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -77,6 +77,10 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ "BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST", "BUZZ_ACP_AGENT_OWNER", + // Remote lifetime/presence policy: user env must not disable the + // desktop/provider-owned bounds while the saved record still promises them. + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_NO_PRESENCE", // Readiness handoff: desktop is the ONLY readiness source. A saved or // ambient env var must not be able to forge setup mode (NotReady) on a // Ready agent or suppress it (empty/stale payload) on a NotReady one. @@ -307,28 +311,5 @@ pub(crate) fn live_persona_env( .unwrap_or_default() } -/// Resolve live env_vars for a linked persona, loading personas from disk. -/// -/// Returns the persona's `env_vars` map if a persona_id is provided and found; -/// returns an empty map if no persona is linked. Errors if the linked persona -/// is missing. Used by the provider deploy path, which has no pre-loaded -/// persona slice. -pub(crate) fn resolve_persona_env( - app: &tauri::AppHandle, - persona_id: Option<&str>, -) -> Result, String> { - let Some(pid) = persona_id else { - return Ok(std::collections::BTreeMap::new()); - }; - let personas = super::load_personas(app).map_err(|e| { - format!("failed to load personas while resolving env for persona `{pid}`: {e}") - })?; - let persona = personas - .into_iter() - .find(|p| p.id == pid) - .ok_or_else(|| format!("persona `{pid}` not found while resolving env"))?; - Ok(persona.env_vars) -} - #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index cf57b125468..534c2e0835c 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -158,6 +158,15 @@ fn reserved_keys_include_respond_to_gate() { } } +#[test] +fn reserved_keys_include_remote_lifetime_policy() { + for key in ["BUZZ_ACP_EXIT_AFTER_INACTIVITY", "BUZZ_ACP_NO_PRESENCE"] { + assert!(is_reserved_env_key(key), "{key} should be reserved"); + let agent = map(&[(key, "0")]); + assert!(merged_user_env(&BTreeMap::new(), &agent).is_empty()); + } +} + #[test] fn reserved_keys_include_code_execution_surface() { // The agent/MCP command + args are what Buzz actually exec's. diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 1ff8bd20efa..93326b99688 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-backend-kubernetes", "binaries/buzz-dev-mcp", "binaries/git-credential-nostr", "binaries/buzz" diff --git a/desktop/src-tauri/tauri.windows.conf.json b/desktop/src-tauri/tauri.windows.conf.json new file mode 100644 index 00000000000..abf09b5bfdb --- /dev/null +++ b/desktop/src-tauri/tauri.windows.conf.json @@ -0,0 +1,11 @@ +{ + "bundle": { + "externalBin": [ + "binaries/buzz-acp", + "binaries/buzz-agent", + "binaries/buzz-dev-mcp", + "binaries/git-credential-nostr", + "binaries/buzz" + ] + } +} diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs new file mode 100644 index 00000000000..0dfd80d4444 --- /dev/null +++ b/desktop/src/features/agents/ui/ProviderConfigFields.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { coerceConfigValues } from "./ProviderConfigFields.tsx"; + +const schema = { + properties: { + inactivity_seconds: { type: "integer" }, + threshold: { type: "number" }, + label: { type: "string" }, + }, +}; + +describe("coerceConfigValues", () => { + it("omits cleared numeric fields without losing explicit zero", () => { + assert.deepEqual( + coerceConfigValues( + { inactivity_seconds: "", threshold: "0", label: "" }, + schema, + ), + { threshold: 0, label: "" }, + ); + }); + + it("preserves nonempty invalid numeric input for provider validation", () => { + assert.deepEqual( + coerceConfigValues({ inactivity_seconds: "not-a-number" }, schema), + { inactivity_seconds: "not-a-number" }, + ); + }); +}); diff --git a/desktop/src/features/agents/ui/ProviderConfigFields.tsx b/desktop/src/features/agents/ui/ProviderConfigFields.tsx index e922dd9c1a3..031b0b7277d 100644 --- a/desktop/src/features/agents/ui/ProviderConfigFields.tsx +++ b/desktop/src/features/agents/ui/ProviderConfigFields.tsx @@ -14,7 +14,8 @@ export function coerceConfigValues( for (const [key, value] of Object.entries(config)) { const prop = properties[key] as Record | undefined; const schemaType = prop?.type; - if ((schemaType === "integer" || schemaType === "number") && value !== "") { + if (schemaType === "integer" || schemaType === "number") { + if (value === "") continue; const num = Number(value); result[key] = Number.isNaN(num) ? value : num; } else if (schemaType === "boolean") { diff --git a/docs/remote-agents.md b/docs/remote-agents.md index 4664de21e36..48ce85f5e57 100644 --- a/docs/remote-agents.md +++ b/docs/remote-agents.md @@ -203,17 +203,20 @@ one. deployment axis (`deployed`/`not_deployed`, from the stored `backend_agent_id`) is bookkeeping, not liveness. Staleness bound: presence can be wrong for the window between an abnormal agent death (SIGKILL, node - loss) and the relay's presence expiry — **90 seconds** + loss) and the relay's presence expiry — **180 seconds** (`PRESENCE_TTL_SECS`, `buzz-pubsub/src/presence.rs:16`; the vision's - "ninety seconds of a wrong dot, never an indefinite one"), the accepted - cost of M1. + "a bounded wrong dot, never an indefinite one"), the accepted + cost of M1. The specific number is a relay-wide constant, not a + remote-agent choice: #3783 raised it from 90s to keep a three-heartbeat + expiry window after the desktop heartbeat moved to 60s. What I3 promises + is that the window is *bounded*, not its width. The Kubernetes binding minimizes the *avoidable* part of that window by sizing the termination grace period to the harness's full graceful-shutdown path (§K8s Grace). Two consequences the bound imposes: (a) the harness's presence-suppression knob, `BUZZ_ACP_NO_PRESENCE`, MUST join `RESERVED_ENV_KEYS` — locally the knob is cosmetic (the process and UI remain visible), but remotely M1 makes presence the *only* signal, so an - unreserved user env var would convert "wrong for ≤90s" into "wrong + unreserved user env var would convert "wrong for ≤180s" into "wrong indefinitely" and silently disarm the one bound in print; (b) presence is scoped to a **community**: the relay derives community from its host, so the deploy-time `relay_url` binds the body to one community for its whole @@ -273,7 +276,7 @@ one. directives. Any revive-on-abnormal-death policy carries a universal precondition: the supervisor can distinguish intent from accident only if the harness formally promises *clean exit = exit code 0* on every - intentional path and nonzero otherwise, pinned by test. At `c1bca1b56` + intentional path and nonzero otherwise, pinned by test. At `28ae6cd21` that property is emergent, not defended (Known Defect 6); restart-on-failure before the pinned contract is how a refactor silently converts every clean stop into a restart loop with no failing @@ -308,7 +311,7 @@ entry of `PATH`, and `~/.local/bin`, for executables named `buzz-backend-`. The suffix after the prefix is the provider id and MUST match `[a-z0-9][a-z0-9_-]*`. On Windows, an `.exe`/`.bat`/`.cmd` extension MUST be stripped before the id is derived (see §Known Defects — as of -`c1bca1b56` it is not, so Windows providers probe but cannot deploy). First +`28ae6cd21` it is not, so Windows providers probe but cannot deploy). First hit per filename wins. Discovery executes nothing. **Shadowing and invalid candidates are diagnosable, not silent.** First-hit @@ -319,7 +322,7 @@ deploy-time errors MUST be able to surface: the selected binary's full path, any shadowed candidates for the same id (later-PATH duplicates), and candidates rejected for malformed names. A deploy error that names which binary ran answers the first question a user with two copies of -`buzz-backend-kubernetes` will ask. (At `c1bca1b56` discovery records only +`buzz-backend-kubernetes` will ask. (At `28ae6cd21` discovery records only the winning path — a desktop change alongside Known Defect 3's.) **Resolution rule.** Every subsequent operation resolves the provider id @@ -330,7 +333,7 @@ to a binary discovery would not have found. **Pre-secret negotiation gate (normative).** Declaring `protocol_version` is worthless if nothing checks it before the nsec crosses the trust -boundary — and at `c1bca1b56` nothing does: `provider_deploy` invokes +boundary — and at `28ae6cd21` nothing does: `provider_deploy` invokes `deploy` directly, so a stale UI-time probe (or a binary replaced on PATH since that probe) can receive `private_key_nsec` unchecked (Known Defect 5). The deploy path MUST: resolve the provider id **once**; copy the @@ -420,7 +423,7 @@ timeout: 600s ``` The agent payload (field list per -`commands/agents_deploy.rs: deploy_payload_json` at `c1bca1b56`; the +`commands/agents_deploy.rs: deploy_payload_json` at `28ae6cd21`; the `launch` block is a normative addition not yet emitted — Known Defect 3): | field | meaning | @@ -429,7 +432,7 @@ The agent payload (field list per | `relay_url` | concrete WS URL (workspace fallback materialized — the remote side has no workspace notion) | | `private_key_nsec` | **the identity** (I1: never empty) | | `auth_tag` | NIP-OA owner attestation | -| `agent_command`, `agent_args` | the ACP agent under the harness (configurable-harness support). At `c1bca1b56` these are raw record bytes — see Known Defect 3: the normative source is the resolved descriptor in `launch` | +| `agent_command`, `agent_args` | the ACP agent under the harness (configurable-harness support). At `28ae6cd21` these are raw record bytes — see Known Defect 3: the normative source is the resolved descriptor in `launch` | | `system_prompt`, `model`, `provider` | effective values, live-persona-first resolution | | `turn_timeout_seconds`, `idle_timeout_seconds`, `max_turn_duration_seconds` | harness timeout knobs | | `parallelism` | concurrent-turn bound | @@ -466,7 +469,7 @@ deploy of the same key, or manually). Reproducing the local spawn's launch semantics requires state only the desktop can resolve: the runtime-metadata table (`model_env_var`, `provider_env_var`, `provider_locked`, `default_env` — -`discovery.rs:74-193`), the six-layer env resolution, harness-definition +`discovery.rs:75-207`), the six-layer env resolution, harness-definition command/args fallback, team instructions, session title, the respond-to gate's legacy owner fallback, and the mesh rewrite. A provider MUST NOT reimplement that derivation — it would be a second copy of desktop runtime @@ -529,12 +532,12 @@ mis-tiered (below): verbatim would bake a host accident into the pod. Launch data MUST be computed from record + config alone. - `BUZZ_ACP_LAZY_POOL=true` — a **deliberate pick, not a transcription**: - the two local paths disagree (manual Start is eager, `runtime.rs:1006`; + the two local paths disagree (manual Start is eager, `runtime.rs:1001`; launch restore is lazy, `restore.rs:333`, precisely to avoid "N idle brains on every launch"). Remote pods take the lazy arm: an idle LLM pool in a cluster is billable waste with no user watching it warm up. - `MCP_HOOK_SERVERS=*` when the resolved runtime has `mcp_hooks` - (`runtime.rs:594-598`; buzz-agent only at `c1bca1b56`) — gates the + (`runtime.rs:594-598`; buzz-agent only at `28ae6cd21`) — gates the `_Stop`/`_PostCompact` hook tools. - `BUZZ_ACP_SYSTEM_PROMPT`, `BUZZ_ACP_IDLE_TIMEOUT`, `BUZZ_ACP_MAX_TURN_DURATION`, `BUZZ_ACP_AGENTS` — resolved by the desktop @@ -728,6 +731,31 @@ keys on it. Whether ten minutes fits the intended cluster class is a product ruling, not a correctness one. +**One create attempt per call (normative).** The replacement rows above — +terminated, never-started provably broken, never-started divergent — exist +to clear residue from a *previous* life. Once a deploy call has created its +own pod, a classification that would replace that pod means the attempt this +call just made has already failed: the harness started, rejected its +configuration, and exited (the deterministic startup failure), or the pod +was proven broken. Re-running the identical create against the same cluster +inside the same call cannot produce a different outcome; what it produces is +a hot delete/mint/create cycle every poll interval for the whole operation +deadline, one immutable Secret per cycle (measured live: 107 Secrets in a +single 600s call), every one younger than §K8s GC's orphan age gate — a +bounded-call resource DoS and nsec-bearing-Secret amplifier. A binding MUST +NOT delete-recreate a pod created by the same deploy call: it MUST return +the in-band error carrying the latest condition (for a terminated container, +the exit code and reason — never the terminated `message`, which is +process-composed output under the same redaction rule as pull messages). +The failed attempt's pod and Secret are deliberately left in place: the pod +is terminated, so the *next* Start's preflight GC collects the pod and its +referenced Secret together before that call's own single attempt — retry is +thereby gated on fresh owner intent, and litter is bounded at one pod plus +one Secret per press, not per poll. This bounds attempts, not observation: +the recoverable rows still observe a slow startup for the full deadline, and +residue from previous lives is still replaced exactly once on the way to +this call's attempt. + **Destructive decisions come from views you control — reads and writes both (normative).** This is one rule with three instances, stated once so nobody optimizes an instance away. §K8s GC's same-clock rule is the time @@ -853,13 +881,13 @@ can yield two live instances in one scope. derive an upper bound for this path from its segment timeouts, because review proved that arithmetic wrong twice**: the visible constants (30s drain, 2s presence, 5s relay close) omit terms that are *variable*, not - constant — at this PR's base `b4f4ed1a6` the post-drain reap segment + constant — at `28ae6cd21` the post-drain reap segment (late-arriving reap `lib.rs:2664`, idle-slot reap loop `:2670`, respawn drain `:2684-2688`) runs *outside* the 30s drain timeout (opened at `:2636`, closed at `:2657`) and serially awaits a 5s post-SIGKILL wait per occupied pool slot (`acp.rs:436`). **That segment alone can reach `30 + 5×parallelism + 7` — ~87s at the desktop's default parallelism - of 10** (`DEFAULT_AGENT_PARALLELISM`, `types.rs:809`; lowered from 24 by + of 10** (`DEFAULT_AGENT_PARALLELISM`, `types.rs:814`; lowered from 24 by #3038), ~197s at the harness cap of 32 (`config.rs:293`) — already exceeding a 60s grace. And it is a *lower* bound on the tail, not the worst case: the same path runs earlier segments before the prompt drain @@ -925,7 +953,7 @@ I5's enforcement point. A new harness knob: could disable the reaper and reopen unbounded lifetime through the front door. `BUZZ_ACP_NO_PRESENCE` (`config.rs:378`) MUST join in the same change, for the same shape of reason at I3 instead of I5: unreserved, it - lets user env silently defeat the 90s presence bound (I3). One knob + lets user env silently defeat the 180s presence bound (I3). One knob guards "knows when to leave", the other "you can see that it left"; both are promises users must not be able to un-make by typo. - Distinctness note: this is a **fourth** timeout concept, deliberately named @@ -1173,7 +1201,7 @@ regardless of `HOME`. would SIGKILL the harness mid-drain, leaving presence stale-online — the avoidable half of I3's staleness window — so the binding declares 60s. But the shutdown tail is *variable*, not constant (§Stop: the post-drain - reap segment alone reaches ~87s at default parallelism at `b4f4ed1a6`, + reap segment alone reaches ~87s at default parallelism at `28ae6cd21`, and earlier untimed segments precede it — the total is not bounded by today's segment timeouts), so no fixed grace can be proven sufficient by adding segment timeouts. The two halves of the requirement: @@ -1541,14 +1569,11 @@ the wrong tool here: the failure modes found in review were wrong delete, phase-as-readiness, non-atomic Secret→pod against GC), which a hand-written model would have reproduced convincingly. -## Known Defects (at `c1bca1b56`) +## Known Defects (at `28ae6cd21`) -**Citation-pin caveat:** `c1bca1b56` is an unmerged feature-branch commit -that diverged from main on Jul 18 and predates #3038 (default parallelism -24 → 10). Line references marked `at b4f4ed1a6` were re-verified against -this PR's own base; unmarked `c1bca1b56` references may be offset on -current main. A follow-up re-pins the whole document to one merged -commit. +**Citation pin:** every `file:line` reference in this document was verified +against `28ae6cd21` — the commit at which this spec merged to `main`. +References are to that tree; a later commit may offset them. Desktop- and harness-side, discovered during this design: @@ -1562,7 +1587,7 @@ Desktop- and harness-side, discovered during this design: a desktop-side PATH augmentation would fix the class. 3. **Deploy payload bypasses the launch resolver** (the prerequisite this spec names for §Launch data — a desktop code change, not spec text). - At `c1bca1b56`, `deploy_payload_json` serializes raw record bytes and a + At `28ae6cd21`, `deploy_payload_json` serializes raw record bytes and a three-layer `merged_user_env` where the local spawn uses `resolve_effective_harness_descriptor`'s six-layer resolution. Concrete consequences, each verified in review: (a) no per-runtime model/provider @@ -1584,14 +1609,14 @@ Desktop- and harness-side, discovered during this design: semantics-preserving remote launch. **Security follow-through:** once secrets can arrive via `launch.env`, desktop redaction MUST collect candidate values from `launch.env` (and `launch.policy_env`) as well as - legacy `agent.env_vars` — at `c1bca1b56`, `env_secrets_from_request` + legacy `agent.env_vars` — at `28ae6cd21`, `env_secrets_from_request` reads only `agent.env_vars` (`backend.rs`), leaving a definition/persona-layer secret outside the literal-value scrub. Conformance: a provider that echoes a launch-only secret into an error must come back redacted. 4. **The I5 reaper does not exist, and its natural home is a trap** (harness code prerequisite). `BUZZ_ACP_EXIT_AFTER_INACTIVITY` appears - nowhere in the harness at `c1bca1b56`; §Auto-Stop is a design, not a + nowhere in the harness at `28ae6cd21`; §Auto-Stop is a design, not a description. Worse, the obvious attachment point — the existing 30s maintenance tick — is gated on `pool_ready` (`lib.rs:1743`), which under `lazy_pool` only becomes true when work arrives, so a never-mentioned @@ -1607,7 +1632,7 @@ Desktop- and harness-side, discovered during this design: resolve-once → stage-and-digest → `info` → explicit-version check → `deploy`, both invocations running the staged bytes. 6. **The clean-exit contract is emergent, not defended** (harness code - prerequisite; gates `OnFailure`). At `b4f4ed1a6`: the graceful path + prerequisite; gates `OnFailure`). At `28ae6cd21`: the graceful path returns `Ok(())` (`lib.rs:2723`), and owner `!shutdown` (`:2045`), Ctrl-C (`:1635`), and SIGTERM (`:1644`) all route into the same shutdown channel — so clean @@ -1620,12 +1645,12 @@ Desktop- and harness-side, discovered during this design: timeout would silently convert every clean stop into a restart loop — I5 defeated with no failing test (I5 ordering rule). 7. **The shutdown tail overruns the declared grace budget at default - config** (harness code prerequisite). At `b4f4ed1a6`: the post-drain + config** (harness code prerequisite). At `28ae6cd21`: the post-drain reap segment (`lib.rs:2664-2688`) runs *after* the 30s drain timeout closes (`:2636,:2657`) and serially awaits a 5s post-SIGKILL wait per occupied slot (`acp.rs:436`) — that segment alone reaches ~87s at the desktop's - default parallelism of 10 (`types.rs:809`; #3038 lowered it from 24), + default parallelism of 10 (`types.rs:814`; #3038 lowered it from 24), ~197s at the harness cap of 32 (`config.rs:293`), against the binding's 60s grace; and it is not the whole tail — the wake-task drain (`:2612`) and awakened-pool shutdown (`:2620-2624`, per-slot loop @@ -1640,7 +1665,7 @@ Desktop- and harness-side, discovered during this design: 8. **Cleared numeric config fields ship as strings** (desktop code prerequisite, raised by blessing `0`). `coerceConfigValues` (`desktop/src/features/agents/ui/ProviderConfigFields.tsx:6` at - `b4f4ed1a6`) skips numeric coercion when the value is `""`, so a + `28ae6cd21`) skips numeric coercion when the value is `""`, so a *cleared* numeric field reaches the provider as a JSON string instead of a number. Blessing `inactivity_seconds: 0` makes clearing that field a legitimate user action, so the empty-string arm now sits on a diff --git a/scripts/bundle-sidecars.sh b/scripts/bundle-sidecars.sh index 07de477405a..8ea5fe2bbe5 100755 --- a/scripts/bundle-sidecars.sh +++ b/scripts/bundle-sidecars.sh @@ -4,6 +4,12 @@ set -euo pipefail SIDECARS=(buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr buzz) HOST=$(rustc -vV | sed -n 's|host: ||p') TARGET=${1:-$HOST} +if [[ "$TARGET" != *windows* ]]; then + SIDECARS+=(buzz-backend-kubernetes) + BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli" +else + BUILD_HINT="cargo build --release -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli" +fi BINARIES_DIR="desktop/src-tauri/binaries" # When --target is passed explicitly to cargo (even if it matches the host), @@ -29,7 +35,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 '$BUILD_HINT' first." >&2 exit 1 fi diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 2e7e9444827..0e5946ca186 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -106,6 +106,12 @@ run_unit_tests() { run_test_step "buzz-push-gateway tests" \ cargo test -p buzz-push-gateway -- --nocapture + + # Kubernetes backend provider: pure decision layers driven by a fake + # substrate, no cluster. Mirrors the nextest path in `just test-unit` — + # the two lists must stay in step or the fallback silently covers less. + run_test_step "buzz-backend-kubernetes tests" \ + cargo test -p buzz-backend-kubernetes -- --nocapture } # ---- DB / integration tests (infra required) -------------------------------- diff --git a/scripts/sprig-entrypoint.sh b/scripts/sprig-entrypoint.sh new file mode 100755 index 00000000000..16abf7bc515 --- /dev/null +++ b/scripts/sprig-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +# Match desktop's URL-scoped git credential configuration without installing a +# helper globally (which would make it answer for unrelated remotes). +if [[ -n "${BUZZ_RELAY_URL:-}" ]]; then + relay_http_url="${BUZZ_RELAY_URL/#ws:/http:}" + relay_http_url="${relay_http_url/#wss:/https:}" + relay_http_url="${relay_http_url%/}" + git config --global "credential.${relay_http_url}/git.helper" \ + /usr/local/bin/git-credential-nostr + git config --global "credential.${relay_http_url}/git.useHttpPath" true +fi + +# The harness must receive Kubernetes' termination signal directly. +exec buzz-acp "$@" diff --git a/scripts/test-k8s-provider-release.sh b/scripts/test-k8s-provider-release.sh new file mode 100755 index 00000000000..40c78e26882 --- /dev/null +++ b/scripts/test-k8s-provider-release.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Reproduce the desktop release's multi-package build shape, then prove the +# Kubernetes provider reports an unreachable cluster in-band rather than +# panicking when rustls has multiple compiled crypto providers available. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +PROVIDER_BINARY="$TARGET_DIR/release/buzz-backend-kubernetes" + +CARGO="${CARGO:-cargo}" +"$CARGO" build --release \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr \ + -p buzz-cli \ + -p buzz-backend-kubernetes + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cat >"$TMP/kubeconfig" <<'YAML' +apiVersion: v1 +kind: Config +clusters: + - name: unreachable + cluster: + server: http://127.0.0.1:9 +contexts: + - name: unreachable + context: + cluster: unreachable + user: none +current-context: unreachable +users: + - name: none + user: {} +YAML + +cat >"$TMP/request.json" <<'JSON' +{ + "op": "deploy", + "agent": { + "relay_url": "wss://relay.example", + "private_key_nsec": "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", + "auth_tag": "owner", + "launch": {"command": "goose", "args": [], "env": {}, "policy_env": {}} + }, + "provider_config": { + "context": "unreachable", + "namespace": "never-created", + "image": "example.invalid/sprig@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "inactivity_seconds": 60 + } +} +JSON + +set +e +KUBECONFIG="$TMP/kubeconfig" \ + "$PROVIDER_BINARY" \ + <"$TMP/request.json" >"$TMP/stdout" 2>"$TMP/stderr" +status=$? +set -e + +[[ "$status" == 0 ]] || { + echo "provider exited $status instead of returning an in-band failure" >&2 + cat "$TMP/stderr" >&2 + exit 1 +} +[[ "$(wc -l <"$TMP/stdout" | tr -d ' ')" == 1 ]] +jq -e '.ok == false and (.error | type == "string" and length > 0)' "$TMP/stdout" >/dev/null +if rg -i 'panic|crypto provider|no process-level' "$TMP/stdout" "$TMP/stderr"; then + echo "provider emitted a panic/crypto-provider failure" >&2 + exit 1 +fi +printf 'PASS: release-shaped provider returned one in-band unreachable-cluster failure\n' diff --git a/scripts/test-k8s-sprig-image-live.sh b/scripts/test-k8s-sprig-image-live.sh new file mode 100755 index 00000000000..ec9f259053e --- /dev/null +++ b/scripts/test-k8s-sprig-image-live.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Prove that a cluster can resolve and start the exact digest-qualified Sprig +# image that will be passed to the Kubernetes provider. This is intentionally a +# separate preflight: a Docker image existing in the host daemon does not imply +# that the kubelet's container runtime can resolve the same name and digest. +set -euo pipefail + +: "${BUZZ_K8S_TEST_CONTEXT:?set the explicit disposable/local kubectl context}" +: "${BUZZ_SPRIG_IMAGE:?set an immutable image reference (name@sha256:<64 hex>)}" + +if [[ ! "$BUZZ_SPRIG_IMAGE" =~ ^[^[:space:]@]+@sha256:[0-9a-fA-F]{64}$ ]]; then + echo "error: BUZZ_SPRIG_IMAGE must be name@sha256:<64 hex>" >&2 + exit 2 +fi + +CONTEXT="$BUZZ_K8S_TEST_CONTEXT" +IMAGE="$BUZZ_SPRIG_IMAGE" +PULL_POLICY="${BUZZ_K8S_TEST_PULL_POLICY:-IfNotPresent}" +case "$PULL_POLICY" in + Always|IfNotPresent|Never) ;; + *) echo "error: invalid BUZZ_K8S_TEST_PULL_POLICY: $PULL_POLICY" >&2; exit 2 ;; +esac + +MANAGED_BY="buzz-backend-kubernetes" +BINDING_VERSION="v1" +NAMESPACE="buzz-k8s-sprig-$(date +%s)-$RANDOM" +CREATED=0 + +cleanup() { + (( CREATED == 1 )) || return 0 + local managed binding foreign + managed="$(kubectl --context "$CONTEXT" get namespace "$NAMESPACE" \ + -o jsonpath='{.metadata.labels.app\.kubernetes\.io/managed-by}' 2>/dev/null || true)" + binding="$(kubectl --context "$CONTEXT" get namespace "$NAMESPACE" \ + -o jsonpath='{.metadata.labels.buzz\.block\.xyz/binding-version}' 2>/dev/null || true)" + if [[ "$managed" != "$MANAGED_BY" || "$binding" != "$BINDING_VERSION" ]]; then + echo "REFUSING cleanup: namespace ownership markers changed: $NAMESPACE" >&2 + return 1 + fi + foreign="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pods -o json \ + | jq '[.items[] | select(.metadata.labels["app.kubernetes.io/managed-by"] != "buzz-backend-kubernetes" or .metadata.labels["buzz.block.xyz/binding-version"] != "v1")] | length')" + if [[ "$foreign" != 0 ]]; then + echo "REFUSING cleanup: namespace contains an unowned pod: $NAMESPACE" >&2 + return 1 + fi + kubectl --context "$CONTEXT" delete namespace "$NAMESPACE" --wait=true +} +trap cleanup EXIT + +# Fail before mutation if the named context is absent or inaccessible. Print the +# cluster identity so evidence cannot be mistaken for a different kubeconfig. +kubectl config get-contexts "$CONTEXT" >/dev/null +SERVER="$(kubectl config view --minify --context "$CONTEXT" -o jsonpath='{.clusters[0].cluster.server}')" +kubectl --context "$CONTEXT" get nodes -o name >/dev/null +printf 'context=%s\nserver=%s\nimage=%s\npull_policy=%s\nnamespace=%s\n' \ + "$CONTEXT" "$SERVER" "$IMAGE" "$PULL_POLICY" "$NAMESPACE" + +kubectl --context "$CONTEXT" create namespace "$NAMESPACE" +CREATED=1 +kubectl --context "$CONTEXT" label namespace "$NAMESPACE" \ + "app.kubernetes.io/managed-by=$MANAGED_BY" \ + "buzz.block.xyz/binding-version=$BINDING_VERSION" + +cat <&2 || true + exit 1 +fi + +kubectl --context "$CONTEXT" --namespace "$NAMESPACE" logs digest-resolution-probe +IMAGE_ID="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pod digest-resolution-probe \ + -o jsonpath='{.status.containerStatuses[0].imageID}')" +RESOLVED_SPEC="$(kubectl --context "$CONTEXT" --namespace "$NAMESPACE" get pod digest-resolution-probe \ + -o jsonpath='{.spec.containers[0].image}')" +[[ "$RESOLVED_SPEC" == "$IMAGE" ]] +[[ -n "$IMAGE_ID" ]] +printf 'resolved_spec=%s\nimage_id=%s\nPASS: exact digest-qualified Sprig reference started\n' \ + "$RESOLVED_SPEC" "$IMAGE_ID" diff --git a/scripts/test-sprig-image.sh b/scripts/test-sprig-image.sh new file mode 100755 index 00000000000..4ca42fdb4d4 --- /dev/null +++ b/scripts/test-sprig-image.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${1:-buzz-sprig:contract-test}" +if [[ "${SKIP_BUILD:-0}" != 1 ]]; then + docker build --file Dockerfile.sprig --tag "$IMAGE" . +fi + +assert_run() { + docker run --rm --entrypoint /bin/bash "$IMAGE" -ceu "$1" +} + +assert_run ' + command -v bash git update-ca-certificates >/dev/null + test "$(readlink /usr/local/bin/buzz-acp)" = sprig + for name in buzz-agent buzz-dev-mcp rg tree buzz git-credential-nostr git-sign-nostr; do + test "$(readlink "/usr/local/bin/$name")" = sprig + done + test "$(git config --system gpg.x509.program)" = /usr/local/bin/git-sign-nostr + ! git config --system --get-all credential.helper + test "$HOME" = /home/agent + test "$(pwd)" = /home/agent +' + +assert_run ' + grep -Eq "^[[:space:]]*exec buzz-acp" /usr/local/bin/sprig-entrypoint + ! grep -Eq "^[[:space:]]*(buzz-acp|bash -c .*buzz-acp)" /usr/local/bin/sprig-entrypoint +' + +docker run --rm --entrypoint /bin/bash \ + -e BUZZ_RELAY_URL=wss://relay.example.test/ "$IMAGE" -ceu ' + /usr/local/bin/sprig-entrypoint --help >/dev/null 2>&1 & pid=$! + for _ in 1 2 3 4 5; do + git config --global --get credential.https://relay.example.test/git.helper >/dev/null 2>&1 && break + sleep 0.1 + done + test "$(git config --global --get credential.https://relay.example.test/git.helper)" = /usr/local/bin/git-credential-nostr + test "$(git config --global --get credential.https://relay.example.test/git.useHttpPath)" = true + ! git config --global --get-all credential.helper + wait "$pid" || true + ' + +echo "PASS: Sprig image runtime contract ($IMAGE)" From f86cfc7369d4471f8939ed98be6f597b0a4b0bb2 Mon Sep 17 00:00:00 2001 From: Matheus Date: Sun, 2 Aug 2026 11:53:01 -0700 Subject: [PATCH 006/163] fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures (#3778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Two related gaps in global back/forward navigation. Fixes #3775. 1. The keyboard shortcuts almost never fire in real use — users fall back to clicking the toolbar chevrons and assume the shortcuts don't exist. 2. On macOS, mouse back/forward buttons (X1/X2) and horizontal swipe gestures do nothing, although they navigate in every browser and in Slack. **Duplicate check:** searched open PRs and issues — none found beyond #3775 (filed alongside this fix). #3078 / #3377 are next/previous-*channel* navigation, a different feature. ## Root causes **Keyboard:** `useBackForwardControls`'s keydown handler bailed whenever the event target was editable — but `useComposerAutofocus` deliberately focuses the message composer (a ProseMirror contenteditable) on mount and on every channel switch. In steady state focus almost always lives in the composer, so the chords were silently swallowed. Invisible to CI because `navigation.spec.ts` only ever clicked the `global-back` / `global-forward` buttons, never pressed the keys. **Mouse/swipe:** on macOS, WKWebView never delivers X1/X2 button events or swipe gestures to the page (Safari handles them natively in the app layer, not in page JS), and Buzz had no native handler. ## Fix ### Keyboard chords (web layer) Match the existing platform chord regardless of the event target and drop the editable-target guard: - `⌘[` / `⌘]` have no text-editing semantics in macOS text fields, and the TipTap/StarterKit editor config binds no `Mod-[` / `Mod-]` shortcuts (checked `useRichTextEditor.ts` — list indentation is Tab/Shift-Tab). - `preventDefault()` keeps the chord out of the editor — asserted in the e2e test. This matches browsers and Slack, where back/forward chords work while a text field is focused. Chord matching is extracted into a pure helper, `app/navigation/backForwardChords.ts`, so it can be unit tested; behavior (bindings, modifier exclusivity, `code`-based matching for non-US layouts) is unchanged. ### macOS mouse buttons and swipe gestures (native layer) An NSEvent local monitor in `mouse_nav.rs` catches what the webview can't see and emits a `mouse-nav` Tauri event to the main window (`emit_to`, so navigation stays scoped if multi-window ever lands) that the frontend acts on. Two AppKit event shapes map to navigation: - `otherMouseUp` with button 3/4 — mice whose X1/X2 buttons arrive as plain button events. These are swallowed after emitting so nothing downstream double-handles them. - `swipe` with a horizontal delta — AppKit's page-swipe gesture (`swipeWithEvent:`): `deltaX > 0` back, `deltaX < 0` forward. Sent by mouse drivers that synthesize a page-swipe gesture for the back/forward buttons instead of button-3/4 events (the hardware this was verified on). Stock Apple trackpad and Magic Mouse swipes arrive as phased scroll-wheel events instead, which this PR does not handle — that path (`ScrollWheel` + `trackSwipeEventWithOptions:`, which also needs scroll-edge detection) is deferred to a follow-up. Swipes are passed through (swallowing mid-gesture events could confuse AppKit gesture tracking). The swipe path was verified end to end on hardware whose back/forward buttons emit only swipe gestures, never button-3/4 events — an instrumented event monitor confirmed the events arrive as `NSEventType::Swipe` with `deltaX ±1`, and navigation worked after mapping them. ## Tests - **13 unit tests** for the web-side chord matcher (`backForwardChords.test.mjs`): supported chords, modifier exclusivity, `code` fallback, and preservation of line-editing shortcuts. - **6 Rust unit tests** for the native mapping helpers (`mouse_nav.rs`): button 3/4 directions, other buttons ignored, swipe delta sign → direction, zero-delta (gesture-begin) ignored. - **e2e regression case** in `navigation.spec.ts`: presses the platform chord *while the composer is focused* — the missing coverage. Verified it fails against the pre-fix implementation and passes with the fix. - Full desktop unit suite: 3832/3832 pass. Full Rust suite (`cargo test`, buzz-desktop): 1888 passed / 0 failed. `pnpm typecheck`, `biome check`, `pnpm check`, `cargo fmt --check`, `cargo clippy`: clean (no new warnings). - Full Playwright e2e: 958 passed; 6 failures are relay-infrastructure tests (live relay seeding / relay state seam) that fail identically without this change — `navigation.spec.ts` is fully green. ## Manual test 1. Open a channel, then another (composer autofocuses on each switch). 2. `⌘[` — returns to the previous channel; `⌘]` — forward again. Typing `[` / `]` in the composer inserts normally. 3. Mouse back/forward buttons navigate the same way, from anywhere in the window (verified on macOS on hardware using both event shapes). ## Update — 2026-07-31 Removed the redundant DOM mouse-button handler after verifying it was unnecessary. The native macOS path remains unchanged and was revalidated manually. --------- Signed-off-by: npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse <23260a641ceda9918c01b70f05c2dc85e642ba0c5bd38511d80b97e2921f7741@buzz.block.builderlab.xyz> Signed-off-by: Matheus Iser Signed-off-by: Will Pfleger Co-authored-by: npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse <23260a641ceda9918c01b70f05c2dc85e642ba0c5bd38511d80b97e2921f7741@buzz.block.builderlab.xyz> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Co-authored-by: Will Pfleger --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 3 +- desktop/src-tauri/src/mouse_nav.rs | 142 ++++++++++++++++++ desktop/src-tauri/src/tray_menu.rs | 6 + .../app/navigation/backForwardChords.test.mjs | 139 +++++++++++++++++ .../src/app/navigation/backForwardChords.ts | 51 +++++++ .../app/navigation/useBackForwardControls.ts | 81 +++++----- desktop/tests/e2e/navigation.spec.ts | 31 ++++ 8 files changed, 412 insertions(+), 42 deletions(-) create mode 100644 desktop/src-tauri/src/mouse_nav.rs create mode 100644 desktop/src/app/navigation/backForwardChords.test.mjs create mode 100644 desktop/src/app/navigation/backForwardChords.ts diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index cf024b22847..1ca7075f118 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1044,6 +1044,7 @@ dependencies = [ "audioadapter-buffers", "axum", "base64 0.22.1", + "block2", "buzz-agent", "buzz-core", "buzz-media", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0b556347761..fd58f27878a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -46,8 +46,9 @@ notify-rust = "4" webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } [target.'cfg(target_os = "macos")'.dependencies] +block2 = { version = "0.6", default-features = false, features = ["std"] } objc2 = { version = "0.6.4", default-features = false } -objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem"] } +objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } diff --git a/desktop/src-tauri/src/mouse_nav.rs b/desktop/src-tauri/src/mouse_nav.rs new file mode 100644 index 00000000000..cd729f73049 --- /dev/null +++ b/desktop/src-tauri/src/mouse_nav.rs @@ -0,0 +1,142 @@ +//! Native macOS handler for back/forward navigation inputs (mouse X1/X2 +//! buttons and horizontal swipe gestures). +//! +//! WKWebView never delivers these inputs to the web content layer, so a DOM +//! listener can't see them (Safari itself handles them natively in the app +//! layer, not in the page). This module installs an NSEvent local monitor +//! and emits a `mouse-nav` Tauri event that `useBackForwardControls` acts on +//! in the frontend. Two event shapes map to navigation: +//! +//! - `otherMouseUp` with button 3/4 — mice whose X1/X2 buttons reach the app +//! as plain mouse buttons. +//! - `swipe` with a horizontal delta — AppKit's page-swipe gesture +//! (`swipeWithEvent:`): `deltaX > 0` is back, `deltaX < 0` is forward. +//! Sent by mouse drivers that synthesize a page-swipe gesture for the +//! back/forward buttons instead of button-3/4 events (the hardware this +//! was verified on). Stock Apple trackpad and Magic Mouse swipes arrive +//! as phased scroll-wheel events instead, which this module does not +//! handle — that path (`ScrollWheel` + `trackSwipeEventWithOptions:`, +//! which also needs scroll-edge detection) is a follow-up. +//! +//! Compiled macOS-only (via `tray_menu`). Non-macOS X1/X2 behavior is left +//! to the underlying webview. + +/// Maps an `otherMouseUp` button number to a navigation direction. +/// Buttons 3 and 4 are X1 (back) and X2 (forward). +fn direction_for_button(button: isize) -> Option<&'static str> { + match button { + 3 => Some("back"), + 4 => Some("forward"), + _ => None, + } +} + +/// Maps a swipe gesture's horizontal delta to a navigation direction, +/// following the AppKit `swipeWithEvent:` convention: positive is back, +/// negative is forward. A swipe arrives as a begin/end pair and only the +/// end event carries the direction, so `deltaX == 0` maps to `None`. +fn direction_for_swipe(delta_x: f64) -> Option<&'static str> { + if delta_x > 0.0 { + Some("back") + } else if delta_x < 0.0 { + Some("forward") + } else { + None + } +} + +pub fn init(app_handle: &tauri::AppHandle) { + use block2::RcBlock; + use objc2_app_kit::{NSEvent, NSEventMask, NSEventType}; + use tauri::Emitter; + + let app = app_handle.clone(); + let block = RcBlock::new(move |event: std::ptr::NonNull| -> *mut NSEvent { + // SAFETY: the monitor hands us a valid NSEvent for the matched mask. + let ev = unsafe { event.as_ref() }; + + match ev.r#type() { + NSEventType::OtherMouseUp => { + if let Some(direction) = direction_for_button(ev.buttonNumber()) { + // Emit to the main window explicitly instead of + // broadcasting (`emit`) so navigation stays scoped if + // multi-window ever lands. "main" is the default label + // for the single configured window (see deep_link.rs). + let _ = app.emit_to("main", "mouse-nav", direction); + // Swallow the release: nothing downstream should also act + // on it. The matching press deliberately passes through: + // WKWebView never delivers X1/X2 to the page, so the + // unmatched down is inert, and swallowing presses risks + // interfering with AppKit behaviors keyed off mouse-down. + return std::ptr::null_mut(); + } + } + NSEventType::Swipe => { + if let Some(direction) = direction_for_swipe(ev.deltaX()) { + let _ = app.emit_to("main", "mouse-nav", direction); + } + // Pass swipes through: nothing else navigates on them, and + // swallowing mid-gesture events could confuse AppKit's + // gesture tracking. + } + _ => {} + } + + event.as_ptr() + }); + + // SAFETY: the block returns either null or the pointer it was given, both + // valid per the monitor contract. The returned monitor token is + // deliberately leaked: the monitor must live for the whole app lifetime. + let monitor = unsafe { + NSEvent::addLocalMonitorForEventsMatchingMask_handler( + NSEventMask::OtherMouseUp | NSEventMask::Swipe, + &block, + ) + }; + + if let Some(monitor) = monitor { + std::mem::forget(monitor); + } else { + eprintln!("buzz-desktop: mouse-nav: failed to install NSEvent monitor"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn button_3_is_back() { + assert_eq!(direction_for_button(3), Some("back")); + } + + #[test] + fn button_4_is_forward() { + assert_eq!(direction_for_button(4), Some("forward")); + } + + #[test] + fn other_buttons_do_not_navigate() { + for button in [0, 1, 2, 5, -1] { + assert_eq!(direction_for_button(button), None); + } + } + + #[test] + fn positive_swipe_delta_is_back() { + assert_eq!(direction_for_swipe(1.0), Some("back")); + assert_eq!(direction_for_swipe(0.5), Some("back")); + } + + #[test] + fn negative_swipe_delta_is_forward() { + assert_eq!(direction_for_swipe(-1.0), Some("forward")); + assert_eq!(direction_for_swipe(-0.5), Some("forward")); + } + + #[test] + fn zero_delta_swipe_begin_event_is_ignored() { + assert_eq!(direction_for_swipe(0.0), None); + } +} diff --git a/desktop/src-tauri/src/tray_menu.rs b/desktop/src-tauri/src/tray_menu.rs index d733cd1f13d..6dcef0ecf78 100644 --- a/desktop/src-tauri/src/tray_menu.rs +++ b/desktop/src-tauri/src/tray_menu.rs @@ -3,6 +3,11 @@ //! The webview owns the live agent-turn state. It sends the small display //! projection here so the native menu can remain useful while Buzz is hidden. +// Mouse back/forward (X1/X2 buttons and swipe) is also macOS-only native I/O; +// group it here so both platform-layer init paths share one call site in lib.rs. +#[path = "mouse_nav.rs"] +pub(crate) mod mouse_nav; + use std::{ sync::{Mutex, OnceLock}, time::{Duration, Instant}, @@ -488,6 +493,7 @@ pub fn init(app: &AppHandle) -> tauri::Result<()> { if let Err(error) = apply_activity_presentation(&tray, activities, recent_activities) { eprintln!("buzz-desktop: failed to apply tray menu presentation: {error}"); } + mouse_nav::init(app); Ok(()) } diff --git a/desktop/src/app/navigation/backForwardChords.test.mjs b/desktop/src/app/navigation/backForwardChords.test.mjs new file mode 100644 index 00000000000..cb60203d044 --- /dev/null +++ b/desktop/src/app/navigation/backForwardChords.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { matchBackForwardChord } from "./backForwardChords.ts"; + +function chord(overrides = {}) { + return { + altKey: false, + code: "", + ctrlKey: false, + key: "", + metaKey: false, + shiftKey: false, + ...overrides, + }; +} + +// ── macOS: ⌘[ / ⌘] ─────────────────────────────────────────────────────────── + +test("mac: ⌘[ matches back", () => { + assert.equal( + matchBackForwardChord(chord({ key: "[", metaKey: true }), true), + "back", + ); +}); + +test("mac: ⌘] matches forward", () => { + assert.equal( + matchBackForwardChord(chord({ key: "]", metaKey: true }), true), + "forward", + ); +}); + +test("mac: matches by code for non-US layouts", () => { + assert.equal( + matchBackForwardChord( + chord({ code: "BracketLeft", key: "Dead", metaKey: true }), + true, + ), + "back", + ); + assert.equal( + matchBackForwardChord( + chord({ code: "BracketRight", key: "Dead", metaKey: true }), + true, + ), + "forward", + ); +}); + +test("mac: requires meta", () => { + assert.equal(matchBackForwardChord(chord({ key: "[" }), true), null); +}); + +test("mac: rejects extra modifiers", () => { + for (const extra of [ + { altKey: true }, + { ctrlKey: true }, + { shiftKey: true }, + ]) { + assert.equal( + matchBackForwardChord(chord({ key: "[", metaKey: true, ...extra }), true), + null, + ); + } +}); + +test("mac: Alt+arrows do not match (that is the win/linux chord)", () => { + assert.equal( + matchBackForwardChord(chord({ altKey: true, key: "ArrowLeft" }), true), + null, + ); +}); + +test("mac: ⌘←/⌘→ never match — they are line start/end in text editing", () => { + // Deliberately unbound: editable targets must keep receiving ⌘←/⌘→ so + // line-start/line-end editing still works. Only ⌘[ / ⌘] navigate. + assert.equal( + matchBackForwardChord(chord({ key: "ArrowLeft", metaKey: true }), true), + null, + ); + assert.equal( + matchBackForwardChord(chord({ key: "ArrowRight", metaKey: true }), true), + null, + ); +}); + +// ── Windows/Linux: Alt+← / Alt+→ ───────────────────────────────────────────── + +test("win/linux: Alt+ArrowLeft matches back", () => { + assert.equal( + matchBackForwardChord(chord({ altKey: true, key: "ArrowLeft" }), false), + "back", + ); +}); + +test("win/linux: Alt+ArrowRight matches forward", () => { + assert.equal( + matchBackForwardChord(chord({ altKey: true, key: "ArrowRight" }), false), + "forward", + ); +}); + +test("win/linux: requires alt", () => { + assert.equal(matchBackForwardChord(chord({ key: "ArrowLeft" }), false), null); +}); + +test("win/linux: rejects extra modifiers", () => { + for (const extra of [ + { ctrlKey: true }, + { metaKey: true }, + { shiftKey: true }, + ]) { + assert.equal( + matchBackForwardChord( + chord({ altKey: true, key: "ArrowLeft", ...extra }), + false, + ), + null, + ); + } +}); + +test("win/linux: ⌘[ does not match (that is the mac chord)", () => { + assert.equal( + matchBackForwardChord(chord({ key: "[", metaKey: true }), false), + null, + ); +}); + +// ── Non-chord keys never match ──────────────────────────────────────────────── + +test("plain bracket / arrow keys without the platform modifier never match", () => { + for (const isMac of [true, false]) { + for (const key of ["[", "]", "ArrowLeft", "ArrowRight", "a", "Enter"]) { + assert.equal(matchBackForwardChord(chord({ key }), isMac), null); + } + } +}); diff --git a/desktop/src/app/navigation/backForwardChords.ts b/desktop/src/app/navigation/backForwardChords.ts new file mode 100644 index 00000000000..ac81f52ea75 --- /dev/null +++ b/desktop/src/app/navigation/backForwardChords.ts @@ -0,0 +1,51 @@ +/** + * Global back/forward navigation chords. + * + * macOS: ⌘[ / ⌘] — matching Safari, Chrome, Finder, and Slack. + * Windows/Linux: Alt+← / Alt+→ — matching browsers and Slack. + * + * Kept pure (no DOM access) so chord matching can be unit tested; the + * window listener wiring lives in `useBackForwardControls`. + */ + +export type BackForwardDirection = "back" | "forward"; + +export type BackForwardChordEvent = Pick< + KeyboardEvent, + "altKey" | "code" | "ctrlKey" | "key" | "metaKey" | "shiftKey" +>; + +export function matchBackForwardChord( + event: BackForwardChordEvent, + isMac: boolean, +): BackForwardDirection | null { + if (isMac) { + if (!event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) { + return null; + } + + if (event.key === "[" || event.code === "BracketLeft") { + return "back"; + } + + if (event.key === "]" || event.code === "BracketRight") { + return "forward"; + } + + return null; + } + + if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) { + return null; + } + + if (event.key === "ArrowLeft") { + return "back"; + } + + if (event.key === "ArrowRight") { + return "forward"; + } + + return null; +} diff --git a/desktop/src/app/navigation/useBackForwardControls.ts b/desktop/src/app/navigation/useBackForwardControls.ts index 7f7d84f6d72..e5513247d50 100644 --- a/desktop/src/app/navigation/useBackForwardControls.ts +++ b/desktop/src/app/navigation/useBackForwardControls.ts @@ -4,7 +4,10 @@ import { useRouter, useRouterState, } from "@tanstack/react-router"; +import { isTauri } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { matchBackForwardChord } from "@/app/navigation/backForwardChords"; import { isMacPlatform } from "@/shared/lib/platform"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; @@ -14,19 +17,6 @@ type RouterHistoryState = { key?: string; }; -function isEditableTarget(target: EventTarget | null): boolean { - if (!(target instanceof HTMLElement)) { - return false; - } - - return ( - target.isContentEditable || - target.closest( - 'input, textarea, select, [contenteditable=""], [contenteditable="true"]', - ) !== null - ); -} - export function useBackForwardControls() { const router = useRouter(); const canGoBack = useCanGoBack(); @@ -81,42 +71,34 @@ export function useBackForwardControls() { }, [canGoForward, router.history]); const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => { - if (isEditableTarget(event.target)) { + // Note: the chords deliberately fire even when focus is inside an + // editable element. The composer autofocuses on every channel switch + // (`useComposerAutofocus`), so in steady state focus almost always + // lives in a contenteditable — an editable-target guard here made the + // shortcuts effectively dead (#3775). Safe because neither ⌘[ / ⌘] + // (macOS) nor Alt+←/→ (Windows/Linux) carry text-editing semantics, + // and the TipTap editor binds no conflicting shortcuts. + const direction = matchBackForwardChord(event, isMacPlatform()); + + if (direction === "back") { + event.preventDefault(); + goBack(); return; } - const isMac = isMacPlatform(); - const isBackShortcut = isMac - ? event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.key === "[" || event.code === "BracketLeft") - : event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - event.key === "ArrowLeft"; - const isForwardShortcut = isMac - ? event.metaKey && - !event.ctrlKey && - !event.altKey && - !event.shiftKey && - (event.key === "]" || event.code === "BracketRight") - : event.altKey && - !event.metaKey && - !event.ctrlKey && - !event.shiftKey && - event.key === "ArrowRight"; - - if (isBackShortcut) { + if (direction === "forward") { event.preventDefault(); + goForward(); + } + }); + + const handleMouseNav = React.useEffectEvent((direction: string) => { + if (direction === "back") { goBack(); return; } - if (isForwardShortcut) { - event.preventDefault(); + if (direction === "forward") { goForward(); } }); @@ -128,6 +110,23 @@ export function useBackForwardControls() { }; }, []); + // macOS: WKWebView never delivers X1/X2 button events or horizontal + // swipe gestures to the DOM, so the native layer catches them + // (`mouse_nav.rs`) and forwards them as a Tauri event. + React.useEffect(() => { + if (!isTauri()) { + return; + } + + const unlistenPromise = listen("mouse-nav", (event) => { + handleMouseNav(event.payload); + }); + + return () => { + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, []); + return { canGoBack, canGoForward, diff --git a/desktop/tests/e2e/navigation.spec.ts b/desktop/tests/e2e/navigation.spec.ts index f7a96cd568b..18db55a50ab 100644 --- a/desktop/tests/e2e/navigation.spec.ts +++ b/desktop/tests/e2e/navigation.spec.ts @@ -48,6 +48,37 @@ test("global back and forward move across channel routes", async ({ page }) => { await expect(page.getByTestId("chat-title")).toHaveText("random"); }); +test("back/forward keyboard chords work while the composer has focus", async ({ + page, +}) => { + const backChord = process.platform === "darwin" ? "Meta+[" : "Alt+ArrowLeft"; + const forwardChord = + process.platform === "darwin" ? "Meta+]" : "Alt+ArrowRight"; + + await page.goto("/"); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + // The composer autofocuses on channel switch; make the regression + // condition explicit by clicking into it. The chords must still fire + // from inside the contenteditable (#3775). + await page.getByTestId("message-input").click(); + await expect(page.getByTestId("message-input")).toBeFocused(); + + await page.keyboard.press(backChord); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.keyboard.press(forwardChord); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + // preventDefault kept the chord out of the editor — no stray characters. + await expect(page.getByTestId("message-input")).toHaveText(""); +}); + // FIXME: the forum post "Back to posts" header renders under the fixed top // chrome drag region, which intercepts the click. Pre-existing breakage — // this spec file was never registered in playwright.config.ts until now. From 318fbf896ec335bc7bcb40edafde0b6ebca53428 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:17:55 -0400 Subject: [PATCH 007/163] fix(security): bump nostr crates for RUSTSEC-2026-0225..0232 + default sprig image to published digest (#4392) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two changes, both fallout/follow-up from #4289 landing: ### 1. Fix the Security job failing on main (lockfile-only) Eight RUSTSEC advisories published today against the nostr stack turned `cargo-deny check` advisories red on main ([failing run](https://github.com/block/buzz/actions/runs/30761611723/job/91533106673)). Not introduced by #4289 — the advisories landed upstream and any push to main today would have tripped them. - **RUSTSEC-2026-0225..0230** → `nostr` 0.44.6 → **0.44.7** (Debug output exposing NIP-46/NIP-60 credentials; wallet parsers accepting unauthenticated events; NIP-44/NIP-04/NIP-98 resource exhaustion; NIP-50 empty-filter panic) - **RUSTSEC-2026-0231..0232** → `nostr-relay-pool` 0.44.2 (root) / 0.44.1 (tauri) → **0.44.3** (auth-challenge memory exhaustion; processing of unverified relay events) Both workspace lockfiles bumped (`Cargo.lock`, `desktop/src-tauri/Cargo.lock`). No manifest changes. ### 2. Default the desktop GUI's sprig image to the published `ghcr.io/block/buzz-sprig` The first main-push after #4289 published the image publicly (package created 18:44Z, visibility `public`). The `config_schema()`'s `image` property now carries a `default`: ``` ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76 ``` **Why tag+digest, not tag:** the backend deliberately rejects tag-only references — the pod runs with the agent's nsec and tags are mutable pointers (`image.rs` §Image). The tag+digest form keeps the human-traceable `sha-6530b58` while the digest does the pinning; `image::parse` already normalizes it to the tagless canonical form, so create-intent fingerprints are identical to the bare-digest spelling. The digest is the **multi-arch manifest-list digest** (amd64+arm64), resolved via `docker buildx imagetools inspect`. **This is a UI prefill, not a baked fallback:** `image` stays in the schema's `required` list, an empty value still fails closed with a named field, and the desktop submits the value explicitly in `provider_config` (the `WhereToRunSection` probe seeds `providerConfig` from schema defaults) — so deploy fingerprints never depend on compiled-in provider state, and the spec's §K8s pod-reconciliation concern about baked-default divergence is not engaged. Module prose that said "no published image exists yet" is updated to match reality. No desktop code changes needed: the form already prefills from `properties[*].default` and submits seeded defaults. ## Testing - `cargo-deny check` at head: **advisories ok, bans ok, licenses ok, sources ok** (was: advisories FAILED) - `cargo test -p buzz-backend-kubernetes`: **158 passed** (154 lib + 4 wire), including new `schema_default_image_round_trips_through_parse` pinning the constant + its normalization, and the wire `info` test now asserting the default is present in the provider's real stdout response - Live provider probe: `{"op":"info"}` against the built binary returns the default in `config_schema.properties.image.default` with `required` unchanged (`["namespace","image"]`) - Full workspace test suite via pre-push hook: green (earlier direct `cargo test --workspace` run: sole failure was `api::mesh_demo::demo_join_forwarded_arm_round_trips_echo`, the documented pre-existing main flake — unrelated, fails on base) - Image existence verified against GHCR: `docker buildx imagetools inspect ghcr.io/block/buzz-sprig:sha-6530b58` resolves to the pinned manifest-list digest with linux/amd64 + linux/arm64 manifests --------- Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- Cargo.lock | 8 ++--- Justfile | 30 +++++++++++++---- crates/buzz-backend-kubernetes/src/config.rs | 33 +++++++++++++++++-- crates/buzz-backend-kubernetes/src/image.rs | 17 ++++++---- .../tests/wire_fixtures.rs | 8 +++++ desktop/src-tauri/Cargo.lock | 8 ++--- 6 files changed, 80 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 62bcea0cae2..937ead564a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5809,9 +5809,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.6" +version = "0.44.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" +checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", "bech32", @@ -5853,9 +5853,9 @@ dependencies = [ [[package]] name = "nostr-relay-pool" -version = "0.44.2" +version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb94d61a467a869a6790b907838a9bea82c813d567d9fcbac995f207be8cee4b" +checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" dependencies = [ "async-utility", "async-wsocket", diff --git a/Justfile b/Justfile index 8dbe125a7d8..d6e86c8d099 100644 --- a/Justfile +++ b/Justfile @@ -526,11 +526,20 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=(--features mesh-llm) export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi - # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up. + # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. + # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the + # exe dir for executable buzz-backend-* files, so the non-executable stub that + # tauri dev copies next to the exe would hide the provider from "Run on". 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") - cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}" - chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}" + STAGING_SIDECARS=(buzz) + if [[ "$TARGET" != *windows* ]]; then + STAGING_SIDECARS+=(buzz-backend-kubernetes) + fi + for bin in "${STAGING_SIDECARS[@]}"; do + cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" + chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" + done cd {{desktop_dir}} export BUZZ_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co" source ../scripts/instance-env.sh @@ -553,11 +562,20 @@ production *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=(--features mesh-llm) export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi - # Replace the 0-byte sidecar stub with the real CLI binary so tauri dev picks it up. + # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. + # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the + # exe dir for executable buzz-backend-* files, so the non-executable stub that + # tauri dev copies next to the exe would hide the provider from "Run on". 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") - cp "${TARGET_DIR}/release/buzz" "desktop/src-tauri/binaries/buzz-${TARGET}" - chmod +x "desktop/src-tauri/binaries/buzz-${TARGET}" + PRODUCTION_SIDECARS=(buzz) + if [[ "$TARGET" != *windows* ]]; then + PRODUCTION_SIDECARS+=(buzz-backend-kubernetes) + fi + for bin in "${PRODUCTION_SIDECARS[@]}"; do + cp "${TARGET_DIR}/release/${bin}" "desktop/src-tauri/binaries/${bin}-${TARGET}" + chmod +x "desktop/src-tauri/binaries/${bin}-${TARGET}" + done cd {{desktop_dir}} export BUZZ_RELAY_URL="wss://buzz.block.builderlab.xyz" source ../scripts/instance-env.sh diff --git a/crates/buzz-backend-kubernetes/src/config.rs b/crates/buzz-backend-kubernetes/src/config.rs index 39b68ce0f2c..4d96735b7b5 100644 --- a/crates/buzz-backend-kubernetes/src/config.rs +++ b/crates/buzz-backend-kubernetes/src/config.rs @@ -1,8 +1,9 @@ //! `provider_config` parsing and the `info` config schema //! (spec §`provider_config` v1 fields, `docs/remote-agents.md:1384-1389`). //! -//! Nine fields, all optional except `image` (v1 ships no baked default — -//! §Image). No credential field exists, by I2: cluster auth comes from ambient +//! Nine fields, all optional except `image` (required at parse time; the +//! schema offers the published sprig image as a prefill default — §Image). +//! No credential field exists, by I2: cluster auth comes from ambient //! kubeconfig resolution and nothing else (`:196-198`). use crate::image::{self, ImageRef}; @@ -33,6 +34,15 @@ impl Default for Resources { /// `BUZZ_ACP_EXIT_AFTER_INACTIVITY` are one knob, not two. pub const DEFAULT_INACTIVITY_SECONDS: u64 = 7200; +/// Default `image` schema prefill: the published sprig image, in tag+digest +/// form so the tag stays human-traceable to its git SHA while the digest does +/// the pinning (§Image — tag-only refs are rejected; `image::parse` drops the +/// tag on normalization). This is a UI prefill, not a baked fallback: `image` +/// stays required, an empty value still fails closed, and the value always +/// arrives explicitly in `provider_config`, so create-intent fingerprints are +/// unaffected by provider upgrades. +pub const DEFAULT_IMAGE: &str = "ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76"; + /// Fixed nonzero UID/GID for the agent container (§Pod shape hardening). pub const RUN_AS_UID: i64 = 10001; pub const RUN_AS_GID: i64 = 10001; @@ -201,7 +211,8 @@ pub fn config_schema() -> serde_json::Value { "image": { "type": "string", "title": "Agent image", - "description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:. Tags are not accepted: this pod holds the agent's private key." + "description": "Digest-pinned image containing the buzz-acp runtime ABI, e.g. ghcr.io/block/buzz-sprig@sha256:. Tags alone are not accepted: this pod holds the agent's private key.", + "default": DEFAULT_IMAGE }, "cpu_request": { "type": "string", "title": "CPU request", "default": defaults.cpu_request @@ -396,6 +407,22 @@ mod tests { assert_eq!(parse(&cfg).unwrap().namespace, default); } + /// Same guarantee for the image prefill: the schema's default must be a + /// value `image::parse` accepts, or the UI prefills a form that fails on + /// submit. Its tag+digest form normalizes to the tagless canonical form. + #[test] + fn schema_default_image_round_trips_through_parse() { + let schema = config_schema(); + let default = schema["properties"]["image"]["default"].as_str().unwrap(); + assert_eq!(default, DEFAULT_IMAGE); + let cfg = serde_json::json!({"namespace": "buzz-agents-abc123", "image": default}); + let parsed = parse(&cfg).unwrap(); + assert_eq!( + parsed.image.as_str(), + "ghcr.io/block/buzz-sprig@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76" + ); + } + /// Nine fields exactly (§`provider_config` v1 fields). The cap is 20; the /// count is pinned so a field added without a spec change is caught here. #[test] diff --git a/crates/buzz-backend-kubernetes/src/image.rs b/crates/buzz-backend-kubernetes/src/image.rs index b35e6bab121..409989fda94 100644 --- a/crates/buzz-backend-kubernetes/src/image.rs +++ b/crates/buzz-backend-kubernetes/src/image.rs @@ -5,9 +5,11 @@ //! distinguishes them from digests for exactly this reason — so a tag-only //! reference is rejected, not just `:latest`. //! -//! v1 ships no baked default (there is no published `ghcr.io/block/buzz-sprig` -//! image yet, so a compile-time digest would be a placeholder). `image` is -//! therefore required, and its absence fails closed with a named field. +//! There is no parse-time fallback: `image` is required, and its absence +//! fails closed with a named field. The published `ghcr.io/block/buzz-sprig` +//! digest is offered only as a schema `default` (a UI prefill the desktop +//! submits explicitly — see `config::DEFAULT_IMAGE`), so the create-intent +//! fingerprint never depends on compiled-in provider state. /// A validated, digest-qualified image reference. /// @@ -35,9 +37,9 @@ impl std::fmt::Display for ImageRef { pub fn parse(raw: &str) -> Result { let reference = raw.trim(); if reference.is_empty() { - return Err("provider_config.image is required: v1 ships no default \ - image, so the digest-pinned image to run must be given \ - explicitly" + return Err("provider_config.image is required: no image is assumed \ + at parse time, so the digest-pinned image to run must be \ + given explicitly" .to_string()); } @@ -169,7 +171,8 @@ mod tests { } } - /// v1 has no baked default, so an absent image is an error that names the + /// Parsing has no fallback (the schema default is a UI prefill, not a + /// parse-time substitute), so an absent image is an error that names the /// field rather than a silent fallback. #[test] fn empty_reference_names_the_field() { diff --git a/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs index 2c3af82b1f2..b3049a98ef3 100644 --- a/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs +++ b/crates/buzz-backend-kubernetes/tests/wire_fixtures.rs @@ -114,6 +114,14 @@ fn info_response_carries_the_contract_fields() { default.starts_with("buzz-agents-"), "unexpected namespace default: {default}" ); + let image_default = schema["properties"]["image"]["default"] + .as_str() + .expect("no image default"); + assert!( + image_default.starts_with("ghcr.io/block/buzz-sprig:") + && image_default.contains("@sha256:"), + "unexpected image default: {image_default}" + ); } /// The desktop's richest payload must parse. No response fixture: this one diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 1ca7075f118..9feecbee01c 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -6124,9 +6124,9 @@ dependencies = [ [[package]] name = "nostr" -version = "0.44.6" +version = "0.44.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" +checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", "bech32", @@ -6168,9 +6168,9 @@ dependencies = [ [[package]] name = "nostr-relay-pool" -version = "0.44.1" +version = "0.44.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91b2c039df4f96c4bf7dae52a74fd5516ad6dda83a11c0c69dea91b5255a4f37" +checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" dependencies = [ "async-utility", "async-wsocket", From 7ff5fc31895efe6265a379d01637c8ee301872e5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 2 Aug 2026 19:10:07 -0400 Subject: [PATCH 008/163] feat(acp): deliver system prompt via _meta.systemPrompt for claude-agent-acp (#4395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claude-agent-acp` (since v0.6.0 / PR #91) accepts `_meta.systemPrompt: {append: text}` on `session/new` to append to the adapter's native preset while keeping its tool-use prompt intact — the same non-standard extension pattern as `_session/steering` was before it was standardised. ## What changes **Rust (`crates/buzz-acp/`)** - Adds `SystemPromptTransport` enum to `acp.rs`: `Field(&str)` (ACP protocol v2, unchanged) vs `ClaudeMeta(&str)` (new `_meta.systemPrompt: {append: text}`). When both `ClaudeMeta` and `session_title` are present the two `_meta` members are merged into one object so neither clobbers the other. - Gates on exact adapter identity `@agentclientprotocol/claude-agent-acp` in `pool.rs`: `session_new_system_prompt()` routes that name to `ClaudeMeta` regardless of reported `protocolVersion` (CC declares v1). `has_system_prompt_support()` gains the same name check so user-message `[Base]`/`[System]` framing is suppressed for CC sessions. - All other paths — goose post-hoc method, protocol-v2 `Field`, legacy user-message framing — are byte-identical to before. **Desktop (`desktop/src/features/agents/ui/`)** - `agentSessionTranscript.ts`: the `session/new` extractor now checks `params._meta.systemPrompt.append` as a fallback when bare `params.systemPrompt` is absent. Bare field takes precedence. Net line count stays at 1173 (ratchet limit). - `agentSessionTranscript.test.mjs`: two new tests — one verifying the `_meta` transport produces the identical standalone card (same five sections, same `turnId: null`, same placement before the first turn) as the bare-field transport; one proving bare field wins when both transports are present. ## Gate claim `@agentclientprotocol/claude-agent-acp` implies `_meta.systemPrompt` support because the feature landed in v0.6.0 (Oct 2025, commit `ea796f3`) before the `@zed-industries/claude-code-acp` → `@agentclientprotocol/claude-agent-acp` package rename (Mar 2026, commit `b409782`). The new name is therefore a reliable capability gate; the old name falls through to the protocol-version gate (status quo, no regression). ## Tests - Rust: Claude append serialization; `_meta` coexistence with `sessionTitle`; protocol-v2 bare field byte-identical; codex/old-zed omission; claude-name support/suppression gate; old `@zed-industries` name falls through to protocol-version gate. - Desktop: `_meta` transport → identical standalone card; bare field wins over `_meta` when both present. ## Pre-existing failures `just mobile-check` and `just mobile-test` fail identically on clean `origin/main` (5 `compose_bar` / `channels_page` tests + 3 Flutter lint warnings) — not caused by this change. All other `just ci` jobs are green. Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- crates/buzz-acp/src/acp.rs | 137 +++++++++++- crates/buzz-acp/src/pool.rs | 62 +++++- .../agents/ui/agentSessionTranscript.test.mjs | 198 ++++++++++++++++++ .../agents/ui/agentSessionTranscript.ts | 14 +- 4 files changed, 386 insertions(+), 25 deletions(-) diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a03..700d5e8dcfd 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -619,29 +619,46 @@ impl AcpClient { /// Send `session/new` and return the full response alongside the session ID. /// /// `cwd` must be an absolute path. `mcp_servers` may be empty. - /// `system_prompt` is included in the request when `Some` — agents that - /// support the field will use it; others ignore unknown fields per JSON-RPC. + /// + /// `system_prompt` controls how the prompt text is delivered: + /// + /// - `None` — no system-prompt field in the request (legacy framing). + /// - `Some(SystemPromptTransport::Field(text))` — bare `systemPrompt` field + /// (ACP protocol v2, buzz-agent, goose unused). + /// - `Some(SystemPromptTransport::ClaudeMeta(text))` — `_meta.systemPrompt` + /// as `{"append": text}`, keeping claude-agent-acp's native preset intact. + /// /// `session_title` rides in `_meta.sessionTitle` when `Some`; `_meta` is /// omitted entirely otherwise, since adapters may distinguish an absent - /// member from a null one. + /// member from a null one. When both `ClaudeMeta` and `session_title` are + /// present the two `_meta` members are merged into a single object. + /// /// Callers use [`extract_model_config_options`] and [`extract_model_state`] /// to pull model info from the raw result. pub async fn session_new_full( &mut self, cwd: &str, mcp_servers: Vec, - system_prompt: Option<&str>, + system_prompt: Option>, session_title: Option<&str>, ) -> Result { let mut params = serde_json::json!({ "cwd": cwd, "mcpServers": mcp_servers, }); - if let Some(sp) = system_prompt { - params["systemPrompt"] = serde_json::Value::String(sp.to_owned()); + match system_prompt { + Some(SystemPromptTransport::Field(sp)) => { + params["systemPrompt"] = serde_json::Value::String(sp.to_owned()); + } + Some(SystemPromptTransport::ClaudeMeta(sp)) => { + // Merge into _meta so sessionTitle (set below) is not clobbered. + params["_meta"]["systemPrompt"] = serde_json::json!({ "append": sp }); + } + None => {} } if let Some(title) = session_title { - params["_meta"] = serde_json::json!({ "sessionTitle": title }); + // Merge — _meta may already carry systemPrompt from ClaudeMeta above. + params["_meta"]["sessionTitle"] = serde_json::Value::String(title.to_owned()); } let result = self.send_request("session/new", params).await?; let session_id = result["sessionId"] @@ -663,7 +680,7 @@ impl AcpClient { &mut self, cwd: &str, mcp_servers: Vec, - system_prompt: Option<&str>, + system_prompt: Option>, session_title: Option<&str>, ) -> Result { Ok(self @@ -2038,6 +2055,22 @@ pub struct SessionNewResponse { pub raw: serde_json::Value, } +/// How to deliver a system prompt on `session/new`. +/// +/// The two variants match the two mechanisms supported by current adapters: +/// +/// - **`Field`** — bare `systemPrompt` field (ACP protocol v2, buzz-agent). +/// - **`ClaudeMeta`** — `_meta.systemPrompt: {"append": text}`, used by +/// `claude-agent-acp` to append to the adapter's own native system prompt +/// while keeping its tool-use preset intact. +#[derive(Debug, Clone, PartialEq)] +pub enum SystemPromptTransport<'a> { + /// Deliver as a bare top-level `systemPrompt` field. + Field(&'a str), + /// Deliver as `_meta.systemPrompt: {"append": text}`. + ClaudeMeta(&'a str), +} + /// How to switch to a particular model on a session. #[derive(Debug, Clone, PartialEq, serde::Serialize)] #[serde(tag = "type")] @@ -3271,7 +3304,12 @@ mod tests { .expect("initialize should succeed"); let resp = client - .session_new_full("/tmp", vec![], Some("Custom system prompt"), None) + .session_new_full( + "/tmp", + vec![], + Some(SystemPromptTransport::Field("Custom system prompt")), + None, + ) .await .expect("session_new_full should succeed"); @@ -3423,6 +3461,87 @@ mod tests { ); } + // ── claude-agent-acp _meta.systemPrompt transport ───────────────────── + + #[tokio::test] + async fn session_new_full_sends_claude_meta_system_prompt_when_claude_meta_transport() { + // When ClaudeMeta transport is requested, the prompt must appear as + // _meta.systemPrompt: {"append": text} — never as a bare systemPrompt field. + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_claude","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + + let resp = client + .session_new_full( + "/tmp", + vec![], + Some(SystemPromptTransport::ClaudeMeta("Be concise")), + None, + ) + .await + .expect("session_new_full should succeed"); + + let received = &resp.raw["_receivedRequest"]; + assert!( + received["params"].get("systemPrompt").is_none(), + "bare systemPrompt must not be present for ClaudeMeta transport" + ); + assert_eq!( + received["params"]["_meta"]["systemPrompt"]["append"].as_str(), + Some("Be concise"), + "_meta.systemPrompt.append must carry the prompt text" + ); + } + + #[tokio::test] + async fn session_new_full_merges_claude_meta_and_session_title_into_single_meta_object() { + // Both ClaudeMeta prompt and session_title must coexist under _meta — + // the prompt must not clobber sessionTitle or vice versa. + let script = r#" + read -t 2 _init + echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}' + read -t 2 REQ + echo '{"jsonrpc":"2.0","id":1,"result":{"sessionId":"ses_merged","_receivedRequest":'"$REQ"'}}' + sleep 1 + "#; + let mut client = spawn_script(script).await; + client + .initialize() + .await + .expect("initialize should succeed"); + + let resp = client + .session_new_full( + "/tmp", + vec![], + Some(SystemPromptTransport::ClaudeMeta("Be concise")), + Some("Fizz · #buzz-dev"), + ) + .await + .expect("session_new_full should succeed"); + + let received = &resp.raw["_receivedRequest"]; + assert_eq!( + received["params"]["_meta"]["systemPrompt"]["append"].as_str(), + Some("Be concise"), + "_meta.systemPrompt.append must be present" + ); + assert_eq!( + received["params"]["_meta"]["sessionTitle"].as_str(), + Some("Fizz · #buzz-dev"), + "_meta.sessionTitle must be present alongside systemPrompt" + ); + } + // ── Goose-native steer scaffold (PR follow-up to #1160) ────────────── /// Helper: spawn an inert `cat` subprocess so we have a real AcpClient diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 348bc138e41..64edf68ee26 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -32,6 +32,7 @@ use uuid::Uuid; use crate::acp::{ extract_model_config_options, extract_model_state, model_in_catalog, resolve_model_switch_method, AcpClient, AcpError, McpServer, ModelSwitchMethod, StopReason, + SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; use crate::observer; @@ -171,6 +172,13 @@ pub struct OwnedAgent { pub protocol_version: u32, } +/// Package name reported by `claude-agent-acp` in its `initialize` response. +/// Any adapter reporting this name supports `_meta.systemPrompt: {append: ...}` +/// on `session/new` — the feature landed in v0.6.0 (Oct 2025), before the +/// `@zed-industries/claude-code-acp` → `@agentclientprotocol/claude-agent-acp` +/// rename, so the new name is a reliable capability gate. +const CLAUDE_AGENT_ACP_NAME: &str = "@agentclientprotocol/claude-agent-acp"; + fn has_system_prompt_support( protocol_version: u32, agent_name: &str, @@ -178,20 +186,25 @@ fn has_system_prompt_support( ) -> bool { if agent_name == "goose" { goose_system_prompt_supported == Some(true) + } else if agent_name == CLAUDE_AGENT_ACP_NAME { + true } else { protocol_version >= 2 } } -fn session_new_system_prompt( +fn session_new_system_prompt<'a>( is_goose: bool, protocol_version: u32, - prompt: Option<&str>, -) -> Option<&str> { - if is_goose || protocol_version < 2 { + agent_name: &str, + prompt: Option<&'a str>, +) -> Option> { + if is_goose || (protocol_version < 2 && agent_name != CLAUDE_AGENT_ACP_NAME) { None + } else if agent_name == CLAUDE_AGENT_ACP_NAME { + prompt.map(SystemPromptTransport::ClaudeMeta) } else { - prompt + prompt.map(SystemPromptTransport::Field) } } @@ -907,6 +920,7 @@ async fn create_session_and_apply_model( session_new_system_prompt( is_goose, agent.protocol_version, + &agent.agent_name, combined_system_prompt.as_deref(), ), session_title.as_deref(), @@ -4003,18 +4017,48 @@ mod tests { assert!(has_system_prompt_support(2, "goose", Some(true))); assert!(has_system_prompt_support(1, "goose", Some(true))); assert!(has_system_prompt_support(2, "buzz-agent", None)); + // Goose never receives system prompt via session/new (uses post-hoc method). assert_eq!( - session_new_system_prompt(true, 2, Some("instructions")), + session_new_system_prompt(true, 2, "goose", Some("instructions")), None ); + // Protocol-v2 non-goose gets Field transport. assert_eq!( - session_new_system_prompt(false, 2, Some("instructions")), - Some("instructions") + session_new_system_prompt(false, 2, "buzz-agent", Some("instructions")), + Some(SystemPromptTransport::Field("instructions")) ); + // Protocol-v1 non-goose, non-claude gets None (legacy user-message framing). assert_eq!( - session_new_system_prompt(false, 1, Some("instructions")), + session_new_system_prompt(false, 1, "codex", Some("instructions")), None ); + // claude-agent-acp gets ClaudeMeta transport regardless of protocol version. + assert_eq!( + session_new_system_prompt(false, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")), + Some(SystemPromptTransport::ClaudeMeta("instructions")) + ); + assert_eq!( + session_new_system_prompt(true, 1, CLAUDE_AGENT_ACP_NAME, Some("instructions")), + None, + "goose path must never produce a transport even when agent_name matches" + ); + } + + #[test] + fn claude_agent_acp_has_system_prompt_support_regardless_of_protocol_version() { + // claude-agent-acp declares protocolVersion:1 but supports _meta.systemPrompt; + // has_system_prompt_support must return true so user-message framing is suppressed. + assert!(has_system_prompt_support(1, CLAUDE_AGENT_ACP_NAME, None)); + assert!(has_system_prompt_support(2, CLAUDE_AGENT_ACP_NAME, None)); + } + + #[test] + fn old_zed_adapter_name_falls_through_to_protocol_version_gate() { + // The renamed @zed-industries package predates the _meta.systemPrompt support, + // so it must not be treated as capable and stays on legacy user-message framing. + let old_name = "@zed-industries/claude-code-acp"; + assert!(!has_system_prompt_support(1, old_name, None)); + assert!(has_system_prompt_support(2, old_name, None)); } #[test] diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index cc6f0467d61..b4a139eb0ee 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -1879,3 +1879,201 @@ test("buildTranscript five-section system prompt card is standalone with all sec "prompt context must NOT contain system-prompt sections (Base/System/Team Instructions/Core Memory/Channel Canvas)", ); }); + +// --- claude-agent-acp _meta.systemPrompt.append transport --- + +test("buildTranscript session/new via _meta.systemPrompt.append produces identical standalone card as bare systemPrompt field", () => { + // claude-agent-acp delivers the system prompt at _meta.systemPrompt.append + // instead of the bare systemPrompt field. The observer must extract it and + // build the identical standalone card (same five sections, same acpSource, + // same turnId: null, same placement before the first turn). + const CH = "55555555-5555-5555-5555-555555555555"; + const SYSTEM_PROMPT = [ + "[Base]", + "You are a helpful assistant.", + "", + "[System]", + "Custom persona.", + "", + "---", + "# Team Instructions", + "Always tag on handoff.", + "", + "[Agent Memory \u2014 core]", + "I am Duncan.", + "", + "[Channel Canvas]", + "Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + "Last modified: 2026-07-01T10:00:00Z", + "Fetch current content with: buzz canvas get --channel 55555555-5555-5555-5555-555555555555", + ].join("\n"); + + const makeEvents = (params) => [ + { + seq: 1, + timestamp: "2026-07-01T10:00:00.000Z", + kind: "turn_started", + agentIndex: 0, + channelId: CH, + sessionId: null, + turnId: "turn-1", + payload: { source: "channel", triggeringEventIds: [] }, + }, + { + seq: 2, + timestamp: "2026-07-01T10:00:00.100Z", + kind: "acp_write", + agentIndex: 0, + channelId: CH, + sessionId: null, + turnId: "turn-1", + payload: { jsonrpc: "2.0", id: 1, method: "session/new", params }, + }, + { + seq: 3, + timestamp: "2026-07-01T10:00:00.200Z", + kind: "session_resolved", + agentIndex: 0, + channelId: CH, + sessionId: "sess-cc", + turnId: "turn-1", + payload: { sessionId: "sess-cc", isNewSession: true }, + }, + { + seq: 4, + timestamp: "2026-07-01T10:00:01.000Z", + kind: "acp_write", + agentIndex: 0, + channelId: CH, + sessionId: "sess-cc", + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + id: 2, + method: "session/prompt", + params: { + sessionId: "sess-cc", + prompt: [ + { + type: "text", + text: `[Buzz event: @mention]\nEvent ID: ${"a".repeat(64)}\nFrom: x (hex: ${"b".repeat(64)})\nContent: hello`, + }, + { type: "text", text: "[Thread context]\nPrior messages here." }, + ], + }, + }, + }, + ]; + + // Build transcript from the claude-agent-acp _meta transport. + const metaEvents = makeEvents({ + _meta: { systemPrompt: { append: SYSTEM_PROMPT } }, + }); + const metaRaw = buildTranscript(metaEvents); + const metaBlocks = buildTranscriptDisplayBlocks(metaRaw); + const metaFlat = flattenDisplayBlocks(metaBlocks); + + // Also build from the standard bare-field transport for comparison. + const fieldEvents = makeEvents({ systemPrompt: SYSTEM_PROMPT }); + const fieldRaw = buildTranscript(fieldEvents); + const fieldBlocks = buildTranscriptDisplayBlocks(fieldRaw); + + // (a) Both produce exactly one standalone system-prompt single block. + const metaSPBlocks = metaBlocks.filter( + (b) => b.kind === "single" && b.item?.acpSource === "session/new", + ); + const fieldSPBlocks = fieldBlocks.filter( + (b) => b.kind === "single" && b.item?.acpSource === "session/new", + ); + assert.equal( + metaSPBlocks.length, + 1, + "_meta: exactly one standalone system-prompt block", + ); + assert.equal( + fieldSPBlocks.length, + 1, + "field: exactly one standalone system-prompt block", + ); + + // (b) Both carry the same five ordered sections. + const EXPECTED_TITLES = [ + "Base", + "System", + "Team Instructions", + "Core Memory", + "Channel Canvas", + ]; + const metaTitles = (metaSPBlocks[0].item?.sections ?? []).map((s) => s.title); + const fieldTitles = (fieldSPBlocks[0].item?.sections ?? []).map( + (s) => s.title, + ); + assert.deepEqual( + metaTitles, + EXPECTED_TITLES, + "_meta: five sections in order", + ); + assert.deepEqual( + fieldTitles, + EXPECTED_TITLES, + "field: five sections in order", + ); + + // (c) System prompt appears before Prompt context in both display orders. + const metaSPIdx = metaFlat.findIndex((i) => i.title === "System prompt"); + const metaPCIdx = metaFlat.findIndex((i) => i.title === "Prompt context"); + assert.ok(metaSPIdx !== -1, "_meta: System prompt item present"); + assert.ok(metaPCIdx !== -1, "_meta: Prompt context item present"); + assert.ok( + metaSPIdx < metaPCIdx, + `_meta: System prompt (${metaSPIdx}) must precede Prompt context (${metaPCIdx})`, + ); + + // (d) The _meta item has turnId: null (standalone, not in a turn bucket). + const metaSPRawIdx = metaRaw.findIndex((i) => i.title === "System prompt"); + assert.equal( + metaRaw[metaSPRawIdx]?.turnId ?? null, + null, + "_meta: system-prompt item must have turnId=null", + ); +}); + +test("buildTranscript session/new bare systemPrompt field takes precedence over _meta.systemPrompt.append", () => { + // When both transports are present (non-standard but must not regress), + // the standard bare field must win — a reversed ?? would silently use the + // wrong text and the card body would differ from the wire source of truth. + const CH = "66666666-6666-6666-6666-666666666666"; + const events = [ + { + seq: 1, + timestamp: "2026-07-01T10:00:00.000Z", + kind: "acp_write", + agentIndex: 0, + channelId: CH, + sessionId: "sess-both", + turnId: "turn-1", + payload: { + jsonrpc: "2.0", + id: 1, + method: "session/new", + params: { + systemPrompt: "[Base]\nWinner.", + _meta: { systemPrompt: { append: "[Base]\nLoser." } }, + }, + }, + }, + ]; + + const rawItems = buildTranscript(events); + const spItem = rawItems.find((i) => i.title === "System prompt"); + assert.ok(spItem, "System prompt item must be present"); + const bodies = (spItem.sections ?? []).map((s) => s.body).join("|"); + assert.ok( + bodies.includes("Winner"), + "bare systemPrompt must win over _meta.systemPrompt.append", + ); + assert.ok( + !bodies.includes("Loser"), + "_meta.systemPrompt.append must not appear when bare field is present", + ); +}); diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index 962290c6ca2..e371bf5fc30 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -872,14 +872,14 @@ export function processTranscriptEvent( } else if (event.kind === "acp_write" && method === "session/new") { // The base + persona prompts ride session/new's systemPrompt, framed by // the harness as [Base]/[System]/[Agent Memory — core]/[Channel Canvas]. - // Each session/new event is keyed by (seq, timestamp) — the same dedup - // pair used by observerRelayStore — so distinct sessions each retain - // their own system-prompt card even across archive rebuilds where two - // processes may emit the same seq. turnId: null keeps it out of turn - // buckets; acpSource "session/new" lets the display grouper place it - // as a standalone card before the session's first turn. + // claude-agent-acp uses _meta.systemPrompt.append instead; both paths + // produce the same standalone card (turnId: null, acpSource "session/new"); + // the bare field takes precedence when both are present. const params = asRecord(payload.params); - const systemPrompt = asString(params.systemPrompt); + const metaPrompt = asString( + asRecord(asRecord(params._meta).systemPrompt).append, + ); + const systemPrompt = asString(params.systemPrompt) ?? metaPrompt; if (systemPrompt) { const sections = parseSystemPromptSections(systemPrompt); if (sections.length > 0) { From a5dbdf5e61e4c512acd99c219c79c154ddb57295 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Sun, 2 Aug 2026 18:54:58 -0700 Subject: [PATCH 009/163] fix(mobile): recover and pace live subscriptions (#3053) ### What changed? Mobile now recovers live subscriptions after retryable or rate-limited relay `CLOSED` responses. It ports the existing desktop model: classify terminal versus retryable closures, honor retry hints through a session-owned rate-limit gate, retry with bounded backoff, and replay visible-channel subscriptions first in bounded batches. Channel refreshes also retain unchanged live subscriptions instead of clearing and recreating them. This is desktop parity, not a new relay policy. ### Why? On reconnect or resume, mobile replayed its retained live subscriptions while `channelsProvider` independently cleared and recreated roughly the same set, alongside unread catch-up and open-channel requests. The relay allows 50 REQs per 5 seconds, so users in many channels could predictably exceed the budget. In live reproduction, 55 subscriptions produced 9 rate-limit closures, 60 produced 18, and 80 produced 36. Mobile then treated every live `CLOSED` as terminal, removed the affected subscription, and never restored it. Channel updates could remain dead until a later session reconstruction. This is the primary causal chain behind [BOT-1449](https://linear.app/squareup/issue/BOT-1449/buzz-mobile-posted-messages-dont-appear-until-leavingre-entering-the). Desktop already handles this as normal transient pressure by classifying closures, gating and backing off retries, pacing reconnect replay, and retaining unchanged subscriptions. This change brings mobile to the same recovery model while removing the avoidable request burst. ### How is it tested? Full mobile suite: 721 passed, 1 skipped. Analyzer and formatting checks pass. Required CI checks pass. Added and updated tests cover `CLOSED` classification, retry hints, rate-limit gating, bounded retry and reset behavior, terminal failures, timer cleanup, history gating, visible-first batched replay, and retention of unchanged subscriptions. --------- Signed-off-by: Tom Brow Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> Co-authored-by: Codex Co-authored-by: npub1tu6ed4gf70jg7pvk8uhttlprexznhzpg74am2d3seqd3ececzgusy8hzac <5f3596d509f3e48f05963f2eb5fc23c9853b8828f57bb53630c81b1ce3381239@buzz.block.builderlab.xyz> Co-authored-by: npub1w85l93z2dyetvaev42kvmgv3r5qsgc7rutrvgpqshqefj4sydqqskwstfm <71e9f2c44a6932b6772caaaccda1911d010463c3e2c6c40410b8329956046801@buzz.block.builderlab.xyz> --- .../channels/channel_detail_page.dart | 5 + .../features/channels/channels_provider.dart | 148 ++- mobile/lib/shared/relay/relay.dart | 2 + .../lib/shared/relay/relay_closed_policy.dart | 39 + .../shared/relay/relay_rate_limit_gate.dart | 80 ++ mobile/lib/shared/relay/relay_session.dart | 318 ++++++- .../channels/channel_detail_page_test.dart | 194 +++- .../channels/channels_provider_test.dart | 230 ++++- .../relay/relay_closed_policy_test.dart | 45 + .../relay/relay_rate_limit_gate_test.dart | 118 +++ .../test/shared/relay/relay_session_test.dart | 842 +++++++++++++++++- 11 files changed, 1934 insertions(+), 87 deletions(-) create mode 100644 mobile/lib/shared/relay/relay_closed_policy.dart create mode 100644 mobile/lib/shared/relay/relay_rate_limit_gate.dart create mode 100644 mobile/test/shared/relay/relay_closed_policy_test.dart create mode 100644 mobile/test/shared/relay/relay_rate_limit_gate_test.dart diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 044342d101f..a24b6c07ee6 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -188,6 +188,11 @@ class ChannelDetailPage extends HookConsumerWidget { messagesState: messagesState, ); + useEffect(() { + final session = ref.read(relaySessionProvider.notifier); + return session.registerVisibleChannel(channel.id); + }, [channel.id]); + // Preload channel member profiles so @mentions resolve correctly. useEffect(() { _preloadMembers(ref, channel.id); diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 614ab054c35..c0525a0b295 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -35,8 +35,12 @@ const _authoredRootIdsPrefix = 'buzz-thread-authored.v1'; class ChannelsNotifier extends AsyncNotifier> { static const _backstopInterval = Duration(seconds: 60); - final List _unsubscribers = []; + final Map _unsubscribersByChannel = {}; + Future _liveSubscriptionQueue = Future.value(); + List _desiredLiveChannels = const []; + Set _desiredLiveChannelIds = const {}; int _subscriptionVersion = 0; + String? _subscriptionRelayBaseUrl; Timer? _backstopTimer; final Map _latestObservedByChannel = {}; final Map> @@ -147,7 +151,10 @@ class ChannelsNotifier extends AsyncNotifier> { .whereType() .toSet() .toList(); - if (channelIds.isEmpty) return const []; + if (channelIds.isEmpty) { + if (subscribeLive) await _subscribeLive(const []); + return const []; + } // Step 2: pull channel metadata in one batched filter. final metas = await session.fetchHistory( @@ -423,50 +430,114 @@ class ChannelsNotifier extends AsyncNotifier> { /// Subscribe per-channel to live events (requires `#h` tag for relay /// channel-scoped fan-out). Also starts a 60s WS backstop poll to detect /// newly created channels we don't yet have subscriptions for. - Future _subscribeLive(List channels) async { - _clearLiveSubscriptions(); - final subscriptionVersion = _subscriptionVersion; - if (ref.read(relaySessionProvider).status != SessionStatus.connected) { - return; - } - - final session = ref.read(relaySessionProvider.notifier); + Future _subscribeLive(List channels) { final channelIds = { for (final channel in channels) if (channel.isMember && !channel.isArchived) channel.id, }; - - final subscriptions = await Future.wait( - channelIds.map((channelId) async { - try { - return await session.subscribe( - NostrFilter( - kinds: EventKind.channelEventKinds, - tags: { - '#h': [channelId], - }, - limit: 0, - ), - _handleLiveEvent, - ); - } catch (error) { - debugPrint( - '[ChannelsNotifier] live subscription failed for $channelId: $error', - ); - return null; - } - }), + final relayBaseUrl = ref.read(relayConfigProvider).baseUrl; + _desiredLiveChannels = channels; + _desiredLiveChannelIds = channelIds; + final subscriptionVersion = ++_subscriptionVersion; + + final sync = _liveSubscriptionQueue.then( + (_) => + _syncLiveSubscriptions(relayBaseUrl, subscriptionVersion, channels), ); + _liveSubscriptionQueue = sync.catchError((Object error, StackTrace stack) { + debugPrint( + '[ChannelsNotifier] live subscription sync failed: $error\n$stack', + ); + }); + return sync; + } + + Future _syncLiveSubscriptions( + String relayBaseUrl, + int subscriptionVersion, + List channels, + ) async { + if (ref.read(relaySessionProvider).status != SessionStatus.connected) { + return; + } + + if (subscriptionVersion != _subscriptionVersion) { + await _syncLiveSubscriptions( + ref.read(relayConfigProvider).baseUrl, + _subscriptionVersion, + _desiredLiveChannels, + ); + return; + } - if (subscriptionVersion != _subscriptionVersion || - ref.read(relaySessionProvider).status != SessionStatus.connected) { - for (final unsubscribe in subscriptions.whereType()) { + if (_subscriptionRelayBaseUrl != relayBaseUrl) { + for (final unsubscribe in _unsubscribersByChannel.values) { unsubscribe(); } + _unsubscribersByChannel.clear(); + _subscriptionRelayBaseUrl = relayBaseUrl; + } + if (ref.read(relayConfigProvider).baseUrl != relayBaseUrl) { + return; + } + final session = ref.read(relaySessionProvider.notifier); + final channelIds = _desiredLiveChannelIds; + + for (final entry in _unsubscribersByChannel.entries.toList()) { + if (channelIds.contains(entry.key)) continue; + _unsubscribersByChannel.remove(entry.key); + entry.value(); + } + + for (final channelId in channelIds) { + if (ref.read(relaySessionProvider).status != SessionStatus.connected) { + return; + } + if (_unsubscribersByChannel.containsKey(channelId)) continue; + try { + final unsubscribe = await session.subscribe( + NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channelId], + }, + limit: 0, + ), + _handleLiveEvent, + ); + if (ref.read(relaySessionProvider).status != SessionStatus.connected || + !_desiredLiveChannelIds.contains(channelId) || + ref.read(relayConfigProvider).baseUrl != relayBaseUrl || + _subscriptionRelayBaseUrl != relayBaseUrl) { + unsubscribe(); + return; + } + final replaced = _unsubscribersByChannel[channelId]; + if (replaced != null) { + unsubscribe(); + continue; + } + _unsubscribersByChannel[channelId] = unsubscribe; + } catch (error) { + debugPrint( + '[ChannelsNotifier] live subscription failed for $channelId: $error', + ); + } + } + + if (ref.read(relaySessionProvider).status != SessionStatus.connected) { return; } - _unsubscribers.addAll(subscriptions.whereType()); + if (subscriptionVersion != _subscriptionVersion) { + final desiredChannelIds = _desiredLiveChannelIds; + for (final entry in _unsubscribersByChannel.entries.toList()) { + if (desiredChannelIds.contains(entry.key)) continue; + _unsubscribersByChannel.remove(entry.key); + entry.value(); + } + return; + } unawaited(_catchUpUnreadEvents(channels)); @@ -734,10 +805,13 @@ class ChannelsNotifier extends AsyncNotifier> { void _clearLiveSubscriptions() { _subscriptionVersion++; - for (final unsubscribe in _unsubscribers) { + _desiredLiveChannels = const []; + _desiredLiveChannelIds = const {}; + for (final unsubscribe in _unsubscribersByChannel.values) { unsubscribe(); } - _unsubscribers.clear(); + _unsubscribersByChannel.clear(); + _subscriptionRelayBaseUrl = null; _backstopTimer?.cancel(); _backstopTimer = null; } diff --git a/mobile/lib/shared/relay/relay.dart b/mobile/lib/shared/relay/relay.dart index bc11325414b..dd153fc9282 100644 --- a/mobile/lib/shared/relay/relay.dart +++ b/mobile/lib/shared/relay/relay.dart @@ -5,8 +5,10 @@ export 'media_image.dart'; export 'media_upload.dart'; export 'nostr_filters.dart'; export 'nostr_models.dart'; +export 'relay_closed_policy.dart'; export 'relay_client.dart'; export 'relay_provider.dart'; +export 'relay_rate_limit_gate.dart'; export 'relay_session.dart'; export 'relay_socket.dart'; export 'signed_event_relay.dart'; diff --git a/mobile/lib/shared/relay/relay_closed_policy.dart b/mobile/lib/shared/relay/relay_closed_policy.dart new file mode 100644 index 00000000000..d39084b1897 --- /dev/null +++ b/mobile/lib/shared/relay/relay_closed_policy.dart @@ -0,0 +1,39 @@ +/// Recovery policy for a relay `CLOSED` subscription message. +enum RelayClosedClass { + /// A transient failure that may recover when the same REQ is retried. + retryable, + + /// Relay back-pressure that must also arm the shared request gate. + rateLimited, + + /// An authorization, access, or filter failure that cannot recover unchanged. + terminal, +} + +/// Classifies whether a relay `CLOSED` message should be retried. +RelayClosedClass classifyRelayClosed(String message) { + final normalized = message.trim().toLowerCase(); + if (normalized.startsWith('rate-limited:')) { + return RelayClosedClass.rateLimited; + } + if (normalized.startsWith('restricted:') || + normalized.startsWith('auth-required:') || + normalized.startsWith('blocked:') || + normalized.startsWith('invalid:') || + normalized.startsWith('pow:') || + normalized.startsWith('duplicate:') || + normalized.startsWith('unsupported:') || + normalized.startsWith('error: mixed search') || + normalized.startsWith('error: too many subscriptions')) { + return RelayClosedClass.terminal; + } + return RelayClosedClass.retryable; +} + +final _rateLimitRetryPattern = RegExp(r'retry in (\d+)s', caseSensitive: false); + +/// Parses the relay's canonical `retry in Ns` hint, when present. +int? parseRateLimitRetrySeconds(String message) { + final match = _rateLimitRetryPattern.firstMatch(message); + return match == null ? null : int.tryParse(match.group(1)!); +} diff --git a/mobile/lib/shared/relay/relay_rate_limit_gate.dart b/mobile/lib/shared/relay/relay_rate_limit_gate.dart new file mode 100644 index 00000000000..61640139368 --- /dev/null +++ b/mobile/lib/shared/relay/relay_rate_limit_gate.dart @@ -0,0 +1,80 @@ +import 'dart:async'; +import 'dart:math'; + +/// Creates a timer used by [RelayRateLimitGate]. +typedef RelayTimerFactory = + Timer Function(Duration duration, void Function() callback); + +/// Session-owned gate that pauses relay requests after back-pressure. +class RelayRateLimitGate { + /// Default gate duration when the relay omits a positive retry hint. + static const defaultRetrySeconds = 10; + + /// Longest retry hint accepted from a relay response. + static const maxRetrySeconds = 300; + + RelayRateLimitGate({ + DateTime Function()? now, + RelayTimerFactory timerFactory = Timer.new, + }) : _now = now ?? DateTime.now, + _timerFactory = timerFactory; + + final DateTime Function() _now; + final RelayTimerFactory _timerFactory; + DateTime? _expiresAt; + Timer? _timer; + Completer? _completer; + + /// Whether a rate-limit window is currently active. + bool get isActive { + final expiresAt = _expiresAt; + return expiresAt != null && _now().isBefore(expiresAt); + } + + /// Activates or extends the gate without shrinking an existing window. + void activate(int? retryInSeconds) { + final seconds = retryInSeconds != null && retryInSeconds > 0 + ? min(retryInSeconds, maxRetrySeconds) + : defaultRetrySeconds; + final duration = Duration(seconds: seconds); + final newExpiry = _now().add(duration); + final currentExpiry = _expiresAt; + if (currentExpiry != null && !newExpiry.isAfter(currentExpiry)) return; + + _expiresAt = newExpiry; + _timer?.cancel(); + _completer ??= Completer(); + _timer = _timerFactory(duration, _expire); + } + + /// Resolves when the active rate-limit window expires. + Future wait() { + if (!isActive) return Future.value(); + return _completer!.future; + } + + /// Milliseconds remaining in the active window, or zero when inactive. + int remainingMs() { + final expiresAt = _expiresAt; + if (expiresAt == null) return 0; + return max(0, expiresAt.difference(_now()).inMilliseconds); + } + + /// Clears the gate and releases all current waiters. + void reset() { + _timer?.cancel(); + _timer = null; + _expiresAt = null; + final completer = _completer; + _completer = null; + if (completer != null && !completer.isCompleted) completer.complete(); + } + + void _expire() { + _timer = null; + _expiresAt = null; + final completer = _completer; + _completer = null; + if (completer != null && !completer.isCompleted) completer.complete(); + } +} diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index d34c5405e8a..877bd15e827 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -13,7 +13,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth.dart'; import 'nostr_models.dart'; import 'relay_client.dart'; +import 'relay_closed_policy.dart'; import 'relay_provider.dart'; +import 'relay_rate_limit_gate.dart'; import 'relay_socket.dart'; enum SessionStatus { disconnected, connecting, connected, reconnecting } @@ -40,6 +42,8 @@ class _LiveSubscription { final void Function(String message)? onClosed; Completer? readyCompleter; int? lastSeenCreatedAt; + int closedRetryAttempt = 0; + Timer? closedRetryTimer; _LiveSubscription({ required this.filter, @@ -49,6 +53,13 @@ class _LiveSubscription { }); } +class _ClosedRetry { + final _LiveSubscription subscription; + final int generation; + + _ClosedRetry({required this.subscription, required this.generation}); +} + class _PendingEvent { final Completer completer; final Timer timeout; @@ -78,21 +89,33 @@ class RelaySessionNotifier extends Notifier { RelaySessionNotifier({ http.Client? httpClient, RelaySocketFactory socketFactory = RelaySocket.new, + RelayRateLimitGate? rateLimitGate, + RelayTimerFactory retryTimerFactory = Timer.new, + Future Function(Duration) replayDelay = Future.delayed, }) : _httpClient = httpClient, - _socketFactory = socketFactory; + _socketFactory = socketFactory, + _rateLimitGate = rateLimitGate ?? RelayRateLimitGate(), + _retryTimerFactory = retryTimerFactory, + _replayDelay = replayDelay; final http.Client? _httpClient; final RelaySocketFactory _socketFactory; + final RelayRateLimitGate _rateLimitGate; + final RelayTimerFactory _retryTimerFactory; + final Future Function(Duration) _replayDelay; static const _baseReconnectDelayMs = 1000; static const _maxReconnectDelayMs = 30000; static const _eventBatchMs = 16; static const _reconnectReplaySkewSeconds = 5; + static const _replayBatchSize = 8; + static const _replayInterBatchDelay = Duration(milliseconds: 50); static const _maxRecentDeliveryKeys = 5000; RelaySocket? _socket; final Map _historySubscriptions = {}; final Map _liveSubscriptions = {}; + final Map _pendingClosedRetries = {}; final Map _pendingEvents = {}; final List<_BufferedEvent> _eventBuffer = []; final Set _recentDeliveryKeys = {}; @@ -105,6 +128,9 @@ class RelaySessionNotifier extends Notifier { bool _paused = false; bool _hasConnectedOnce = false; int _connectionGeneration = 0; + final Map _visibleChannelsByOwner = {}; + bool _socketConnected = false; + bool _closedRetryReplayScheduled = false; @override SessionState build() { @@ -158,6 +184,7 @@ class RelaySessionNotifier extends Notifier { if (shouldCloseClient) client.close(); }); if (response.statusCode < 200 || response.statusCode >= 300) { + _activateRateLimitGateFromHttpError(response.body); throw RelayException(response.statusCode, response.body); } final decoded = jsonDecode(response.body); @@ -178,12 +205,30 @@ class RelaySessionNotifier extends Notifier { } } + void _activateRateLimitGateFromHttpError(String body) { + final dynamic decoded; + try { + decoded = jsonDecode(body); + } on FormatException { + return; + } + if (decoded is! Map) return; + final message = decoded['error']; + if (message is! String || + classifyRelayClosed(message) != RelayClosedClass.rateLimited) { + return; + } + _rateLimitGate.activate(parseRateLimitRetrySeconds(message)); + } + /// Fetch historical events matching [filter]. Sends REQ, collects events /// until EOSE, then resolves. One-shot subscription. Future> fetchHistory( NostrFilter filter, { Duration timeout = const Duration(seconds: 8), - }) { + }) async { + if (_rateLimitGate.isActive) await _rateLimitGate.wait(); + if (_disposed) throw StateError('Relay session is disposed'); final subId = _nextSubId('h'); final completer = Completer>(); @@ -214,6 +259,7 @@ class RelaySessionNotifier extends Notifier { void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, }) async { + if (_disposed) throw StateError('Relay session is disposed'); final subId = _nextSubId('l'); final readyCompleter = Completer(); @@ -285,11 +331,35 @@ class RelaySessionNotifier extends Notifier { void debugFlushEventBuffer() => _flushEventBuffer(); @visibleForTesting - void debugHandleConnected() => _handleConnected(_connectionGeneration); + Future debugHandleConnected() => + _handleConnected(_connectionGeneration); + + @visibleForTesting + Future debugReplayLiveSubscriptions() => + _replayLiveSubscriptions(_connectionGeneration); + + @visibleForTesting + void debugDispose() => _dispose(); @visibleForTesting - void debugHandleDisconnected([Object? error]) => - _handleDisconnected(_connectionGeneration, error); + void debugSupersedeConnection() => _connectionGeneration++; + + @visibleForTesting + void debugHandleDisconnected([Object? error]) { + _socketConnected = false; + _handleDisconnected(_connectionGeneration, error); + } + + @visibleForTesting + void debugResetClosedRetriesForDisconnect() { + _socketConnected = false; + _resetAllClosedRetries(); + } + + @visibleForTesting + void debugSetSessionStatus(SessionStatus status) { + _socketConnected = status == SessionStatus.connected; + } @visibleForTesting void debugPauseNow() => _pauseNow(); @@ -302,11 +372,20 @@ class RelaySessionNotifier extends Notifier { void debugAttachSocketForTest(RelaySocket socket) { _socket?.dispose(); _socket = socket; - state = const SessionState(status: SessionStatus.connected); + _socketConnected = true; + } + + /// Registers a visible channel and returns an owner-scoped release callback. + /// The most recently registered owner is prioritized during reconnect replay. + void Function() registerVisibleChannel(String channelId) { + final owner = Object(); + _visibleChannelsByOwner[owner] = channelId; + return () => _visibleChannelsByOwner.remove(owner); } /// Force a reconnect (e.g., returning from background). Future reconnect() async { + _socketConnected = false; await _socket?.disconnect(); _reconnectDelayMs = _baseReconnectDelayMs; final config = ref.read(relayConfigProvider); @@ -321,6 +400,7 @@ class RelaySessionNotifier extends Notifier { void _pauseNow() { _paused = true; + _socketConnected = false; _reconnectTimer?.cancel(); _cancelAllHistory(Exception('App moved to background')); _rejectAllPending(Exception('App moved to background')); @@ -372,18 +452,21 @@ class RelaySessionNotifier extends Notifier { await socket.connect(); } - void _handleConnected(int generation) { + Future _handleConnected(int generation) async { if (_disposed || generation != _connectionGeneration) return; + _socketConnected = true; _hasConnectedOnce = true; _reconnectDelayMs = _baseReconnectDelayMs; state = const SessionState(status: SessionStatus.connected); - _replayLiveSubscriptions(); + await _replayLiveSubscriptions(generation); } void _handleDisconnected(int generation, Object? error) { if (_disposed || generation != _connectionGeneration) return; + _socketConnected = false; _cancelAllHistory(error); _rejectAllPending(error); + _resetAllClosedRetries(); _eventBuffer.clear(); _flushTimer?.cancel(); _flushTimer = null; @@ -413,19 +496,84 @@ class RelaySessionNotifier extends Notifier { /// Replay all live subscriptions after a reconnect, with a time skew to /// catch events that occurred during the disconnect. - void _replayLiveSubscriptions() { - for (final entry in _liveSubscriptions.entries) { - final sub = entry.value; - final since = sub.lastSeenCreatedAt != null - ? sub.lastSeenCreatedAt! - _reconnectReplaySkewSeconds - : null; - final filter = since != null - ? sub.filter.copyWithSince(since) - : sub.filter; - _sendReq(entry.key, filter); + Future _replayLiveSubscriptions(int generation) async { + if (_rateLimitGate.isActive) await _rateLimitGate.wait(); + if (!_isActiveConnection(generation)) return; + + final entries = _liveSubscriptions.entries.toList(); + final visibleChannelId = _visibleChannelsByOwner.isEmpty + ? null + : _visibleChannelsByOwner.values.last; + if (visibleChannelId != null) { + entries.sort((left, right) { + final leftVisible = + left.value.filter.tags['#h']?.contains(visibleChannelId) ?? false; + final rightVisible = + right.value.filter.tags['#h']?.contains(visibleChannelId) ?? false; + if (leftVisible == rightVisible) return 0; + return leftVisible ? -1 : 1; + }); + } + + await _sendReplayBatches(entries, generation); + } + + Future _replayPendingClosedRetries(int generation) async { + if (!_isActiveConnection(generation)) return; + final entries = _pendingClosedRetries.entries + .where((entry) => entry.value.generation == generation) + .map( + (entry) => MapEntry( + entry.key, + entry.value.subscription, + ), + ) + .toList(); + await _sendReplayBatches(entries, generation, pendingClosedRetries: true); + } + + Future _sendReplayBatches( + List> entries, + int generation, { + bool pendingClosedRetries = false, + }) async { + for (var i = 0; i < entries.length; i += _replayBatchSize) { + if (_rateLimitGate.isActive) await _rateLimitGate.wait(); + if (!_isActiveConnection(generation)) return; + final batch = entries.sublist( + i, + min(i + _replayBatchSize, entries.length), + ); + for (final entry in batch) { + if (_liveSubscriptions[entry.key] != entry.value) continue; + if (pendingClosedRetries) { + final pendingRetry = _pendingClosedRetries[entry.key]; + if (pendingRetry?.subscription != entry.value || + pendingRetry?.generation != generation) { + continue; + } + _pendingClosedRetries.remove(entry.key); + } + _sendReq(entry.key, _replayFilter(entry.value)); + } + if (i + _replayBatchSize < entries.length) { + await _replayDelay(_replayInterBatchDelay); + } } } + bool _isActiveConnection(int generation) => + !_disposed && generation == _connectionGeneration; + + NostrFilter _replayFilter(_LiveSubscription subscription) { + final since = subscription.lastSeenCreatedAt; + return since == null + ? subscription.filter + : subscription.filter.copyWithSince( + max(0, since - _reconnectReplaySkewSeconds), + ); + } + void _handleMessage(List data) { if (data.isEmpty) return; final type = data[0] as String; @@ -458,6 +606,7 @@ class RelaySessionNotifier extends Notifier { // Live subscriptions get batched. final liveSub = _liveSubscriptions[subId]; if (liveSub != null) { + _resetClosedRetry(liveSub); // Track last seen timestamp for reconnect replay. if (liveSub.lastSeenCreatedAt == null || event.createdAt > liveSub.lastSeenCreatedAt!) { @@ -485,6 +634,9 @@ class RelaySessionNotifier extends Notifier { // Live subscription: signal ready. final liveSub = _liveSubscriptions[subId]; + if (liveSub != null) { + _resetClosedRetry(liveSub); + } if (liveSub != null && liveSub.readyCompleter != null && !liveSub.readyCompleter!.isCompleted) { @@ -504,9 +656,13 @@ class RelaySessionNotifier extends Notifier { final message = data.length >= 3 && data[2] is String ? data[2] as String : 'subscription closed by relay'; + final closedClass = classifyRelayClosed(message); final historySub = _historySubscriptions.remove(subId); if (historySub != null) { + if (closedClass == RelayClosedClass.rateLimited) { + _rateLimitGate.activate(parseRateLimitRetrySeconds(message)); + } historySub.timeout.cancel(); if (!historySub.completer.isCompleted) { historySub.completer.completeError(Exception(message)); @@ -514,17 +670,87 @@ class RelaySessionNotifier extends Notifier { return; } - final liveSub = _liveSubscriptions.remove(subId); + final liveSub = _liveSubscriptions[subId]; if (liveSub == null) return; - _recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:')); - final readyCompleter = liveSub.readyCompleter; - if (readyCompleter != null && !readyCompleter.isCompleted) { - readyCompleter.completeError(Exception(message)); + if (closedClass == RelayClosedClass.terminal) { + if (readyCompleter != null && !readyCompleter.isCompleted) { + readyCompleter.completeError(Exception(message)); + } + liveSub.onClosed?.call(message); + _removeLiveSubscription(subId, liveSub); return; } + if (readyCompleter != null && !readyCompleter.isCompleted) { + readyCompleter.complete(); + liveSub.readyCompleter = null; + } + if (liveSub.closedRetryTimer != null) return; + + final attempt = liveSub.closedRetryAttempt; + final backoffMs = attempt >= 5 + ? _maxReconnectDelayMs + : _baseReconnectDelayMs * (1 << attempt); + var delayMs = backoffMs; + if (closedClass == RelayClosedClass.rateLimited) { + final retrySeconds = parseRateLimitRetrySeconds(message); + _rateLimitGate.activate(retrySeconds); + final fallbackMs = + (retrySeconds != null && retrySeconds > 0 + ? min(retrySeconds, RelayRateLimitGate.maxRetrySeconds) + : RelayRateLimitGate.defaultRetrySeconds) * + 1000; + delayMs = max( + backoffMs, + _rateLimitGate.remainingMs() == 0 + ? fallbackMs + : _rateLimitGate.remainingMs(), + ); + } - liveSub.onClosed?.call(message); + liveSub.closedRetryAttempt = attempt + 1; + final retryGeneration = _connectionGeneration; + liveSub.closedRetryTimer = _retryTimerFactory( + Duration(milliseconds: delayMs), + () async { + liveSub.closedRetryTimer = null; + if (!_isActiveConnection(retryGeneration) || + _liveSubscriptions[subId] != liveSub) { + return; + } + if (_rateLimitGate.isActive) await _rateLimitGate.wait(); + if (!_isActiveConnection(retryGeneration) || + _liveSubscriptions[subId] != liveSub || + !_socketConnected) { + return; + } + _pendingClosedRetries[subId] = _ClosedRetry( + subscription: liveSub, + generation: retryGeneration, + ); + _scheduleClosedRetryReplay(retryGeneration); + }, + ); + } + + void _scheduleClosedRetryReplay(int generation) { + if (_closedRetryReplayScheduled) return; + _closedRetryReplayScheduled = true; + scheduleMicrotask(() async { + try { + await _replayPendingClosedRetries(generation); + } finally { + _closedRetryReplayScheduled = false; + _pendingClosedRetries.removeWhere( + (_, retry) => retry.generation != _connectionGeneration, + ); + if (_pendingClosedRetries.values.any( + (retry) => retry.generation == _connectionGeneration, + )) { + _scheduleClosedRetryReplay(_connectionGeneration); + } + } + }); } void _handleOk(List data) { @@ -619,9 +845,41 @@ class RelaySessionNotifier extends Notifier { } void _unsubscribe(String subId) { + final subscription = _liveSubscriptions[subId]; + if (subscription != null) { + _removeLiveSubscription(subId, subscription); + } + _sendClose(subId); + } + + void _removeLiveSubscription(String subId, _LiveSubscription subscription) { + if (_liveSubscriptions[subId] != subscription) return; _liveSubscriptions.remove(subId); + _pendingClosedRetries.remove(subId); + subscription.closedRetryTimer?.cancel(); + subscription.closedRetryTimer = null; _recentDeliveryKeys.removeWhere((key) => key.startsWith('$subId:')); - _sendClose(subId); + } + + void _resetClosedRetry(_LiveSubscription subscription) { + subscription.closedRetryAttempt = 0; + subscription.closedRetryTimer?.cancel(); + subscription.closedRetryTimer = null; + } + + void _cancelAllClosedRetries() { + _pendingClosedRetries.clear(); + for (final subscription in _liveSubscriptions.values) { + subscription.closedRetryTimer?.cancel(); + subscription.closedRetryTimer = null; + } + } + + void _resetAllClosedRetries() { + _pendingClosedRetries.clear(); + for (final subscription in _liveSubscriptions.values) { + _resetClosedRetry(subscription); + } } void _cancelAllHistory(Object? error) { @@ -650,8 +908,18 @@ class RelaySessionNotifier extends Notifier { _reconnectTimer?.cancel(); _flushTimer?.cancel(); _backgroundGraceTimer?.cancel(); + _cancelAllClosedRetries(); + _rateLimitGate.reset(); + _visibleChannelsByOwner.clear(); + _socketConnected = false; _cancelAllHistory(null); _rejectAllPending(null); + final subscriptions = _liveSubscriptions.values.toList(); + _liveSubscriptions.clear(); + for (final subscription in subscriptions) { + subscription.closedRetryTimer?.cancel(); + subscription.closedRetryTimer = null; + } _recentDeliveryKeys.clear(); _socket?.dispose(); _socket = null; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 899394de233..f95c6fefed1 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -229,6 +229,55 @@ Widget _buildTestable({ ); } +Widget _buildNavigationTestable({ + required Channel channelA, + required Channel channelB, + required RelaySessionNotifier relaySession, +}) { + return ProviderScope( + overrides: [ + relaySessionProvider.overrideWith(() => relaySession), + channelMessagesProvider( + channelA.id, + ).overrideWith(() => _FakeMessagesNotifier([], channelId: channelA.id)), + channelMessagesProvider( + channelB.id, + ).overrideWith(() => _FakeMessagesNotifier([], channelId: channelB.id)), + channelTypingProvider( + channelA.id, + ).overrideWith(() => _FakeTypingNotifier([], channelId: channelA.id)), + channelTypingProvider( + channelB.id, + ).overrideWith(() => _FakeTypingNotifier([], channelId: channelB.id)), + channelDetailsProvider( + channelA.id, + ).overrideWith((ref) async => ChannelDetails.fromChannel(channelA)), + channelDetailsProvider( + channelB.id, + ).overrideWith((ref) async => ChannelDetails.fromChannel(channelB)), + channelMembersProvider( + channelA.id, + ).overrideWith((ref) async => const []), + channelMembersProvider( + channelB.id, + ).overrideWith((ref) async => const []), + userCacheProvider.overrideWith(() => _FakeUserCacheNotifier({})), + profileProvider.overrideWith(() => _FakeProfileNotifier()), + channelsProvider.overrideWith( + () => _FakeChannelsNotifier([channelA, channelB]), + ), + relayClientProvider.overrideWithValue( + RelayClient(baseUrl: 'http://localhost:3000'), + ), + savedPrefsProvider.overrideWithValue(_testPrefs), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: ChannelDetailPage(channel: channelA), + ), + ); +} + /// Finder that searches for text within RichText spans. [find.text] only /// matches the top-level text property; this also searches nested TextSpans. Finder findRichText(String text) { @@ -262,6 +311,103 @@ void main() { }); group('ChannelDetailPage', () { + testWidgets( + 'restores the previous channel replay priority after a nested pop', + (tester) async { + final channelA = _channel(id: 'channel-a', name: 'channel A'); + final channelB = _channel(id: 'channel-b', name: 'channel B'); + final socket = _RecordingRelaySocket(); + final relaySession = RelaySessionNotifier(); + relaySession.debugAttachSocketForTest(socket); + + final subscribeB = relaySession.subscribe( + _filterForChannel(channelB.id), + (_) {}, + ); + relaySession.debugHandleMessage(['EOSE', 'l-1']); + await subscribeB; + final subscribeA = relaySession.subscribe( + _filterForChannel(channelA.id), + (_) {}, + ); + relaySession.debugHandleMessage(['EOSE', 'l-2']); + await subscribeA; + + await tester.pumpWidget( + _buildNavigationTestable( + channelA: channelA, + channelB: channelB, + relaySession: relaySession, + ), + ); + await tester.pumpAndSettle(); + + final navigator = Navigator.of( + tester.element(find.byType(ChannelDetailPage)), + ); + navigator.push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channelB), + ), + ); + await tester.pumpAndSettle(); + navigator.pop(); + await tester.pumpAndSettle(); + + socket.messages.clear(); + await relaySession.debugReplayLiveSubscriptions(); + + expect(_replayedChannelIds(socket), [channelA.id, channelB.id]); + }, + ); + + testWidgets( + 'keeps replacement channel replay priority after old route disposal', + (tester) async { + final channelA = _channel(id: 'channel-a', name: 'channel A'); + final channelB = _channel(id: 'channel-b', name: 'channel B'); + final socket = _RecordingRelaySocket(); + final relaySession = RelaySessionNotifier(); + relaySession.debugAttachSocketForTest(socket); + + final subscribeA = relaySession.subscribe( + _filterForChannel(channelA.id), + (_) {}, + ); + relaySession.debugHandleMessage(['EOSE', 'l-1']); + await subscribeA; + final subscribeB = relaySession.subscribe( + _filterForChannel(channelB.id), + (_) {}, + ); + relaySession.debugHandleMessage(['EOSE', 'l-2']); + await subscribeB; + + await tester.pumpWidget( + _buildNavigationTestable( + channelA: channelA, + channelB: channelB, + relaySession: relaySession, + ), + ); + await tester.pumpAndSettle(); + + Navigator.of( + tester.element(find.byType(ChannelDetailPage)), + ).pushReplacement( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channelB), + ), + ); + await tester.pumpAndSettle(); + + socket.messages.clear(); + await relaySession.debugReplayLiveSubscriptions(); + + expect(_replayedChannelIds(socket), [channelB.id, channelA.id]); + }, + ); + testWidgets('debounces same-slot reconnect skeletons before revealing', ( tester, ) async { @@ -2664,9 +2810,12 @@ class _FakeMessagesNotifier extends ChannelMessagesNotifier { List _messages; bool _hasLoadedMessages; - _FakeMessagesNotifier(this._messages, {bool hasLoadedMessages = true}) - : _hasLoadedMessages = hasLoadedMessages, - super(_channelId); + _FakeMessagesNotifier( + this._messages, { + String channelId = _channelId, + bool hasLoadedMessages = true, + }) : _hasLoadedMessages = hasLoadedMessages, + super(channelId); @override AsyncValue> build() => AsyncData(_messages); @@ -2713,7 +2862,8 @@ class _ReconnectingRelaySession extends RelaySessionNotifier { class _FakeTypingNotifier extends ChannelTypingNotifier { final List _entries; - _FakeTypingNotifier(this._entries) : super(_channelId); + _FakeTypingNotifier(this._entries, {String channelId = _channelId}) + : super(channelId); @override List build() => _entries; @@ -2794,6 +2944,42 @@ class _FakeChannelActions extends ChannelActions { } } +class _RecordingRelaySocket extends RelaySocket { + _RecordingRelaySocket() + : super( + wsUrl: 'wss://relay.example', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + + final List> messages = []; + + @override + void send(List payload) => messages.add(payload); + + @override + void dispose() {} +} + +NostrFilter _filterForChannel(String channelId) => NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channelId], + }, + limit: 0, +); + +List _replayedChannelIds(_RecordingRelaySocket socket) => socket + .messages + .where((message) => message.first == 'REQ') + .map( + (message) => + ((message[2] as Map)['#h'] as List).single as String, + ) + .toList(); + class _TestNavigatorObserver extends NavigatorObserver { int pushCount = 0; diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 79be7a66f72..c128b9be5a9 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -52,6 +54,174 @@ void main() { }, ); + test( + 'refreshing an unchanged channel set issues zero new live REQs', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + final initialSubscribeCount = session.totalSubscribeCount; + + await container.read(channelsProvider.notifier).refresh(); + + expect(session.totalSubscribeCount, initialSubscribeCount); + expect(session.unsubscribeCount, 0); + expect(session.subscribeFilters, hasLength(2)); + }, + ); + + test( + 'live subscription diff only removes and adds changed channels', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + session.memberships = [ + _membership(_channelB, myPk), + _membership(_channelD, myPk), + ]; + session.metadata = [ + _meta(id: _channelB, name: 'random'), + _meta(id: _channelD, name: 'support'), + ]; + + await container.read(channelsProvider.notifier).refresh(); + + expect(session.totalSubscribeCount, 3); + expect(session.unsubscribeCount, 1); + expect( + session.subscribeFilters + .map((filter) => filter.tags['#h']!.single) + .toSet(), + {_channelB, _channelD}, + ); + }, + ); + + test( + 'empty channel refresh removes every retained live subscription', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + session.memberships = []; + session.metadata = []; + + await container.read(channelsProvider.notifier).refresh(); + + expect(session.activeChannels, isEmpty); + expect(session.activeSubscriptionCount, 0); + expect(session.unsubscribeCount, 2); + }, + ); + + test( + 'overlapping refreshes retain one live subscription per desired channel', + () async { + final session = _FakeRelaySession( + memberships: [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + ], + metadata: [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + ], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + session.pauseNextSubscribe(); + session.memberships = [ + _membership(_channelA, myPk), + _membership(_channelB, myPk), + _membership(_channelD, myPk), + ]; + session.metadata = [ + _meta(id: _channelA, name: 'general'), + _meta(id: _channelB, name: 'random'), + _meta(id: _channelD, name: 'support'), + ]; + + final firstRefresh = container.read(channelsProvider.notifier).refresh(); + await session.nextSubscribeStarted; + final secondRefresh = container.read(channelsProvider.notifier).refresh(); + session.resumePausedSubscribe(); + await Future.wait([firstRefresh, secondRefresh]); + + expect(session.activeChannels, {_channelA, _channelB, _channelD}); + expect(session.activeSubscriptionCount, 3); + }, + ); + + test( + 'community switch replaces retained live subscriptions on the new relay', + () async { + final session = _FakeRelaySession( + memberships: [_membership(_channelA, myPk)], + metadata: [_meta(id: _channelA, name: 'general')], + ); + final container = _buildContainer(session: session); + addTearDown(container.dispose); + + await container.read(channelsProvider.future); + expect(session.activeChannels, {_channelA}); + + session.setStatus(SessionStatus.disconnected); + session.memberships = [_membership(_channelB, myPk)]; + session.metadata = [_meta(id: _channelB, name: 'random')]; + container + .read(relayConfigProvider.notifier) + .update(baseUrl: 'https://new-community.example'); + await Future.delayed(Duration.zero); + session.setStatus(SessionStatus.connected); + await container.read(channelsProvider.future); + await _waitUntil( + () => + session.activeChannels.length == 1 && + session.activeChannels.contains(_channelB), + ); + + expect(session.activeChannels, {_channelB}); + expect(session.activeSubscriptionCount, 1); + expect(session.unsubscribeCount, 1); + }, + ); + test('live channel events update channel lastMessageAt', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -423,6 +593,14 @@ ProviderContainer _buildContainer({required _FakeRelaySession session}) { ); } +Future _waitUntil(bool Function() predicate) async { + for (var i = 0; i < 100; i++) { + if (predicate()) return; + await Future.delayed(Duration.zero); + } + fail('Timed out waiting for asynchronous provider work'); +} + /// Fake [RelaySessionNotifier] that returns canned events from [fetchHistory] /// and records subscribe calls. class _FakeRelaySession extends RelaySessionNotifier { @@ -440,8 +618,40 @@ class _FakeRelaySession extends RelaySessionNotifier { final List historyFilters = []; final List subscribeFilters = []; - final List _listeners = []; + final Map _subscriptions = {}; + int _nextSubscriptionKey = 0; + Completer? _pausedSubscribe; + Completer? _subscribeStarted; int unsubscribeCount = 0; + int totalSubscribeCount = 0; + + Set get activeChannels => { + for (final (filter, _) in _subscriptions.values) ?filter.tags['#h']?.single, + }; + + int get activeSubscriptionCount => _subscriptions.length; + + Future get nextSubscribeStarted async { + final started = _subscribeStarted; + if (started == null) { + throw StateError('No paused subscription is pending'); + } + await started.future; + } + + void pauseNextSubscribe() { + if (_pausedSubscribe != null) { + throw StateError('A subscription is already paused'); + } + _pausedSubscribe = Completer(); + _subscribeStarted = Completer(); + } + + void resumePausedSubscribe() { + final paused = _pausedSubscribe; + if (paused == null) throw StateError('No subscription is paused'); + paused.complete(); + } @override SessionState build() => const SessionState(status: SessionStatus.connected); @@ -483,12 +693,22 @@ class _FakeRelaySession extends RelaySessionNotifier { void Function(NostrEvent) onEvent, { void Function(String message)? onClosed, }) async { + totalSubscribeCount++; subscribeFilters.add(filter); - _listeners.add(onEvent); + final paused = _pausedSubscribe; + if (paused != null) { + _subscribeStarted!.complete(); + await paused.future; + _pausedSubscribe = null; + _subscribeStarted = null; + } + final subscriptionKey = ++_nextSubscriptionKey; + _subscriptions[subscriptionKey] = (filter, onEvent); return () { + final subscription = _subscriptions.remove(subscriptionKey); + if (subscription == null) return; unsubscribeCount++; - subscribeFilters.remove(filter); - _listeners.remove(onEvent); + subscribeFilters.remove(subscription.$1); }; } @@ -498,7 +718,7 @@ class _FakeRelaySession extends RelaySessionNotifier { /// Emit a live event to all subscribers. void emit(NostrEvent event) { - for (final listener in List.of(_listeners)) { + for (final (_, listener) in List.of(_subscriptions.values)) { listener(event); } } diff --git a/mobile/test/shared/relay/relay_closed_policy_test.dart b/mobile/test/shared/relay/relay_closed_policy_test.dart new file mode 100644 index 00000000000..ff78ea89ef8 --- /dev/null +++ b/mobile/test/shared/relay/relay_closed_policy_test.dart @@ -0,0 +1,45 @@ +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('classifyRelayClosed', () { + const cases = { + 'rate-limited: quota exceeded; retry in 4s': RelayClosedClass.rateLimited, + 'rate-limited: too many concurrent requests': + RelayClosedClass.rateLimited, + 'restricted: access revoked': RelayClosedClass.terminal, + 'auth-required: verification failed': RelayClosedClass.terminal, + 'blocked: community policy': RelayClosedClass.terminal, + 'invalid: malformed filter': RelayClosedClass.terminal, + 'pow: insufficient work': RelayClosedClass.terminal, + 'duplicate: subscription already exists': RelayClosedClass.terminal, + 'unsupported: filter extension': RelayClosedClass.terminal, + 'error: mixed search and channel filter': RelayClosedClass.terminal, + 'error: too many subscriptions': RelayClosedClass.terminal, + 'error: relay temporarily unavailable': RelayClosedClass.retryable, + 'subscription closed by relay': RelayClosedClass.retryable, + }; + + for (final entry in cases.entries) { + test(entry.key, () { + expect(classifyRelayClosed(entry.key), entry.value); + }); + } + }); + + test('parseRateLimitRetrySeconds handles canonical and absent hints', () { + expect( + parseRateLimitRetrySeconds('rate-limited: quota exceeded; retry in 17s'), + 17, + ); + expect(parseRateLimitRetrySeconds('RETRY IN 2S'), 2); + expect(parseRateLimitRetrySeconds('retry in 0s'), 0); + expect(parseRateLimitRetrySeconds('retry in 999999s'), 999999); + final oversizedHint = List.filled(1000, '9').join(); + expect(parseRateLimitRetrySeconds('retry in ${oversizedHint}s'), isNull); + expect( + parseRateLimitRetrySeconds('rate-limited: too many concurrent requests'), + isNull, + ); + }); +} diff --git a/mobile/test/shared/relay/relay_rate_limit_gate_test.dart b/mobile/test/shared/relay/relay_rate_limit_gate_test.dart new file mode 100644 index 00000000000..811d439572d --- /dev/null +++ b/mobile/test/shared/relay/relay_rate_limit_gate_test.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('uses the default for absent and non-positive hints', () { + var now = DateTime.utc(2026); + final timers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => now, + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + + gate.activate(null); + expect(gate.remainingMs(), 10000); + expect(timers.single.duration, const Duration(seconds: 10)); + + now = now.add(const Duration(seconds: 11)); + gate.activate(0); + expect(timers.last.duration, const Duration(seconds: 10)); + }); + + test('clamps large hints to five minutes', () { + final timers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime.utc(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + + gate.activate(999999); + + expect(timers.single.duration, const Duration(seconds: 300)); + expect(gate.remainingMs(), 300000); + }); + + test('overlapping activations only extend the active window', () async { + var now = DateTime.utc(2026); + final timers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => now, + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + + gate.activate(30); + final wait = gate.wait(); + final firstTimer = timers.single; + + now = now.add(const Duration(seconds: 5)); + gate.activate(10); + expect(timers, hasLength(1)); + expect(gate.remainingMs(), 25000); + + gate.activate(40); + expect(timers, hasLength(2)); + expect(firstTimer.isActive, isFalse); + expect(gate.remainingMs(), 40000); + + timers.last.fire(); + await wait; + expect(gate.isActive, isFalse); + expect(gate.remainingMs(), 0); + }); + + test('reset releases waiters and cancels the active timer', () async { + final timers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + + gate.activate(20); + final wait = gate.wait(); + gate.reset(); + + await wait; + expect(timers.single.isActive, isFalse); + expect(gate.isActive, isFalse); + }); +} + +class _ManualTimer implements Timer { + _ManualTimer(this.duration, this._callback); + + final Duration duration; + final void Function() _callback; + bool _active = true; + + void fire() { + if (!_active) return; + _active = false; + _callback(); + } + + @override + void cancel() => _active = false; + + @override + bool get isActive => _active; + + @override + int get tick => _active ? 0 : 1; +} diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 4647c05e07d..b97df0849d5 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -105,6 +105,178 @@ void main() { ); }); + test('queryRelay arms the rate-limit gate from a 429 retry hint', () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + const body = '{"error":"rate-limited: quota exceeded; retry in 4s"}'; + final harness = _queryHarness( + gate: gate, + client: http_testing.MockClient((_) async => http.Response(body, 429)), + ); + addTearDown(harness.container.dispose); + + await expectLater( + harness.session.queryRelay(const []), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 429) + .having((error) => error.body, 'body', body), + ), + ); + + expect(gateTimers.single.duration, const Duration(seconds: 4)); + expect(gate.isActive, isTrue); + }); + + test('queryRelay uses the default gate for a 503 without a hint', () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + const body = '{"error":"rate-limited: shared admission unavailable"}'; + final harness = _queryHarness( + gate: gate, + client: http_testing.MockClient((_) async => http.Response(body, 503)), + ); + addTearDown(harness.container.dispose); + + await expectLater( + harness.session.queryRelay(const []), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 503) + .having((error) => error.body, 'body', body), + ), + ); + + expect(gateTimers.single.duration, const Duration(seconds: 10)); + expect(gate.isActive, isTrue); + }); + + test('queryRelay does not arm the gate for a non-rate-limit error', () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + const body = '{"error":"not found"}'; + final harness = _queryHarness( + gate: gate, + client: http_testing.MockClient((_) async => http.Response(body, 404)), + ); + addTearDown(harness.container.dispose); + + await expectLater( + harness.session.queryRelay(const []), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 404) + .having((error) => error.body, 'body', body), + ), + ); + + expect(gateTimers, isEmpty); + expect(gate.isActive, isFalse); + }); + + test('queryRelay preserves an error with an unrecognized body', () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + const body = 'upstream unavailable'; + final harness = _queryHarness( + gate: gate, + client: http_testing.MockClient((_) async => http.Response(body, 503)), + ); + addTearDown(harness.container.dispose); + + await expectLater( + harness.session.queryRelay(const []), + throwsA( + isA() + .having((error) => error.statusCode, 'statusCode', 503) + .having((error) => error.body, 'body', body), + ), + ); + + expect(gateTimers, isEmpty); + expect(gate.isActive, isFalse); + }); + + test('queryRelay success does not arm the rate-limit gate', () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final harness = _queryHarness( + gate: gate, + client: http_testing.MockClient((_) async => http.Response('[]', 200)), + ); + addTearDown(harness.container.dispose); + + expect(await harness.session.queryRelay(const []), isEmpty); + expect(gateTimers, isEmpty); + expect(gate.isActive, isFalse); + }); + + test('queryRelay does not wait for an active rate-limit gate', () async { + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: _ManualTimer.new, + ); + var requestCount = 0; + final harness = _queryHarness( + gate: gate, + client: http_testing.MockClient((_) async { + requestCount++; + return http.Response('[]', 200); + }), + ); + addTearDown(harness.container.dispose); + // Let the provider's build/dispose churn settle before arming: reading the + // notifier registers `ref.onDispose(_dispose)`, and `_dispose` resets the + // shared gate. Arming before that settles leaves the gate disarmed by the + // time the request runs, which makes this row pass for the wrong reason. + await pumpEventQueue(); + gate.activate(4); + expect(gate.isActive, isTrue); + + final query = harness.session.queryRelay(const []); + await Future.delayed(Duration.zero); + + expect(requestCount, 1); + expect(await query, isEmpty); + // Still armed: the read must neither wait on the gate nor clear it. + expect(gate.isActive, isTrue); + }); + test( 'history timeout rejects instead of returning partial empty data', () async { @@ -328,7 +500,7 @@ void main() { unsubscribe(); }); - test('live subscribe fails when relay closes before ready', () async { + test('terminal CLOSED fails a live subscribe before ready', () async { final session = RelaySessionNotifier(); const filter = NostrFilter(kinds: [EventKind.agentObserverFrame], limit: 0); @@ -352,32 +524,601 @@ void main() { }); test( - 'live onClosed callback runs when relay closes an open subscription', + 'retryable CLOSED before EOSE retains and retries the live sub', () async { - final session = RelaySessionNotifier(); - final closedMessages = []; - const filter = NostrFilter( - kinds: [EventKind.agentObserverFrame], - limit: 0, + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, ); + session.debugAttachSocketForTest(socket); - final subscribe = session.subscribe( - filter, - (_) {}, - onClosed: closedMessages.add, + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['CLOSED', 'l-1', 'error: relay overloaded']); + final unsubscribe = await subscribe; + + expect(timers.single.duration, const Duration(seconds: 1)); + timers.single.fire(); + await Future.delayed(Duration.zero); + + expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(2)); + unsubscribe(); + }, + ); + + test('CLOSED retries back off and reset after EOSE', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + expect(timers.last.duration, const Duration(seconds: 1)); + timers.last.fire(); + await Future.delayed(Duration.zero); + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + expect(timers.last.duration, const Duration(seconds: 2)); + + session.debugHandleMessage(['EOSE', 'l-1']); + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + expect(timers.last.duration, const Duration(seconds: 1)); + unsubscribe(); + }); + + test('CLOSED retry backoff saturates before a high-attempt shift', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + + for (var attempt = 0; attempt < 100; attempt++) { + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + expect( + timers.last.duration, + attempt >= 5 + ? const Duration(seconds: 30) + : Duration(seconds: 1 << attempt), ); + timers.last.fire(); + await Future.delayed(Duration.zero); + } + + unsubscribe(); + }); + + test('CLOSED retries reset after a delivered event', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + timers.last.fire(); + await Future.delayed(Duration.zero); + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + expect(timers.last.duration, const Duration(seconds: 2)); + + session.debugHandleMessage([ + 'EVENT', + 'l-1', + _event(createdAt: 30).toJson(), + ]); + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + expect(timers.last.duration, const Duration(seconds: 1)); + unsubscribe(); + }); + + test('CLOSED retries reset after disconnect and reconnect', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + timers.last.fire(); + await Future.delayed(Duration.zero); + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + expect(timers.last.duration, const Duration(seconds: 2)); + + session.debugResetClosedRetriesForDisconnect(); + expect(timers.last.isActive, isFalse); + session.debugSetSessionStatus(SessionStatus.connected); + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + expect(timers.last.duration, const Duration(seconds: 1)); + unsubscribe(); + }); + + test('a CLOSED retry timer does not send while disconnected', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + final requestCount = _reqs(socket).length; + + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + session.debugSetSessionStatus(SessionStatus.reconnecting); + timers.single.fire(); + await Future.delayed(Duration.zero); + + expect(_reqs(socket), hasLength(requestCount)); + unsubscribe(); + }); + + test('terminal CLOSED removes a live sub without retrying it', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + await subscribe; + + session.debugHandleMessage(['CLOSED', 'l-1', 'restricted: access revoked']); + await session.debugReplayLiveSubscriptions(); + + expect(timers, isEmpty); + expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(1)); + }); + + test('unsubscribe and dispose cancel CLOSED retry timers', () async { + final timers = <_ManualTimer>[]; + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + timers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + + final firstSubscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await firstSubscribe; + session.debugHandleMessage(['CLOSED', 'l-1', 'error: transient']); + final unsubscribeTimer = timers.last; + unsubscribe(); + expect(unsubscribeTimer.isActive, isFalse); + + final secondSubscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-2']); + await secondSubscribe; + session.debugHandleMessage(['CLOSED', 'l-2', 'error: transient']); + final disposeTimer = timers.last; + session.debugDispose(); + expect(disposeTimer.isActive, isFalse); + }); + + test('rate-limited live CLOSED honours the gate floor', () async { + final retryTimers = <_ManualTimer>[]; + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final session = RelaySessionNotifier( + rateLimitGate: gate, + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + retryTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + + session.debugHandleMessage([ + 'CLOSED', + 'l-1', + 'rate-limited: quota exceeded; retry in 4s', + ]); + + expect( + retryTimers.single.duration.inMilliseconds, + inInclusiveRange(3990, 4000), + ); + expect(gateTimers.single.duration, const Duration(seconds: 4)); + unsubscribe(); + }); + + test( + 'rate-limited CLOSED retry does not survive a superseded connection', + () async { + final retryTimers = <_ManualTimer>[]; + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + rateLimitGate: gate, + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + retryTimers.add(timer); + return timer; + }, + ); + session.debugAttachSocketForTest(socket); + final subscribe = session.subscribe(_channelFilter, (_) {}); session.debugHandleMessage(['EOSE', 'l-1']); final unsubscribe = await subscribe; + socket.messages.clear(); + session.debugHandleMessage([ 'CLOSED', 'l-1', - 'restricted: no longer valid', + 'rate-limited: quota exceeded; retry in 4s', ]); + retryTimers.single.fire(); + await Future.delayed(Duration.zero); + expect(_reqs(socket), isEmpty); + + session.debugSupersedeConnection(); + final replacementReplay = session.debugReplayLiveSubscriptions(); + await Future.delayed(Duration.zero); + gateTimers.single.fire(); + await replacementReplay; + await Future.delayed(Duration.zero); - expect(closedMessages, ['restricted: no longer valid']); + expect(_reqs(socket).where((req) => req[1] == 'l-1'), hasLength(1)); unsubscribe(); }, ); + + test('simultaneous rate-limited CLOSED retries are replay-paced', () async { + final retryTimers = <_ManualTimer>[]; + final gateTimers = <_ManualTimer>[]; + final replayDelays = []; + final replayDelayCompleters = >[]; + final gate = RelayRateLimitGate( + now: () => DateTime(2026), + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + rateLimitGate: gate, + retryTimerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + retryTimers.add(timer); + return timer; + }, + replayDelay: (duration) { + replayDelays.add(duration); + final completer = Completer(); + replayDelayCompleters.add(completer); + return completer.future; + }, + ); + session.debugAttachSocketForTest(socket); + + for (var i = 0; i < 30; i++) { + final subscribe = session.subscribe( + _filterForChannel('channel-$i'), + (_) {}, + ); + session.debugHandleMessage(['EOSE', 'l-${i + 1}']); + await subscribe; + } + socket.messages.clear(); + + for (var i = 0; i < 30; i++) { + session.debugHandleMessage([ + 'CLOSED', + 'l-${i + 1}', + 'rate-limited: quota exceeded; retry in 4s', + ]); + } + for (final timer in retryTimers) { + timer.fire(); + } + await Future.delayed(Duration.zero); + expect(_reqs(socket), isEmpty); + + gateTimers.single.fire(); + await Future.delayed(Duration.zero); + expect(_reqs(socket), hasLength(8)); + expect(replayDelays, [const Duration(milliseconds: 50)]); + + for (final expectedCount in [16, 24, 30]) { + replayDelayCompleters.last.complete(); + await Future.delayed(Duration.zero); + expect(_reqs(socket), hasLength(expectedCount)); + } + expect(replayDelays, [ + const Duration(milliseconds: 50), + const Duration(milliseconds: 50), + const Duration(milliseconds: 50), + ]); + session.debugDispose(); + }); + + test('active rate-limit gate does not delay a new live subscribe', () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier(rateLimitGate: gate); + session.debugAttachSocketForTest(socket); + gate.activate(4); + + final subscribe = session.subscribe(_channelFilter, (_) {}); + + expect(_reqs(socket), hasLength(1)); + expect(gateTimers.single.duration, const Duration(seconds: 4)); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + unsubscribe(); + session.debugDispose(); + }); + + test('rate-limited history CLOSED gates the next REQ', () async { + final gateTimers = <_ManualTimer>[]; + final gate = RelayRateLimitGate( + timerFactory: (duration, callback) { + final timer = _ManualTimer(duration, callback); + gateTimers.add(timer); + return timer; + }, + ); + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier(rateLimitGate: gate); + session.debugAttachSocketForTest(socket); + + final first = session.fetchHistory(_channelFilter); + session.debugHandleMessage([ + 'CLOSED', + 'h-1', + 'rate-limited: quota exceeded; retry in 4s', + ]); + await expectLater(first, throwsException); + + final second = session.fetchHistory(_channelFilter); + await Future.delayed(Duration.zero); + expect(_reqs(socket), hasLength(1)); + expect(gateTimers.single.duration, const Duration(seconds: 4)); + + gateTimers.single.fire(); + await Future.delayed(Duration.zero); + expect(_reqs(socket), hasLength(2)); + session.debugHandleMessage(['EOSE', 'h-2']); + await second; + }); + + test( + 'visible channel owners restore and ignore out-of-order release', + () async { + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier(); + session.debugAttachSocketForTest(socket); + const channelIds = ['channel-a', 'channel-b', 'channel-c']; + + for (var i = 0; i < channelIds.length; i++) { + final subscribe = session.subscribe( + _filterForChannel(channelIds[i]), + (_) {}, + ); + session.debugHandleMessage(['EOSE', 'l-${i + 1}']); + await subscribe; + } + + final releaseA = session.registerVisibleChannel('channel-a'); + final releaseB = session.registerVisibleChannel('channel-b'); + final releaseC = session.registerVisibleChannel('channel-c'); + releaseB(); + socket.messages.clear(); + await session.debugReplayLiveSubscriptions(); + expect(_replayedChannelIds(socket).first, 'channel-c'); + + releaseC(); + socket.messages.clear(); + await session.debugReplayLiveSubscriptions(); + expect(_replayedChannelIds(socket).first, 'channel-a'); + + releaseB(); + releaseA(); + }, + ); + + test('replay is visible-first and batched eight at a time', () async { + final replayDelays = []; + final replayDelayCompleter = Completer(); + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + replayDelay: (duration) { + replayDelays.add(duration); + return replayDelayCompleter.future; + }, + ); + session.debugAttachSocketForTest(socket); + + for (var i = 0; i < 9; i++) { + final channelId = i == 8 ? _visibleChannelId : 'channel-$i'; + final subscribe = session.subscribe(_filterForChannel(channelId), (_) {}); + session.debugHandleMessage(['EOSE', 'l-${i + 1}']); + await subscribe; + } + socket.messages.clear(); + final releaseVisibleChannel = session.registerVisibleChannel( + _visibleChannelId, + ); + + final replay = session.debugReplayLiveSubscriptions(); + await Future.delayed(Duration.zero); + + final firstBatch = _reqs(socket); + expect(firstBatch, hasLength(8)); + expect((firstBatch.first[2] as Map)['#h'], [ + _visibleChannelId, + ]); + expect(replayDelays, [const Duration(milliseconds: 50)]); + + replayDelayCompleter.complete(); + await replay; + expect(_reqs(socket), hasLength(9)); + releaseVisibleChannel(); + }); + + test( + 'replay generation guard bails after a connection is superseded', + () async { + final replayDelayCompleter = Completer(); + final socket = _RecordingRelaySocket(); + final session = RelaySessionNotifier( + replayDelay: (_) => replayDelayCompleter.future, + ); + session.debugAttachSocketForTest(socket); + + for (var i = 0; i < 9; i++) { + final subscribe = session.subscribe( + _filterForChannel('channel-$i'), + (_) {}, + ); + session.debugHandleMessage(['EOSE', 'l-${i + 1}']); + await subscribe; + } + socket.messages.clear(); + + final replay = session.debugReplayLiveSubscriptions(); + await Future.delayed(Duration.zero); + expect(_reqs(socket), hasLength(8)); + + session.debugSupersedeConnection(); + replayDelayCompleter.complete(); + await replay; + + expect(_reqs(socket), hasLength(8)); + }, + ); + + test('live onClosed callback runs only for a terminal CLOSED', () async { + final session = RelaySessionNotifier(); + final closedMessages = []; + const filter = NostrFilter(kinds: [EventKind.agentObserverFrame], limit: 0); + + final subscribe = session.subscribe( + filter, + (_) {}, + onClosed: closedMessages.add, + ); + session.debugHandleMessage(['EOSE', 'l-1']); + final unsubscribe = await subscribe; + session.debugHandleMessage([ + 'CLOSED', + 'l-1', + 'error: temporarily unavailable', + ]); + expect(closedMessages, isEmpty); + session.debugHandleMessage([ + 'CLOSED', + 'l-1', + 'restricted: no longer valid', + ]); + + expect(closedMessages, ['restricted: no longer valid']); + unsubscribe(); + }); +} + +class _QueryHarness { + final ProviderContainer container; + final RelaySessionNotifier session; + + _QueryHarness({required this.container, required this.session}); +} + +_QueryHarness _queryHarness({ + required RelayRateLimitGate gate, + required http.Client client, +}) { + final session = RelaySessionNotifier(httpClient: client, rateLimitGate: gate); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ), + ), + ], + ); + container.read(relaySessionProvider); + return _QueryHarness(container: container, session: session); } class _FakeAuthNotifier extends AuthNotifier { @@ -437,11 +1178,11 @@ class _FakeRelayConfigNotifier extends RelayConfigNotifier { RelayConfig build() => RelayConfig(baseUrl: _baseUrl, nsec: _nsec); } -NostrEvent _event() { - return const NostrEvent( +NostrEvent _event({int createdAt = 20}) { + return NostrEvent( id: 'event-1', pubkey: 'alice', - createdAt: 20, + createdAt: createdAt, kind: EventKind.streamMessageV2, tags: [ ['h', _channelId], @@ -450,3 +1191,72 @@ NostrEvent _event() { sig: 'sig', ); } + +const _visibleChannelId = '99999999-9999-4999-8999-999999999999'; +const _channelFilter = NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [_channelId], + }, + limit: 0, +); + +NostrFilter _filterForChannel(String channelId) => NostrFilter( + kinds: EventKind.channelEventKinds, + tags: { + '#h': [channelId], + }, + limit: 0, +); + +List _replayedChannelIds(_RecordingRelaySocket socket) => _reqs(socket) + .map( + (message) => + ((message[2] as Map)['#h'] as List).single as String, + ) + .toList(); + +List> _reqs(_RecordingRelaySocket socket) => + socket.messages.where((message) => message.first == 'REQ').toList(); + +class _RecordingRelaySocket extends RelaySocket { + _RecordingRelaySocket() + : super( + wsUrl: 'wss://relay.example', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) {}, + ); + + final List> messages = []; + + @override + void send(List payload) => messages.add(payload); + + @override + void dispose() {} +} + +class _ManualTimer implements Timer { + _ManualTimer(this.duration, this._callback); + + final Duration duration; + final void Function() _callback; + bool _active = true; + + void fire() { + if (!_active) return; + _active = false; + _callback(); + } + + @override + void cancel() => _active = false; + + @override + bool get isActive => _active; + + @override + int get tick => _active ? 0 : 1; +} From 2c0ac2467437b30953a95e00f419143488bcfcc7 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 07:36:13 -0400 Subject: [PATCH 010/163] fix(desktop): stop the create-agent provider config probe from erasing keystrokes (#4411) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Fixes the create-agent dialog's "Run on" provider config fields eating keystrokes — reported by Tyler in buzz-remote-agents (channel `29414326`, thread `db76677a`): the Kubernetes **Kubeconfig context** field would not accept typing. ## Why it happened (the Typewriter Eraser, shipped in #4289) `WhereToRunSection`'s probe `useEffect` depended on the whole `draft`: 1. every keystroke changed the draft → effect re-fired → provider binary re-probed; 2. each probe result is a fresh object written into the draft → the effect re-triggered **itself**, respawning the provider binary in a loop for as long as the dialog sat on a provider; 3. every probe resolution reset `providerConfig` to schema defaults — erasing whatever was typed. A field with no schema default (`context`) snapped back to empty, i.e. "won't let me type". Unrelated to how many kubeconfig contexts you have. ## Fix - **Probe once per provider selection**, keyed on the provider's stable `binaryPath` — not the draft, not the provider object (a `useBackendProvidersQuery` refresh must not reprobe an unchanged selection). - **Latest-state resolution** via `React.useEffectEvent` + a new pure `applyProbeResult` helper: schema defaults merge **beneath** the current `providerConfig`, so a probe landing after the user typed can never clobber in-flight input (per Wren's pre-patch red-team: changing deps alone leaves a stale closure). Existing `cancelled` cleanup keeps provider-switch/unmount safe; selection reset (`emptyWhereToRunDraft`) and the fail-closed probe-error path are unchanged. ## Tests - **Unit** (`whereToRunIntent.test.mjs`): `applyProbeResult` merge semantics — defaults under typed values, user-cleared fields stay cleared, schema-less results, unrelated fields preserved. - **E2E** (new `where-to-run-config.spec.ts`, added to the smoke project, **red-first verified**: all 3 fail against the unfixed component): - typing into a defaultless provider field sticks, and `probe_backend_provider` fires exactly once per selection; - the config form is gated on probe resolution (slow probe: no half-rendered form, defaults prefill once); - provider → local → provider re-probes and resets cleanly. - Mock bridge gains `backendProviders` / `backendProviderProbeResult` / `backendProviderProbeDelayMs` seams (defaults preserve prior behavior). ## Verification at 8eb7680 - `pnpm check` + `tsc` clean, `pnpm test` 3926/3926; - new spec 3/3 green (and 3/3 red on the unfixed component); - pre-push lefthook: desktop-test, desktop-check, desktop-tauri-checks, rust-tests, mobile-test all green. --------- Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + .../features/agents/ui/WhereToRunSection.tsx | 49 +++--- .../agents/ui/whereToRunIntent.test.mjs | 70 ++++++++ .../features/agents/ui/whereToRunIntent.ts | 29 ++++ desktop/src/testing/e2eBridge.ts | 24 ++- desktop/tests/e2e/where-to-run-config.spec.ts | 154 ++++++++++++++++++ desktop/tests/helpers/bridge.ts | 19 +++ 7 files changed, 323 insertions(+), 23 deletions(-) create mode 100644 desktop/tests/e2e/where-to-run-config.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7ce7f48389a..773c2e6bf5e 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -132,6 +132,7 @@ export default defineConfig({ "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", + "**/where-to-run-config.spec.ts", "**/huddle-transcription.spec.ts", ], use: { diff --git a/desktop/src/features/agents/ui/WhereToRunSection.tsx b/desktop/src/features/agents/ui/WhereToRunSection.tsx index f068eceec82..ee9ec37132d 100644 --- a/desktop/src/features/agents/ui/WhereToRunSection.tsx +++ b/desktop/src/features/agents/ui/WhereToRunSection.tsx @@ -5,7 +5,11 @@ import { useBackendProvidersQuery } from "@/features/agents/hooks"; import { probeBackendProvider } from "@/shared/api/tauri"; import { ProviderConfigFields } from "./ProviderConfigFields"; -import { emptyWhereToRunDraft, type WhereToRunDraft } from "./whereToRunIntent"; +import { + applyProbeResult, + emptyWhereToRunDraft, + type WhereToRunDraft, +} from "./whereToRunIntent"; /** Optional remote-backend selector. Buzz shared compute is an LLM provider, not a run destination. */ export function WhereToRunSection({ @@ -26,32 +30,37 @@ export function WhereToRunSection({ [backendProviders, draft.runOn], ); + // Latest-state seam for probe resolution: an Effect Event always sees the + // draft as it is *now*. Without this, the probe promise closes over the + // draft from probe start, and anything typed while the probe was in flight + // gets thrown away when it resolves (a second, subtler Typewriter Eraser). + const applyProbe = React.useEffectEvent( + (result: Awaited>) => { + onDraftChange(applyProbeResult(draft, result)); + }, + ); + + // Probe once per provider *selection*, keyed on the provider's stable + // path — never on the draft. Depending on the draft made every keystroke + // refire the probe, and each resolution reset providerConfig to schema + // defaults, which erased what the user was typing (the Typewriter Eraser) + // and spawned the provider binary in a loop for as long as the dialog was + // open. Keying on the path (not the provider object) also keeps a + // providers-query refresh from reprobing an unchanged selection. + const selectedBinaryPath = isProviderMode + ? (selectedBackendProvider?.binaryPath ?? null) + : null; React.useEffect(() => { - if (!isProviderMode || !selectedBackendProvider) { + if (!selectedBinaryPath) { setProbeError(null); return; } let cancelled = false; setProbeError(null); - void probeBackendProvider(selectedBackendProvider.binaryPath) + void probeBackendProvider(selectedBinaryPath) .then((result) => { if (cancelled) return; - const defaults: Record = {}; - const properties = - (result.config_schema as Record | undefined) - ?.properties ?? {}; - for (const [key, property] of Object.entries(properties) as [ - string, - Record, - ][]) { - if (property.default != null) - defaults[key] = String(property.default); - } - onDraftChange({ - ...draft, - probedProvider: result, - providerConfig: defaults, - }); + applyProbe(result); }) .catch((error: unknown) => { if (!cancelled) { @@ -61,7 +70,7 @@ export function WhereToRunSection({ return () => { cancelled = true; }; - }, [draft, isProviderMode, onDraftChange, selectedBackendProvider]); + }, [selectedBinaryPath]); if (backendProviders.length === 0) return null; diff --git a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs index 500e9019f28..262d55d998d 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.test.mjs +++ b/desktop/src/features/agents/ui/whereToRunIntent.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + applyProbeResult, canSubmitWhereToRun, emptyWhereToRunDraft, providerConfigComplete, @@ -59,3 +60,72 @@ test("provider draft resolves with coerced config values", () => { config: { region: "us", size: 3 }, }); }); + +// ── applyProbeResult: probe resolution must merge, not overwrite ───────────── +// +// Pins the seam that fixed the "Typewriter Eraser" (agent-create dialog's +// provider config fields losing keystrokes): a probe resolution prefills +// schema defaults *beneath* the user's in-flight config, never over it. The +// effect in WhereToRunSection keys probing on the provider's binary path, so +// the only probe writes that reach providerConfig are the ones pinned here. + +const probeWithDefaults = { + ok: true, + config_schema: { + properties: { + context: { type: "string", title: "Kubeconfig context" }, + namespace: { type: "string", default: "buzz-agents-x1y2z3" }, + inactivity_seconds: { type: "number", default: 1800 }, + }, + required: ["namespace"], + }, +}; + +const unprobedDraft = { + ...emptyWhereToRunDraft, + runOn: "kubernetes", +}; + +test("probe resolution prefills schema defaults on a fresh draft", () => { + const next = applyProbeResult(unprobedDraft, probeWithDefaults); + assert.equal(next.probedProvider, probeWithDefaults); + assert.deepEqual(next.providerConfig, { + namespace: "buzz-agents-x1y2z3", + inactivity_seconds: "1800", + }); +}); + +test("probe resolution keeps user-typed values over schema defaults", () => { + const typed = { + ...unprobedDraft, + providerConfig: { context: "prod-us-west", namespace: "my-ns" }, + }; + const next = applyProbeResult(typed, probeWithDefaults); + assert.deepEqual(next.providerConfig, { + context: "prod-us-west", + namespace: "my-ns", + inactivity_seconds: "1800", + }); +}); + +test("probe resolution keeps a user-cleared field cleared", () => { + // "" is a deliberate user state — coerceConfigValues drops empty numerics + // and required-gating treats "" as incomplete; the probe must not undo it. + const cleared = { ...unprobedDraft, providerConfig: { namespace: "" } }; + const next = applyProbeResult(cleared, probeWithDefaults); + assert.equal(next.providerConfig.namespace, ""); +}); + +test("a schema-less probe result records the probe without touching config", () => { + const typed = { ...unprobedDraft, providerConfig: { context: "abc" } }; + const next = applyProbeResult(typed, { ok: true }); + assert.deepEqual(next.providerConfig, { context: "abc" }); + assert.deepEqual(next.probedProvider, { ok: true }); +}); + +test("probe resolution preserves unrelated draft fields", () => { + assert.equal( + applyProbeResult(unprobedDraft, probeWithDefaults).runOn, + "kubernetes", + ); +}); diff --git a/desktop/src/features/agents/ui/whereToRunIntent.ts b/desktop/src/features/agents/ui/whereToRunIntent.ts index fcb3e82b7e0..9aa9248e75d 100644 --- a/desktop/src/features/agents/ui/whereToRunIntent.ts +++ b/desktop/src/features/agents/ui/whereToRunIntent.ts @@ -15,6 +15,35 @@ export const emptyWhereToRunDraft: WhereToRunDraft = { probedProvider: null, }; +/** + * Fold a completed probe into the draft the user has *now* — not the draft + * that existed when the probe started. Schema defaults prefill only the keys + * the user has not touched: anything already in `providerConfig` (typed while + * the probe was in flight) wins over the default. Overwriting instead of + * merging is the "Typewriter Eraser" bug — every probe resolution silently + * erased in-flight keystrokes. + */ +export function applyProbeResult( + current: WhereToRunDraft, + result: BackendProviderProbeResult, +): WhereToRunDraft { + const defaults: Record = {}; + const properties = + (result.config_schema as Record | undefined)?.properties ?? + {}; + for (const [key, property] of Object.entries(properties) as [ + string, + Record, + ][]) { + if (property.default != null) defaults[key] = String(property.default); + } + return { + ...current, + probedProvider: result, + providerConfig: { ...defaults, ...current.providerConfig }, + }; +} + export function providerConfigComplete(draft: WhereToRunDraft): boolean { if (draft.runOn === "local") return true; if (!draft.probedProvider) return false; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 355ccea9fc3..f17faa218b0 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -507,6 +507,11 @@ type E2eConfig = { * returning a catalog. */ discoverAgentModelsError?: string; + // Backend provider mocks for the create-agent "Run on" section. See + // tests/helpers/bridge.ts:MockBridgeOptions for semantics. + backendProviders?: Array<{ id: string; binaryPath: string }>; + backendProviderProbeResult?: Record; + backendProviderProbeDelayMs?: number; }; relayHttpUrl?: string; relayWsUrl?: string; @@ -11064,9 +11069,22 @@ export function maybeInstallE2eTauriMocks() { activeConfig, ); case "discover_backend_providers": - return []; - case "probe_backend_provider": - return { ok: false, error: "mock: no providers available" }; + return activeConfig?.mock?.backendProviders ?? []; + case "probe_backend_provider": { + const probeDelayMs = + activeConfig?.mock?.backendProviderProbeDelayMs ?? 0; + if (probeDelayMs > 0) { + await new Promise((resolve) => + window.setTimeout(resolve, probeDelayMs), + ); + } + return ( + activeConfig?.mock?.backendProviderProbeResult ?? { + ok: false, + error: "mock: no providers available", + } + ); + } case "discover_managed_agent_prereqs": return handleDiscoverManagedAgentPrereqs( payload as Parameters[0], diff --git a/desktop/tests/e2e/where-to-run-config.spec.ts b/desktop/tests/e2e/where-to-run-config.spec.ts new file mode 100644 index 00000000000..869e9b2138c --- /dev/null +++ b/desktop/tests/e2e/where-to-run-config.spec.ts @@ -0,0 +1,154 @@ +/** + * E2E spec for the create-agent "Run on" provider config fields. + * + * Pins the fix for the "Typewriter Eraser": WhereToRunSection's probe effect + * used to depend on the whole draft, so every keystroke re-probed the + * provider and every probe resolution reset providerConfig to schema + * defaults — typing into a defaultless field (the k8s "Kubeconfig context") + * looked completely dead, and the provider binary respawned in a loop. + * + * Covers: + * - typing into a defaultless provider field sticks, and the provider is + * probed exactly once for the selection (not once per keystroke) + * - the config form is gated on probe resolution (no half-rendered form), + * and defaults prefill exactly once when a slow probe lands + * - switching provider → local → provider re-probes and resets cleanly + * + * The stale-closure merge on probe resolution (defaults beneath in-flight + * typing) is unreachable through this UI because the fields render only + * after the probe resolves; it is pinned at the unit level in + * whereToRunIntent.test.mjs (applyProbeResult). + */ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +type Page = import("@playwright/test").Page; + +const PROVIDER = { + id: "kubernetes", + binaryPath: "/mock/buzz-backend-kubernetes", +}; + +const PROBE_RESULT = { + ok: true, + name: "kubernetes", + version: "0.0.0-mock", + config_schema: { + type: "object", + properties: { + context: { + type: "string", + title: "Kubeconfig context", + description: "Context from your kubeconfig.", + }, + namespace: { + type: "string", + title: "Namespace", + default: "buzz-agents-mock01", + }, + }, + required: ["namespace"], + }, +}; + +async function probeInvocations(page: Page): Promise { + return page.evaluate( + () => + ( + window as Window & { __BUZZ_E2E_COMMANDS__?: string[] } + ).__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "probe_backend_provider", + ).length ?? 0, + ); +} + +/** Open the create-agent dialog and select the mocked provider in "Run on". */ +async function openCreateDialogOnProvider(page: Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("new-agent-card").click(); + await page.getByRole("menuitem", { name: "Create agent" }).click(); + const dialog = page.getByTestId("persona-dialog"); + await expect(dialog).toBeVisible({ timeout: 10_000 }); + await dialog.locator("#agent-run-on").selectOption(PROVIDER.id); + return dialog; +} + +test("typing into a defaultless provider field sticks and probes only once", async ({ + page, +}) => { + await installMockBridge(page, { + backendProviders: [PROVIDER], + backendProviderProbeResult: PROBE_RESULT, + }); + const dialog = await openCreateDialogOnProvider(page); + + const contextField = dialog.locator("#provider-cfg-context"); + await expect(contextField).toBeVisible({ timeout: 10_000 }); + // Defaults prefilled from the schema; context has none. + await expect(dialog.locator("#provider-cfg-namespace")).toHaveValue( + "buzz-agents-mock01", + ); + await expect(contextField).toHaveValue(""); + + await contextField.pressSequentially("prod-us-west", { delay: 20 }); + await expect(contextField).toHaveValue("prod-us-west"); + + // One selection, one probe — keystrokes must not refire it. + expect(await probeInvocations(page)).toBe(1); +}); + +test("config fields render only after a slow probe resolves, with defaults", async ({ + page, +}) => { + // The fields are gated on the probe result (draft.probedProvider), which is + // what makes mid-flight typing unreachable through the UI — the stale-probe + // merge seam (applyProbeResult) is pinned at the unit level instead. This + // spec holds the gate: no half-rendered form before the probe lands, and + // defaults appear exactly once when it does. + await installMockBridge(page, { + backendProviders: [PROVIDER], + backendProviderProbeResult: PROBE_RESULT, + backendProviderProbeDelayMs: 1_000, + }); + const dialog = await openCreateDialogOnProvider(page); + + // Pre-resolution: the security warning is up, the form is not. + await expect(dialog.getByText("will receive your agent")).toBeVisible(); + await expect(dialog.locator("#provider-cfg-context")).toHaveCount(0); + + // Post-resolution: fields render with schema defaults prefilled. + await expect(dialog.locator("#provider-cfg-context")).toBeVisible({ + timeout: 10_000, + }); + await expect(dialog.locator("#provider-cfg-namespace")).toHaveValue( + "buzz-agents-mock01", + ); + expect(await probeInvocations(page)).toBe(1); +}); + +test("provider → local → provider re-probes and resets the config", async ({ + page, +}) => { + await installMockBridge(page, { + backendProviders: [PROVIDER], + backendProviderProbeResult: PROBE_RESULT, + }); + const dialog = await openCreateDialogOnProvider(page); + + const contextField = dialog.locator("#provider-cfg-context"); + await expect(contextField).toBeVisible({ timeout: 10_000 }); + await contextField.fill("stale-value"); + + await dialog.locator("#agent-run-on").selectOption("local"); + await expect(contextField).toHaveCount(0); + + await dialog.locator("#agent-run-on").selectOption(PROVIDER.id); + await expect(dialog.locator("#provider-cfg-context")).toBeVisible({ + timeout: 10_000, + }); + // Fresh selection = fresh draft: the stale value must not leak back. + await expect(dialog.locator("#provider-cfg-context")).toHaveValue(""); + expect(await probeInvocations(page)).toBe(2); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 8a9ab2be119..345e1ee4d7f 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -524,6 +524,25 @@ type MockBridgeOptions = { * returning a catalog. Exercises the discovery-failure UI path. */ discoverAgentModelsError?: string; + /** + * Providers returned by `discover_backend_providers`. Defaults to `[]` + * (the "Run on" section stays hidden). Setting this renders the remote + * backend selector in the create-agent dialog. + */ + backendProviders?: Array<{ id: string; binaryPath: string }>; + /** + * Result returned by `probe_backend_provider`. Defaults to + * `{ ok: false, error: "mock: no providers available" }`. + */ + backendProviderProbeResult?: Record; + /** + * Delay (ms) applied to `probe_backend_provider` so a spec can assert the + * pre-resolution state (config fields stay probe-gated until the result + * lands). Typing while a probe is in flight is unreachable through the UI + * for the same reason; that merge path is pinned at the unit level + * (`applyProbeResult` in whereToRunIntent.test.mjs). + */ + backendProviderProbeDelayMs?: number; }; type BridgeOptions = { From 83a285f1b1a0be862d55781fad9c75ec8813886d Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:14:01 -0400 Subject: [PATCH 011/163] ci(linux): enable mesh-llm feature in Linux release and canary builds (#4524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Official Linux desktop packages (`.deb` / AppImage) are built without `--features mesh-llm`, so they ship the `mesh_llm_stubs` backend and Settings → Compute always fails with `mesh-llm feature not enabled`. This PR adds the feature flag to the two Linux build commands: - `release.yml` → `release-linux` job - `linux-canary.yml` → canary build That's the whole diff — 2 lines. Fixes #3788 (Linux); see also #3841 (dup with UI-gating PR #3914) and the Windows twin #2836/#3223. ## Why no native prebuild step (unlike the macOS job) The macOS job carries Metal llama prebuild/cache steps from #798. Linux doesn't need an equivalent: - `mesh-llm-host-runtime` is compiled with `dynamic-native-runtime` and installs the recommended runtime on first use (verified by sha256 checksum over HTTPS; upstream's signature verification path is not yet implemented — default policy is `RequireChecksum`, per `mesh-llm-runtime-install/src/lib.rs`) (`desktop/src-tauri/src/mesh_llm/mod.rs` — `initialize_mesh_native_runtime`), so release builds work on clean machines without bundling llama.cpp. - Upstream publishes Linux x86_64/aarch64 runtime bundles for the pinned `v0.74.0` line, and `scripts/ensure-mesh-native-runtime.sh` already maps `meshllm-native-runtime-linux-x86_64-cpu` / `linux-aarch64-cpu` for local/e2e use. - The unmerged branch `micn/mesh-node-download` (`96f29417a`) treats even the macOS prebuild steps as removable dead weight for the same reason. ## Background The omission is historical drift, not a decision: Linux packaging predates the mesh feature flag (#693), mesh became opt-in for build-cost/reliability reasons (#823, #1183), and #1221 re-enabled it for releases by editing only the macOS build line. `release-linux` and the later `linux-canary` copy were never revisited. The mesh shutdown hard-exit/relaunch path is gated `all(mesh-llm, target_os = "macos")` because ggml/Metal destructors abort on macOS; ordinary mesh shutdown (`shutdown_mesh_runtime`) is cross-platform, so Linux falls through to the generic path. ## Validation - [x] `./bin/cargo check --manifest-path desktop/src-tauri/Cargo.toml --features mesh-llm` green at base `2c0ac2467` (feature graph compiles at the pinned v0.74.0 line) - [ ] Linux canary run with this change: AppImage/.deb build succeeds and binary contains real `mesh_llm` symbols (not `mesh_llm_stubs`) - [ ] Installed package: cold-start → Settings → Compute → runtime download → serve → clean shutdown The last two need a Linux run/host. **Note (from review):** `linux-canary.yml` is `workflow_dispatch`-only and its `Require main` step rejects non-main refs, so the canary cannot run on this branch pre-merge — and `.github/workflows/**` matches no ci.yml paths-filter, so this PR's own CI does not exercise the changed lines. Validation sequencing is therefore merge → dispatch linux-canary on main → live-package pass, with a trivial 2-line revert as the escape hatch. Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- .github/workflows/linux-canary.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index 16648787708..e1625f4ec83 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -170,7 +170,7 @@ jobs: ./scripts/bundle-sidecars.sh - name: Build Linux Tauri app - run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --config src-tauri/tauri.canary.conf.json + run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.canary.conf.json env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02011ad3867..9da067b74ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -573,7 +573,7 @@ jobs: BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json - name: Build Linux Tauri app - run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --config src-tauri/tauri.release.conf.json + run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json From 857e63c4ddfb76f95ab40bb691e00544413f6b81 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 3 Aug 2026 15:30:23 +0100 Subject: [PATCH 012/163] Polish mobile composer and messaging UI (#3918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Refine the mobile composer with compact and expanded states, shared footer fades, haptics, reliable keyboard dismissal, and full-width camera and photo surfaces. - Standardize popovers, filters, and section menus with consistent type, strokes, radii, spacing, icons, and destructive styling. - Align message presentation with desktop through consistent system rows, typing and loading feedback, emoji placement, and predictable photo viewing. ## Validation - `just mobile-check` - `just mobile-test` — 1,037 passed, 1 skipped - Tested on Pixel 10 and a connected iPhone ## Snapshots
Compact composer Attachment menu Recent photos
--------- Signed-off-by: kenny lopez --- mobile/ios/Runner/InlinePhotoPicker.swift | 11 - .../ios/Runner/NativeAttachmentPopover.swift | 89 ++-- .../NativeAttachmentPopoverCoordinator.swift | 68 ++- mobile/ios/RunnerTests/RunnerTests.swift | 35 +- .../lib/features/activity/activity_page.dart | 16 +- .../activity_page/header_actions.dart | 16 - .../activity/activity_page/lists.dart | 4 +- .../activity/activity_page/status_views.dart | 7 +- .../channels/channel_detail_page.dart | 259 ++++++---- .../channel_detail_page/message_list.dart | 71 ++- .../channel_detail_page/system_rows.dart | 22 +- .../channels/channel_typing_indicator.dart | 66 ++- .../channels/channels_page/sections.dart | 66 ++- mobile/lib/features/channels/compose_bar.dart | 126 ++--- .../channels/compose_bar/attachments.dart | 97 ++-- .../channels/compose_bar/camera_preview.dart | 4 +- .../features/channels/compose_bar/dock.dart | 144 ++++++ .../compose_bar/formatting_toolbar.dart | 4 +- .../channels/compose_bar/helpers.dart | 41 ++ .../compose_bar/ios_photo_picker.dart | 9 +- .../features/channels/compose_bar/layout.dart | 32 +- .../compose_bar/photo_gallery_picker.dart | 18 +- .../channels/compose_bar/send_button.dart | 4 +- .../channels/compose_bar/suggestions.dart | 154 +++--- .../channels/composer_dock_size_reporter.dart | 50 ++ .../lib/features/channels/emoji_picker.dart | 4 +- .../channels/emoji_picker/emoji_grid.dart | 5 +- .../features/channels/message_actions.dart | 3 +- .../lib/features/channels/reaction_row.dart | 3 +- .../features/channels/thread_detail_page.dart | 469 ++++++++++++------ .../lib/shared/emoji/native_emoji_glyph.dart | 22 + mobile/lib/shared/theme/app_theme.dart | 20 +- .../lib/shared/theme/message_typography.dart | 10 +- .../shared/widgets/anchored_popover_menu.dart | 32 +- .../lib/shared/widgets/filter_chip_bar.dart | 18 +- .../widgets/keyboard_dismiss_on_drag.dart | 15 +- .../widgets/mobile_tab_footer_backdrop.dart | 32 +- .../features/activity/activity_page_test.dart | 44 +- .../channels/channel_detail_page_test.dart | 346 ++++++++++++- .../features/channels/channels_page_test.dart | 74 +++ .../features/channels/compose_bar_test.dart | 362 ++++++++++++++ .../shared/emoji/native_emoji_glyph_test.dart | 37 ++ mobile/test/shared/theme/app_theme_test.dart | 17 + .../shared/theme/message_typography_test.dart | 7 + .../shared/widgets/filter_chip_bar_test.dart | 52 +- .../keyboard_dismiss_on_drag_test.dart | 23 +- .../mobile_tab_footer_backdrop_test.dart | 22 + 47 files changed, 2376 insertions(+), 654 deletions(-) create mode 100644 mobile/lib/features/channels/compose_bar/dock.dart create mode 100644 mobile/lib/features/channels/composer_dock_size_reporter.dart create mode 100644 mobile/lib/shared/emoji/native_emoji_glyph.dart create mode 100644 mobile/test/shared/emoji/native_emoji_glyph_test.dart diff --git a/mobile/ios/Runner/InlinePhotoPicker.swift b/mobile/ios/Runner/InlinePhotoPicker.swift index 05a576323f9..41267347794 100644 --- a/mobile/ios/Runner/InlinePhotoPicker.swift +++ b/mobile/ios/Runner/InlinePhotoPicker.swift @@ -3,14 +3,6 @@ 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? @@ -130,9 +122,6 @@ final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView { } 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 f2f6c01df83..cc29de9cb32 100644 --- a/mobile/ios/Runner/NativeAttachmentPopover.swift +++ b/mobile/ios/Runner/NativeAttachmentPopover.swift @@ -17,8 +17,6 @@ final class NativeAttachmentPopoverViewController: case camera } - private typealias ContentPreparation = (@escaping () -> Void) -> Void - private let channel: FlutterMethodChannel private let expandedWidth: CGFloat private let maximumMenuHeight: CGFloat @@ -85,17 +83,29 @@ final class NativeAttachmentPopoverViewController: override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .clear - view.layer.cornerRadius = 22 + view.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius view.layer.cornerCurve = .continuous - view.clipsToBounds = true + view.layer.borderColor = UIColor.black.withAlphaComponent(0.04).cgColor + view.layer.borderWidth = NativeAttachmentPopoverStyle.borderWidth + view.layer.shadowColor = UIColor.black.cgColor + view.layer.shadowOpacity = NativeAttachmentPopoverStyle.shadowOpacity + view.layer.shadowRadius = NativeAttachmentPopoverStyle.shadowRadius + view.layer.shadowOffset = NativeAttachmentPopoverStyle.shadowOffset + view.clipsToBounds = false let glassEffect = UIGlassEffect(style: .regular) glassEffect.isInteractive = true let glassView = UIVisualEffectView(effect: glassEffect) glassView.translatesAutoresizingMaskIntoConstraints = false + glassView.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius + glassView.layer.cornerCurve = .continuous + glassView.clipsToBounds = true view.addSubview(glassView) contentHost.translatesAutoresizingMaskIntoConstraints = false + contentHost.layer.cornerRadius = NativeAttachmentPopoverStyle.cornerRadius + contentHost.layer.cornerCurve = .continuous + contentHost.clipsToBounds = true view.addSubview(contentHost) NSLayoutConstraint.activate([ glassView.leadingAnchor.constraint(equalTo: view.leadingAnchor), @@ -114,6 +124,11 @@ final class NativeAttachmentPopoverViewController: override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() + view.layer.shadowPath = + UIBezierPath( + roundedRect: view.bounds, + cornerRadius: NativeAttachmentPopoverStyle.cornerRadius + ).cgPath cameraPreviewLayer?.frame = cameraPreviewView?.bounds ?? .zero } @@ -198,21 +213,21 @@ final class NativeAttachmentPopoverViewController: makeNativeAttachmentMenuButton( title: "Camera", symbol: "camera", - action: UIAction { [weak self] _ in self?.showCamera() } + action: { [weak self] in self?.showCamera() } ) ) stack.addArrangedSubview( makeNativeAttachmentMenuButton( title: "Photos", symbol: "photo.on.rectangle.angled", - action: UIAction { [weak self] _ in self?.showPhotos() } + action: { [weak self] in self?.showPhotos() } ) ) stack.addArrangedSubview( makeNativeAttachmentMenuButton( title: "Video", symbol: "video", - action: UIAction { [weak self] _ in + action: { [weak self] in self?.finish(method: "pickVideo") } ) @@ -221,7 +236,7 @@ final class NativeAttachmentPopoverViewController: makeNativeAttachmentMenuButton( title: "Files", symbol: "doc", - action: UIAction { [weak self] _ in + action: { [weak self] in self?.finish(method: "pickFiles") } ) @@ -280,14 +295,14 @@ final class NativeAttachmentPopoverViewController: title: nil, symbol: "chevron.left", accessibilityLabel: "Back to attachment options", - action: UIAction { [weak self] _ in self?.showMenu() } + action: { [weak self] in self?.showMenu() } ) let actionButton = makeGlassControl( title: "All Photos", symbol: nil, accessibilityLabel: "All Photos", prominent: true, - action: UIAction { [weak self] _ in self?.performPhotoAction() } + action: { [weak self] in self?.performPhotoAction() } ) photoActionButton = actionButton addBottomControls( @@ -296,27 +311,7 @@ final class NativeAttachmentPopoverViewController: trailing: actionButton ) - 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) - } - } - ) + transition(to: .photos, content: container) } private func showCamera() { @@ -353,7 +348,7 @@ final class NativeAttachmentPopoverViewController: title: nil, symbol: "chevron.left", accessibilityLabel: "Back to attachment options", - action: UIAction { [weak self] _ in self?.showMenu() } + action: { [weak self] in self?.showMenu() } ) let captureButton = makeCameraCaptureButton() cameraCaptureButton = captureButton @@ -415,7 +410,6 @@ final class NativeAttachmentPopoverViewController: private func transition( to nextSurface: Surface, content nextView: UIView, - preparation: ContentPreparation? = nil, completion: (() -> Void)? = nil ) { let previousView = visibleContentView @@ -485,11 +479,7 @@ final class NativeAttachmentPopoverViewController: } } - if let preparation { - preparation(reveal) - } else { - reveal() - } + reveal() } } @@ -498,7 +488,7 @@ final class NativeAttachmentPopoverViewController: symbol: String?, accessibilityLabel: String, prominent: Bool = false, - action: UIAction + action: @escaping () -> Void ) -> UIButton { var configuration = prominent @@ -513,20 +503,37 @@ final class NativeAttachmentPopoverViewController: } configuration.imagePadding = 8 configuration.baseForegroundColor = .white + configuration.titleTextAttributesTransformer = + UIConfigurationTextAttributesTransformer { attributes in + var interAttributes = attributes + interAttributes.font = NativeAttachmentMenuTypography.font( + forTextStyle: .body + ) + return interAttributes + } configuration.contentInsets = NSDirectionalEdgeInsets( top: 11, leading: 15, bottom: 11, trailing: 15 ) - let button = UIButton(configuration: configuration, primaryAction: action) + let button = UIButton( + configuration: configuration, + primaryAction: UIAction { _ in + UISelectionFeedbackGenerator().selectionChanged() + action() + } + ) button.accessibilityLabel = accessibilityLabel return button } private func makeCameraCaptureButton() -> UIButton { let button = UIButton( - primaryAction: UIAction { [weak self] _ in self?.capturePhoto() } + primaryAction: UIAction { [weak self] _ in + UISelectionFeedbackGenerator().selectionChanged() + self?.capturePhoto() + } ) button.accessibilityLabel = "Take photo" button.translatesAutoresizingMaskIntoConstraints = false diff --git a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift index 73559d7c2b6..f59db1f84c4 100644 --- a/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift +++ b/mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift @@ -1,3 +1,4 @@ +import CoreText import Flutter import UIKit @@ -276,7 +277,7 @@ enum NativeAttachmentMenuLayout { static func itemHeight( compatibleWith traitCollection: UITraitCollection ) -> CGFloat { - let labelHeight = UIFont.preferredFont( + let labelHeight = NativeAttachmentMenuTypography.font( forTextStyle: labelTextStyle, compatibleWith: traitCollection ).lineHeight @@ -313,12 +314,71 @@ enum NativeAttachmentMenuLayout { } } +enum NativeAttachmentMenuTypography { + static let interPostScriptName = "InterVariable" + + private static let registeredInter: Bool = { + let fontURL = Bundle.main.bundleURL + .appendingPathComponent("Frameworks") + .appendingPathComponent("App.framework") + .appendingPathComponent("flutter_assets") + .appendingPathComponent("assets") + .appendingPathComponent("fonts") + .appendingPathComponent("InterVariable.ttf") + guard FileManager.default.fileExists(atPath: fontURL.path) else { + return false + } + return CTFontManagerRegisterFontsForURL( + fontURL as CFURL, + .process, + nil + ) + }() + + static func font( + forTextStyle textStyle: UIFont.TextStyle, + compatibleWith traitCollection: UITraitCollection? = nil + ) -> UIFont { + _ = registeredInter + let scaledPointSize = UIFontMetrics(forTextStyle: textStyle).scaledValue( + for: 20, + compatibleWith: traitCollection + ) + let preferredFont = UIFont.preferredFont( + forTextStyle: textStyle, + compatibleWith: traitCollection + ) + guard + let interFont = UIFont( + name: interPostScriptName, + size: scaledPointSize + ) + else { + return preferredFont + } + return interFont + } +} + +enum NativeAttachmentPopoverStyle { + static let cornerRadius: CGFloat = 20 + static let shadowOpacity: Float = 0.18 + static let shadowRadius: CGFloat = 12 + static let shadowOffset = CGSize(width: 0, height: 6) + static let borderWidth: CGFloat = 1 +} + func makeNativeAttachmentMenuButton( title: String, symbol: String, - action: UIAction + action: @escaping () -> Void ) -> UIButton { - let button = UIButton(primaryAction: action) + let button = UIButton( + primaryAction: UIAction { _ in + UISelectionFeedbackGenerator().selectionChanged() + action() + } + ) button.accessibilityLabel = title let symbolConfiguration = UIImage.SymbolConfiguration( @@ -338,7 +398,7 @@ func makeNativeAttachmentMenuButton( let titleLabel = UILabel() titleLabel.text = title titleLabel.textColor = .label - titleLabel.font = .preferredFont( + titleLabel.font = NativeAttachmentMenuTypography.font( forTextStyle: NativeAttachmentMenuLayout.labelTextStyle ) titleLabel.adjustsFontForContentSizeCategory = true diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index c5333cfdf22..8374ca77b60 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -127,19 +127,6 @@ class RunnerTests: XCTestCase { ) } - 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) @@ -155,6 +142,28 @@ class RunnerTests: XCTestCase { XCTAssertEqual(NativeAttachmentMenuLayout.labelTextStyle, .title3) } + func testNativeAttachmentMenuUsesInterAndSharedPopoverChrome() { + let font = NativeAttachmentMenuTypography.font( + forTextStyle: NativeAttachmentMenuLayout.labelTextStyle + ) + var didSelect = false + let button = makeNativeAttachmentMenuButton( + title: "Photos", + symbol: "photo", + action: { didSelect = true } + ) + let titleLabel = button.subviews.compactMap { $0 as? UILabel }.first + + XCTAssertTrue(font.fontName.hasPrefix("Inter")) + XCTAssertTrue(titleLabel?.font.fontName.hasPrefix("Inter") == true) + XCTAssertEqual(NativeAttachmentPopoverStyle.cornerRadius, 20) + XCTAssertEqual(NativeAttachmentPopoverStyle.borderWidth, 1) + XCTAssertEqual(NativeAttachmentPopoverStyle.shadowOpacity, 0.18) + + button.sendActions(for: .primaryActionTriggered) + XCTAssertTrue(didSelect) + } + func testNativeAttachmentMenuGrowsAndScrollsForAccessibilityText() { let traits = UITraitCollection( preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index aecef6329cc..5b4cebfa975 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -37,6 +37,18 @@ part 'activity_page/inbox_row.dart'; part 'activity_page/lists.dart'; part 'activity_page/status_views.dart'; +EdgeInsets _activityScrollPadding( + BuildContext context, { + double horizontal = 0, + double top = Grid.xxs, + double bottom = Grid.xxs, +}) => EdgeInsets.fromLTRB( + horizontal, + top, + horizontal, + MediaQuery.paddingOf(context).bottom + bottom, +); + /// Conversation-oriented Activity inbox. /// /// Matches desktop's Home inbox item design and semantics (see @@ -264,7 +276,7 @@ class ActivityPage extends HookConsumerWidget { body = RefreshIndicator( onRefresh: refresh, child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: _activityScrollPadding(context), itemCount: visibleItems.length, itemBuilder: (context, index) { final item = visibleItems[index]; @@ -318,7 +330,9 @@ class ActivityPage extends HookConsumerWidget { ], ), body: SafeArea( + key: const ValueKey('activity-content-safe-area'), top: false, + bottom: false, child: Padding( padding: EdgeInsets.only( top: frostedAppBarHeight(context, titleStyle: headerTitleStyle), diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 56592e2e69f..39bb29e7a39 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -39,15 +39,6 @@ class _FilterMenuButton extends StatelessWidget { alignment: AnchoredPopoverAlignment.start, offset: const Offset(0, Grid.half), menuPadding: const EdgeInsets.symmetric(vertical: Grid.half), - color: context.colors.surface.withValues(alpha: 0.98), - elevation: 8, - shadowColor: context.colors.shadow.withValues(alpha: 0.18), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.card), - side: BorderSide( - color: context.colors.outlineVariant.withValues(alpha: 0.45), - ), - ), surfaceKey: const ValueKey('activity-filter-popover'), items: [ for (final entry in _filterLabels.entries) @@ -183,13 +174,6 @@ class _InboxOptionsButton extends StatelessWidget { 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), - ), surfaceKey: const ValueKey('activity-options-popover'), items: [ PopupMenuItem( diff --git a/mobile/lib/features/activity/activity_page/lists.dart b/mobile/lib/features/activity/activity_page/lists.dart index 37311c9287c..9df193fe96e 100644 --- a/mobile/lib/features/activity/activity_page/lists.dart +++ b/mobile/lib/features/activity/activity_page/lists.dart @@ -36,7 +36,7 @@ class _RemindersList extends ConsumerWidget { return RefreshIndicator( onRefresh: onRefresh, child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: _activityScrollPadding(context), itemCount: reminders.length, itemBuilder: (context, index) { final reminder = reminders[index]; @@ -97,7 +97,7 @@ class _DraftsList extends StatelessWidget { } return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + padding: _activityScrollPadding(context), itemCount: drafts.length, itemBuilder: (context, index) { final draft = drafts[index]; diff --git a/mobile/lib/features/activity/activity_page/status_views.dart b/mobile/lib/features/activity/activity_page/status_views.dart index 11115d9dc8e..442634849c3 100644 --- a/mobile/lib/features/activity/activity_page/status_views.dart +++ b/mobile/lib/features/activity/activity_page/status_views.dart @@ -6,7 +6,12 @@ class _LoadingSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.separated( - padding: const EdgeInsets.all(Grid.gutter), + padding: _activityScrollPadding( + context, + horizontal: Grid.gutter, + top: Grid.gutter, + bottom: Grid.gutter, + ), itemCount: 8, separatorBuilder: (_, _) => const SizedBox(height: Grid.xs), itemBuilder: (context, _) => Row( diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index a24b6c07ee6..f1efb1557f3 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' show min; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show ScrollDirection; @@ -32,6 +33,7 @@ import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; +import 'composer_dock_size_reporter.dart'; import 'date_formatters.dart'; import 'day_divider.dart'; import 'dm_channel_labels.dart'; @@ -125,6 +127,7 @@ class ChannelDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final composerDockHeight = useState(0.0); final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); final messagesState = ref.watch(channelMessagesProvider(channel.id)); @@ -156,6 +159,10 @@ class ChannelDetailPage extends HookConsumerWidget { channel; final resolvedChannel = detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel; + final showsComposer = + !resolvedChannel.isForum && + resolvedChannel.isMember && + !resolvedChannel.isArchived; final messagesNotifier = ref.read( channelMessagesProvider(channel.id).notifier, ); @@ -293,122 +300,162 @@ class ChannelDetailPage extends HookConsumerWidget { ), ], ), - body: Column( + body: Stack( + fit: StackFit.expand, children: [ - Expanded( - child: resolvedChannel.isForum - ? Stack( - fit: StackFit.expand, - children: [ - ForumPostsView( - channel: resolvedChannel, - currentPubkey: currentPubkey, - ), - if (showConnectionSkeleton.value) - Positioned( - top: - frostedAppBarHeight( - context, - titleContentHeight: appBarTitleContentHeight, - ) + - Grid.xs, - left: Grid.gutter, - right: Grid.gutter, - child: _ForumConnectionSkeleton( - status: sessionStatus, - ), - ), - ], - ) - : SkeletonReveal( - loading: - showInitialConnectionSkeleton || - showConnectionSkeleton.value || - messagesState.isLoading, - shimmerEnabled: sessionStatus != SessionStatus.disconnected, - skeleton: _MessageTimelineSkeleton( - appBarTitleContentHeight: appBarTitleContentHeight, - status: sessionStatus, - ), - content: messagesState.when( - loading: SizedBox.shrink, - error: (e, _) => Padding( - padding: EdgeInsets.only( - top: frostedAppBarHeight( - context, - titleContentHeight: appBarTitleContentHeight, + Column( + children: [ + Expanded( + child: resolvedChannel.isForum + ? Stack( + fit: StackFit.expand, + children: [ + ForumPostsView( + channel: resolvedChannel, + currentPubkey: currentPubkey, ), + if (showConnectionSkeleton.value) + Positioned( + top: + frostedAppBarHeight( + context, + titleContentHeight: + appBarTitleContentHeight, + ) + + Grid.xs, + left: Grid.gutter, + right: Grid.gutter, + child: _ForumConnectionSkeleton( + status: sessionStatus, + ), + ), + ], + ) + : SkeletonReveal( + loading: + showInitialConnectionSkeleton || + showConnectionSkeleton.value || + messagesState.isLoading, + shimmerEnabled: + sessionStatus != SessionStatus.disconnected, + skeleton: _MessageTimelineSkeleton( + appBarTitleContentHeight: appBarTitleContentHeight, + status: sessionStatus, ), - child: Center( - child: Text( - 'Failed to load messages', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.error, + content: messagesState.when( + loading: SizedBox.shrink, + error: (e, _) => Padding( + padding: EdgeInsets.only( + top: frostedAppBarHeight( + context, + titleContentHeight: appBarTitleContentHeight, + ), + ), + child: Center( + child: Text( + 'Failed to load messages', + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.error, + ), + ), ), ), + data: (events) { + final messages = formatTimeline( + events, + currentPubkey: currentPubkey, + ); + final summaries = ref + .read( + channelMessagesProvider(channel.id).notifier, + ) + .threadSummaries; + final entries = buildMainTimelineEntries( + messages, + relaySummaries: summaries, + ); + return _MessageList( + entries: entries, + allMessages: messages, + initialMessageId: initialMessageId, + initialThreadRootId: initialThreadRootId, + channelId: channel.id, + currentPubkey: currentPubkey, + isMember: resolvedChannel.isMember, + isArchived: resolvedChannel.isArchived, + appBarTitleContentHeight: + appBarTitleContentHeight, + composerBottomInset: showsComposer + ? composerDockHeight.value + : 0, + ); + }, ), ), - data: (events) { - final messages = formatTimeline( - events, - currentPubkey: currentPubkey, - ); - final summaries = ref - .read(channelMessagesProvider(channel.id).notifier) - .threadSummaries; - final entries = buildMainTimelineEntries( - messages, - relaySummaries: summaries, - ); - return _MessageList( - entries: entries, - allMessages: messages, - initialMessageId: initialMessageId, - initialThreadRootId: initialThreadRootId, - channelId: channel.id, - currentPubkey: currentPubkey, - isMember: resolvedChannel.isMember, - isArchived: resolvedChannel.isArchived, - appBarTitleContentHeight: appBarTitleContentHeight, - ); - }, - ), - ), + ), + if (!resolvedChannel.isForum && + (!resolvedChannel.isMember || + resolvedChannel.isArchived)) ...[ + 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.isDm) + _ReadOnlyNotice(channel: resolvedChannel), + ], + ], ), - if (!resolvedChannel.isForum) - AnimatedSize( - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - curve: Curves.easeOutCubic, + if (showsComposer) + Align( alignment: Alignment.bottomCenter, - child: typingEntries.isEmpty - ? const SizedBox.shrink() - : ChannelTypingIndicator(entries: typingEntries), + child: ComposerDockSizeReporter( + key: const ValueKey('channel-composer-dock'), + onHeightChanged: (height) { + if ((composerDockHeight.value - height).abs() < 0.5) return; + composerDockHeight.value = height; + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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), + ), + ComposeBar( + channelId: channel.id, + channelName: resolvedChannel.isDm + ? '' + : resolvedChannel.name, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) => ref + .read(sendMessageProvider) + .call( + channelId: channel.id, + content: content, + mentionPubkeys: mentionPubkeys, + mediaTags: mediaTags, + ), + ), + ], + ), + ), ), - if (!resolvedChannel.isForum && - resolvedChannel.isMember && - !resolvedChannel.isArchived) - ComposeBar( - channelId: channel.id, - channelName: resolvedChannel.isDm ? '' : resolvedChannel.name, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) => ref - .read(sendMessageProvider) - .call( - channelId: channel.id, - content: content, - mentionPubkeys: mentionPubkeys, - mediaTags: mediaTags, - ), - ) - else if (!resolvedChannel.isDm && - (!resolvedChannel.isMember || resolvedChannel.isArchived)) - _ReadOnlyNotice(channel: resolvedChannel), ], ), ); diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index eacba5856e5..ac73a5076e9 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -10,6 +10,7 @@ class _MessageList extends HookConsumerWidget { final bool isMember; final bool isArchived; final double appBarTitleContentHeight; + final double composerBottomInset; const _MessageList({ required this.entries, @@ -21,6 +22,7 @@ class _MessageList extends HookConsumerWidget { required this.isMember, required this.isArchived, required this.appBarTitleContentHeight, + required this.composerBottomInset, }); @override @@ -259,7 +261,7 @@ class _MessageList extends HookConsumerWidget { context, titleContentHeight: appBarTitleContentHeight, ), - bottom: 0, + bottom: composerBottomInset, ), itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0), itemBuilder: (context, index) { @@ -356,25 +358,76 @@ class _MessageList extends HookConsumerWidget { Positioned( left: 0, right: 0, - bottom: Grid.xs, + bottom: composerBottomInset + Grid.xs, child: Center( - child: FilledButton.icon( + child: _JumpToLatestButton( key: const ValueKey('channel-jump-to-latest'), onPressed: scrollToLatest, - style: FilledButton.styleFrom( - backgroundColor: context.colors.primaryContainer, - foregroundColor: context.colors.onPrimaryContainer, + ), + ), + ), + ], + ); + } +} + +class _JumpToLatestButton extends StatelessWidget { + final VoidCallback onPressed; + + const _JumpToLatestButton({required this.onPressed, super.key}); + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(Radii.full); + return Semantics( + button: true, + child: ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + key: const ValueKey('channel-jump-to-latest-surface'), + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.5), + borderRadius: borderRadius, + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onPressed, + borderRadius: borderRadius, + child: Padding( padding: const EdgeInsets.symmetric( horizontal: Grid.gutter, vertical: Grid.xxs, ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.arrowDown, + size: 16, + color: context.colors.onSurface, + ), + const SizedBox(width: Grid.half), + Text( + 'Latest', + style: context.textTheme.labelLarge?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), ), - icon: const Icon(LucideIcons.arrowDown, size: 16), - label: const Text('Latest'), ), ), ), - ], + ), + ), ); } } 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 0372690e1e1..e22a31684e8 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -27,12 +27,18 @@ class _SystemMessageRow extends ConsumerWidget { final userCache = ref.watch(userCacheProvider); final sourceMessages = groupedMessages ?? [message]; final groupedMembership = _membershipDisplayEvent(sourceMessages); - final channelCreator = systemEvent.type == SystemEventType.channelCreated - ? systemEvent.actorPubkey?.trim() - : null; + final messageStyleAction = switch (systemEvent.type) { + SystemEventType.channelCreated => 'created this channel', + SystemEventType.huddleStarted => 'started a huddle', + SystemEventType.huddleEnded => 'ended the huddle', + _ => null, + }; + final messageStyleActor = messageStyleAction == null + ? null + : systemEvent.actorPubkey?.trim(); final usesMessageStyleLayout = groupedMembership != null || - (channelCreator != null && channelCreator.isNotEmpty); + (messageStyleActor != null && messageStyleActor.isNotEmpty); String resolveLabel(String? pubkey) { if (pubkey == null) return 'Someone'; @@ -102,13 +108,15 @@ class _SystemMessageRow extends ConsumerWidget { resolveLabel: resolveLabel, userCache: userCache, ) - else if (channelCreator != null && channelCreator.isNotEmpty) + else if (messageStyleActor != null && + messageStyleActor.isNotEmpty && + messageStyleAction != null) _MessageStyleSystemMessageContent( - displayPubkey: channelCreator, + displayPubkey: messageStyleActor, createdAt: message.createdAt, resolveLabel: resolveLabel, userCache: userCache, - actionSpans: const [TextSpan(text: 'created this channel')], + actionSpans: [TextSpan(text: messageStyleAction)], ) else Row( diff --git a/mobile/lib/features/channels/channel_typing_indicator.dart b/mobile/lib/features/channels/channel_typing_indicator.dart index d021b9e2875..0543f13b065 100644 --- a/mobile/lib/features/channels/channel_typing_indicator.dart +++ b/mobile/lib/features/channels/channel_typing_indicator.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; @@ -72,13 +73,11 @@ class ChannelTypingIndicator extends ConsumerWidget { ), const SizedBox(width: Grid.xxs), Flexible( - child: Text( + child: _TypingTextShimmer( text, style: context.textTheme.labelSmall?.copyWith( - color: context.colors.primary, - fontStyle: FontStyle.italic, + color: context.colors.onSurfaceVariant, ), - overflow: TextOverflow.ellipsis, ), ), ], @@ -87,3 +86,62 @@ class ChannelTypingIndicator extends ConsumerWidget { ); } } + +class _TypingTextShimmer extends HookWidget { + final String text; + final TextStyle? style; + + const _TypingTextShimmer(this.text, {this.style}); + + @override + Widget build(BuildContext context) { + final animation = useAnimationController( + duration: const Duration(milliseconds: 2600), + ); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final baseColor = style?.color ?? context.colors.onSurfaceVariant; + final highlightColor = + Color.lerp(context.colors.surface, baseColor, 0.4) ?? baseColor; + + useEffect(() { + if (reducedMotion) { + animation + ..stop() + ..value = 0; + } else { + animation.repeat(); + } + return animation.stop; + }, [animation, reducedMotion]); + + final label = Text(text, style: style, overflow: TextOverflow.ellipsis); + if (reducedMotion) return label; + + return RepaintBoundary( + child: AnimatedBuilder( + animation: animation, + child: label, + builder: (context, child) { + final center = 1.5 - (animation.value * 3); + return ShaderMask( + key: const ValueKey('channel-typing-shimmer'), + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment(center - 1, 0), + end: Alignment(center + 1, 0), + colors: [ + baseColor, + baseColor, + highlightColor, + baseColor, + baseColor, + ], + stops: const [0, 0.34, 0.5, 0.66, 1], + ).createShader(bounds), + child: child, + ); + }, + ), + ); + } +} diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index f9fe5453d78..febe16e9969 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -1,5 +1,7 @@ part of '../channels_page.dart'; +const _sectionMenuItemPadding = EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0); + class _CustomChannelSection extends StatelessWidget { final ChannelSection section; final List channels; @@ -178,32 +180,42 @@ class _CustomSectionHeader extends ConsumerWidget { 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), - ), surfaceKey: ValueKey('section-popover-${section.id}'), items: [ const PopupMenuItem( value: 'rename', - child: Text('Rename'), + padding: _sectionMenuItemPadding, + child: _SectionMenuItemContent( + icon: LucideIcons.pencil, + label: 'Rename section', + ), ), PopupMenuItem( value: 'move_up', enabled: !isFirst, - child: const Text('Move Up'), + padding: _sectionMenuItemPadding, + child: const _SectionMenuItemContent( + icon: LucideIcons.arrowUp, + label: 'Move up', + ), ), PopupMenuItem( value: 'move_down', enabled: !isLast, - child: const Text('Move Down'), + padding: _sectionMenuItemPadding, + child: const _SectionMenuItemContent( + icon: LucideIcons.arrowDown, + label: 'Move down', + ), ), - const PopupMenuItem( + PopupMenuItem( value: 'delete', - child: Text('Delete'), + padding: _sectionMenuItemPadding, + child: _SectionMenuItemContent( + icon: LucideIcons.trash2, + label: 'Delete section', + color: context.colors.error, + ), ), ], ); @@ -229,6 +241,36 @@ class _CustomSectionHeader extends ConsumerWidget { } } +class _SectionMenuItemContent extends StatelessWidget { + final IconData icon; + final String label; + final Color? color; + + const _SectionMenuItemContent({ + required this.icon, + required this.label, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: Grid.xxs), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: color == null ? null : TextStyle(color: color), + ), + ), + ], + ); + } +} + CustomEmoji? _resolveCustomEmoji(String icon, List palette) { if (!icon.startsWith(':') || !icon.endsWith(':')) return null; final shortcode = normalizeShortcode(icon); diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 7560f998f30..fe0d5cf5d42 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:collection'; import 'dart:math' as math; +import 'dart:ui' show FlutterView; import 'package:camera/camera.dart' as camera; import 'package:flutter/foundation.dart'; @@ -19,8 +20,10 @@ import '../../shared/mentions/agent_identity_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/keyboard_dismiss_on_drag.dart'; +import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; @@ -48,6 +51,7 @@ part 'compose_bar/ios_attachment_popover.dart'; part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; part 'compose_bar/layout.dart'; +part 'compose_bar/dock.dart'; const _maxConcurrentImageUploads = 3; @@ -84,6 +88,7 @@ class ComposeBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useMemoized(_MarkdownEditingController.new); + useListenable(controller); useEffect(() => controller.dispose, [controller]); // Restore and persist unsent text as a local draft so the Activity @@ -126,7 +131,13 @@ class ComposeBar extends HookConsumerWidget { return () => controller.removeListener(persistDraft); }, [controller, draftKey, draftIdentity]); final focusNode = useFocusNode(); + useEffect( + () => + () => _dismissComposerKeyboard(focusNode), + [focusNode], + ); final isComposerExpanded = useState(false); + final isEmojiPickerOpen = useState(false); final attachmentSurface = useState(_AttachmentSurface.closed); final iosAttachmentPopover = useMemoized( _IOSAttachmentPopoverController.new, @@ -144,6 +155,7 @@ class ComposeBar extends HookConsumerWidget { final clipboardHasImage = useState(false); final hasAttachments = attachments.value.isNotEmpty; final hasPendingUploads = uploadingCount.value > 0; + final canSend = controller.text.trim().isNotEmpty || hasAttachments; final customEmoji = ref.watch(customEmojiListProvider); final reducedMotion = MediaQuery.disableAnimationsOf(context); final composerExpansionController = useAnimationController( @@ -155,6 +167,42 @@ class ComposeBar extends HookConsumerWidget { .clamp(0.0, 1.0) .toDouble(); + void collapseComposer() { + if (!isComposerExpanded.value) return; + showFormatting.value = false; + isComposerExpanded.value = false; + } + + // A focus loss covers deliberate dismiss gestures. The metrics observer + // also catches the system back/swipe dismissal path, where the platform can + // hide the keyboard while Flutter keeps the TextField focused. + useEffect(() { + void collapseWhenUnfocused() { + if (!focusNode.hasFocus && !isEmojiPickerOpen.value) { + collapseComposer(); + } + } + + focusNode.addListener(collapseWhenUnfocused); + return () => focusNode.removeListener(collapseWhenUnfocused); + }, [focusNode]); + + final appView = View.of(context); + useEffect(() { + final observer = _ComposerKeyboardMetricsObserver( + view: appView, + onKeyboardHidden: () { + collapseComposer(); + // Android Back and iOS dismissal gestures can hide the keyboard + // without changing Flutter focus. Clear it as well so reopening the + // compact capsule establishes a new text-input connection. + focusNode.unfocus(); + }, + ); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, [appView, focusNode]); + final resolvedHint = hintText ?? (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); @@ -167,8 +215,8 @@ class ComposeBar extends HookConsumerWidget { composerExpansionController.animateWith( SpringSimulation( SpringDescription.withDurationAndBounce( - duration: const Duration(milliseconds: 280), - bounce: 0.16, + duration: const Duration(milliseconds: 220), + bounce: 0.08, ), composerExpansionController.value, target, @@ -887,64 +935,16 @@ class ComposeBar extends HookConsumerWidget { // Suggestions and attachments live in the overlay so showing them cannot // reflow the composer. Both stay anchored just above the capsule. - return Padding( - padding: EdgeInsets.only( - left: Grid.twelve, - right: Grid.twelve, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, - ), - child: OverlayPortal.overlayChildLayoutBuilder( + final composerWidthFactor = 0.85 + 0.15 * composerExpansionProgress; + return _ComposerDockFrame( + widthFactor: composerWidthFactor, + child: _ComposerOverlayPortal( controller: suggestionOverlayController, - overlayChildBuilder: (context, layoutInfo) { - final composerOrigin = MatrixUtils.transformPoint( - layoutInfo.childPaintTransform, - Offset.zero, - ); - return ValueListenableBuilder<_AttachmentSurface>( - valueListenable: attachmentSurface, - builder: (context, surface, _) { - final surfaceDuration = reducedMotion - ? Duration.zero - : Duration( - milliseconds: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? 320 - : 250, - ); - final expandedSurfaceCoversComposer = - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos; - final overlayAnchorY = - composerOrigin.dy + - (expandedSurfaceCoversComposer - ? layoutInfo.childSize.height + Grid.twelve - : 0); - return AnimatedPositioned( - duration: surfaceDuration, - curve: - surface == _AttachmentSurface.camera || - surface == _AttachmentSurface.photos - ? const Cubic(0.34, 1.25, 0.64, 1) - : const Cubic(0.22, 1, 0.36, 1), - left: composerOrigin.dx, - bottom: layoutInfo.overlaySize.height - overlayAnchorY, - width: layoutInfo.childSize.width, - child: ClipRect( - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xxs), - child: surface == _AttachmentSurface.closed - ? _SuggestionPanelMotion( - duration: surfaceDuration, - alignment: Alignment.bottomLeft, - child: buildOverlayPanel(surface), - ) - : buildOverlayPanel(surface), - ), - ), - ); - }, - ); + attachmentSurface: attachmentSurface, + reducedMotion: reducedMotion, + buildOverlayPanel: buildOverlayPanel, + onDismissAttachmentSurface: () { + attachmentSurface.value = _AttachmentSurface.closed; }, child: _ComposeBarLayout( attachments: attachments.value, @@ -977,12 +977,18 @@ class ComposeBar extends HookConsumerWidget { }, onEmoji: () { attachmentSurface.value = _AttachmentSurface.closed; - showEmojiPicker(context: context, onSelect: insertEmoji); + isEmojiPickerOpen.value = true; + _showComposerEmojiPicker(context, insertEmoji, () { + if (!context.mounted) return; + isEmojiPickerOpen.value = false; + focusNode.requestFocus(); + }); }, onOpenFormatting: () { attachmentSurface.value = _AttachmentSurface.closed; showFormatting.value = true; }, + canSend: canSend, hasPendingUploads: hasPendingUploads, isSending: isSending.value, ), diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index 7c53ae10982..eee4d222acb 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -177,7 +177,7 @@ class _AttachmentSurfacePanel extends HookWidget { final height = menuLayout.height + ((expandedHeight - menuLayout.height) * sizeProgress); - final baseColor = context.colors.surfaceContainerHighest; + final baseColor = appPopoverColor(context); final expandedColor = visibleExpandedSurface == _AttachmentSurface.camera ? Colors.black @@ -189,57 +189,51 @@ class _AttachmentSurfacePanel extends HookWidget { child: SizedBox( width: width, height: height, - child: DecoratedBox( - decoration: BoxDecoration( - color: Color.lerp(baseColor, expandedColor, sizeProgress), - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.dialog), - child: Material( - type: MaterialType.transparency, - child: Stack( - clipBehavior: Clip.hardEdge, - children: [ - Positioned( - left: 0, - top: 0, - width: _attachmentMenuWidth, - height: menuLayout.height, - child: IgnorePointer( - ignoring: surface != _AttachmentSurface.menu, - child: Opacity( - opacity: menuOpacity, - child: _AttachmentMenu( - layout: menuLayout, - onCamera: onCamera, - onPhotos: onPhotos, - onVideo: onVideo, - onFiles: onFiles, - ), - ), + child: Material( + key: const ValueKey('attachment-surface-popover'), + type: MaterialType.card, + color: Color.lerp(baseColor, expandedColor, sizeProgress), + surfaceTintColor: Colors.transparent, + elevation: appPopoverElevation, + shadowColor: appPopoverShadowColor(context), + shape: appPopoverShape(context), + clipBehavior: Clip.antiAlias, + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + left: 0, + top: 0, + width: _attachmentMenuWidth, + height: menuLayout.height, + child: IgnorePointer( + ignoring: surface != _AttachmentSurface.menu, + child: Opacity( + opacity: menuOpacity, + child: _AttachmentMenu( + layout: menuLayout, + onCamera: onCamera, + onPhotos: onPhotos, + onVideo: onVideo, + onFiles: onFiles, ), ), - Positioned( - left: 0, - top: 0, - width: expandedWidth, - height: expandedHeight, - child: IgnorePointer( - ignoring: !isExpanded, - child: Opacity( - opacity: expandedOpacity, - child: expandedContent, - ), - ), + ), + ), + Positioned( + left: 0, + top: 0, + width: expandedWidth, + height: expandedHeight, + child: IgnorePointer( + ignoring: !isExpanded, + child: Opacity( + opacity: expandedOpacity, + child: expandedContent, ), - ], + ), ), - ), + ], ), ), ), @@ -308,7 +302,7 @@ class _AttachmentTrigger extends StatelessWidget { _AttachmentSurface.camera || _AttachmentSurface.photos => 'Back to attachment options', }, - onPressed: () => onTap(context), + onPressed: () => _runComposerAction(() => onTap(context)), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, icon: AnimatedRotation( @@ -417,7 +411,7 @@ class _AttachmentMenuItem extends StatelessWidget { child: Tooltip( message: label, child: InkWell( - onTap: onTap, + onTap: () => _runComposerAction(onTap), child: Padding( padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), child: Row( @@ -617,7 +611,8 @@ class _AttachmentStrip extends StatelessWidget { width: 24, height: 24, child: IconButton( - onPressed: () => onRemove(attachment.url), + onPressed: () => + _runComposerAction(() => onRemove(attachment.url)), tooltip: 'Remove attachment', visualDensity: VisualDensity.compact, style: IconButton.styleFrom( diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart index e51c2d16aaf..1a06534d6e3 100644 --- a/mobile/lib/features/channels/compose_bar/camera_preview.dart +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -252,7 +252,7 @@ class _CameraCaptureButton extends StatelessWidget { button: true, label: 'Take photo', child: GestureDetector( - onTap: isPressed ? null : onTap, + onTap: isPressed ? null : () => _runComposerAction(onTap), child: AnimatedScale( scale: isPressed ? 0.92 : 1, duration: duration, @@ -290,7 +290,7 @@ class _CameraCloseButton extends StatelessWidget { return SizedBox.square( dimension: emphasized ? _cameraBackSize : 36, child: IconButton( - onPressed: onTap, + onPressed: () => _runComposerAction(onTap), tooltip: 'Back to attachment options', padding: EdgeInsets.zero, style: IconButton.styleFrom( diff --git a/mobile/lib/features/channels/compose_bar/dock.dart b/mobile/lib/features/channels/compose_bar/dock.dart new file mode 100644 index 00000000000..f19fe19e46d --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/dock.dart @@ -0,0 +1,144 @@ +part of '../compose_bar.dart'; + +class _ComposerDockFrame extends StatelessWidget { + final double widthFactor; + final Widget child; + + const _ComposerDockFrame({required this.widthFactor, required this.child}); + + @override + Widget build(BuildContext context) { + final backdropHeight = mobileTabFooterBackdropHeight(context); + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + key: const ValueKey('composer-footer-gradient'), + left: 0, + right: 0, + bottom: 0, + height: backdropHeight, + child: IgnorePointer( + child: MobileTabFooterBackdrop(height: backdropHeight), + ), + ), + Padding( + padding: EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: Align( + alignment: Alignment.bottomCenter, + child: FractionallySizedBox( + key: const ValueKey('composer-width-transition'), + widthFactor: widthFactor, + child: child, + ), + ), + ), + ], + ); + } +} + +class _ComposerOverlayPortal extends StatelessWidget { + final OverlayPortalController controller; + final ValueListenable<_AttachmentSurface> attachmentSurface; + final bool reducedMotion; + final Widget Function(_AttachmentSurface surface) buildOverlayPanel; + final VoidCallback onDismissAttachmentSurface; + final Widget child; + + const _ComposerOverlayPortal({ + required this.controller, + required this.attachmentSurface, + required this.reducedMotion, + required this.buildOverlayPanel, + required this.onDismissAttachmentSurface, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return OverlayPortal.overlayChildLayoutBuilder( + controller: controller, + overlayChildBuilder: (context, layoutInfo) { + final composerOrigin = MatrixUtils.transformPoint( + layoutInfo.childPaintTransform, + Offset.zero, + ); + return ValueListenableBuilder<_AttachmentSurface>( + valueListenable: attachmentSurface, + builder: (context, surface, _) { + final surfaceDuration = reducedMotion + ? Duration.zero + : Duration( + milliseconds: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? 320 + : 250, + ); + final expandedSurfaceCoversComposer = + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos; + final surfaceLeft = expandedSurfaceCoversComposer + ? Grid.twelve + : composerOrigin.dx; + final surfaceWidth = expandedSurfaceCoversComposer + ? layoutInfo.overlaySize.width - (Grid.twelve * 2) + : layoutInfo.childSize.width; + final overlayAnchorY = + composerOrigin.dy + + (expandedSurfaceCoversComposer + ? layoutInfo.childSize.height + Grid.twelve + : 0); + return Stack( + children: [ + if (surface != _AttachmentSurface.closed) + Positioned( + left: 0, + top: 0, + right: 0, + height: composerOrigin.dy, + child: ExcludeSemantics( + child: GestureDetector( + key: const ValueKey('attachment-dismiss-barrier'), + behavior: HitTestBehavior.opaque, + onTap: onDismissAttachmentSurface, + ), + ), + ), + AnimatedPositioned( + duration: surfaceDuration, + curve: + surface == _AttachmentSurface.camera || + surface == _AttachmentSurface.photos + ? const Cubic(0.34, 1.25, 0.64, 1) + : const Cubic(0.22, 1, 0.36, 1), + left: surfaceLeft, + bottom: layoutInfo.overlaySize.height - overlayAnchorY, + width: surfaceWidth, + child: ClipRect( + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: surface == _AttachmentSurface.closed + ? _SuggestionPanelMotion( + duration: surfaceDuration, + alignment: Alignment.bottomLeft, + child: buildOverlayPanel(surface), + ) + : buildOverlayPanel(surface), + ), + ), + ), + ], + ); + }, + ); + }, + child: child, + ); + } +} diff --git a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart index c9089a99cb4..c6dc65a8fa9 100644 --- a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart +++ b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart @@ -58,7 +58,7 @@ class _FormatButton extends StatelessWidget { message: tooltip, child: InkWell( borderRadius: BorderRadius.circular(Radii.sm), - onTap: onTap, + onTap: () => _runComposerAction(onTap), child: Padding( padding: const EdgeInsets.all(Grid.xxs), child: Icon(icon, size: 18, color: context.colors.primary), @@ -80,7 +80,7 @@ class _ComposeAction extends StatelessWidget { width: 36, height: 36, child: IconButton( - onPressed: onTap, + onPressed: () => _runComposerAction(onTap), icon: Icon(icon, size: 20, color: context.colors.onSurfaceVariant), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, diff --git a/mobile/lib/features/channels/compose_bar/helpers.dart b/mobile/lib/features/channels/compose_bar/helpers.dart index 145d28fb441..c09815538a1 100644 --- a/mobile/lib/features/channels/compose_bar/helpers.dart +++ b/mobile/lib/features/channels/compose_bar/helpers.dart @@ -1,6 +1,47 @@ part of '../compose_bar.dart'; const _typingThrottleMs = 3000; + +class _ComposerKeyboardMetricsObserver with WidgetsBindingObserver { + final FlutterView view; + final VoidCallback onKeyboardHidden; + bool _wasVisible; + + _ComposerKeyboardMetricsObserver({ + required this.view, + required this.onKeyboardHidden, + }) : _wasVisible = view.viewInsets.bottom > 0; + + @override + void didChangeMetrics() { + final isVisible = view.viewInsets.bottom > 0; + if (_wasVisible && !isVisible) onKeyboardHidden(); + _wasVisible = isVisible; + } +} + +void _runComposerAction(VoidCallback action) { + unawaited(HapticFeedback.selectionClick()); + action(); +} + +void _showComposerEmojiPicker( + BuildContext context, + ValueChanged onSelect, + VoidCallback onDismiss, +) { + showEmojiPicker( + context: context, + onSelect: (emoji) => _runComposerAction(() => onSelect(emoji)), + onDismiss: onDismiss, + ); +} + +void _dismissComposerKeyboard(FocusNode focusNode) { + focusNode.unfocus(); + unawaited(SystemChannels.textInput.invokeMethod('TextInput.hide')); +} + const _pastedImageMimeTypes = [ 'image/jpeg', 'image/jpg', diff --git a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart index 039b5160a3d..ade2bec94e7 100644 --- a/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart +++ b/mobile/lib/features/channels/compose_bar/ios_photo_picker.dart @@ -146,7 +146,9 @@ class _IOSInlinePhotoPicker extends HookWidget { ), child: IconButton( key: const ValueKey('ios-inline-photo-picker-back'), - onPressed: isProcessing.value ? null : onBack, + onPressed: isProcessing.value + ? null + : () => _runComposerAction(onBack), tooltip: 'Back to attachment options', icon: const Icon( LucideIcons.chevronLeft, @@ -164,11 +166,12 @@ class _IOSInlinePhotoPicker extends HookWidget { child: FilledButton( key: const ValueKey('ios-inline-photo-picker-select'), onPressed: canSelect - ? submitSelection + ? () => + _runComposerAction(() => unawaited(submitSelection())) : selectedCount.value == 0 && !isPreparingSelection.value && !isProcessing.value - ? openAllPhotos + ? () => _runComposerAction(() => unawaited(openAllPhotos())) : null, style: FilledButton.styleFrom( backgroundColor: Colors.black.withValues(alpha: 0.76), diff --git a/mobile/lib/features/channels/compose_bar/layout.dart b/mobile/lib/features/channels/compose_bar/layout.dart index 31b930e36dd..e0adb9621c1 100644 --- a/mobile/lib/features/channels/compose_bar/layout.dart +++ b/mobile/lib/features/channels/compose_bar/layout.dart @@ -25,6 +25,7 @@ class _ComposeBarLayout extends StatelessWidget { final VoidCallback onChannel; final VoidCallback onEmoji; final VoidCallback onOpenFormatting; + final bool canSend; final bool hasPendingUploads; final bool isSending; @@ -53,6 +54,7 @@ class _ComposeBarLayout extends StatelessWidget { required this.onChannel, required this.onEmoji, required this.onOpenFormatting, + required this.canSend, required this.hasPendingUploads, required this.isSending, }); @@ -63,10 +65,17 @@ class _ComposeBarLayout extends StatelessWidget { } Widget _buildBar(BuildContext context) { + final trimmedDraft = controller.text.trim(); + final collapsedText = trimmedDraft.isEmpty + ? resolvedHint + : trimmedDraft.replaceAll(RegExp(r'\s+'), ' '); + final composerRadius = + Radii.dialog + Grid.quarter * (1 - expansionProgress); return Container( + key: const ValueKey('composer-surface'), decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), + borderRadius: BorderRadius.circular(composerRadius), border: Border.all( color: Colors.black.withValues(alpha: 0.04), width: 1, @@ -142,7 +151,7 @@ class _ComposeBarLayout extends StatelessWidget { label: resolvedHint, child: GestureDetector( behavior: HitTestBehavior.opaque, - onTap: onExpand, + onTap: () => _runComposerAction(onExpand), child: Padding( padding: const EdgeInsets.symmetric( vertical: Grid.half, @@ -150,9 +159,13 @@ class _ComposeBarLayout extends StatelessWidget { child: Align( alignment: Alignment.centerLeft, child: Text( - resolvedHint, + collapsedText, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, + color: trimmedDraft.isEmpty + ? context.colors.onSurfaceVariant + : context.colors.onSurface, ), ), ), @@ -160,6 +173,12 @@ class _ComposeBarLayout extends StatelessWidget { ), ), ), + const SizedBox(width: Grid.xxs), + _SendButton( + isDisabled: !canSend || hasPendingUploads, + isSending: isSending, + onTap: onSend, + ), ], ), ClipRect( @@ -167,7 +186,7 @@ class _ComposeBarLayout extends StatelessWidget { alignment: Alignment.topCenter, heightFactor: expansionValue, child: IgnorePointer( - ignoring: expansionValue < 0.98, + ignoring: !isExpanded, child: Opacity( opacity: expansionProgress, child: Transform.translate( @@ -225,7 +244,8 @@ class _ComposeBarLayout extends StatelessWidget { ), const Spacer(), _SendButton( - isDisabled: hasPendingUploads, + isDisabled: + !canSend || hasPendingUploads, isSending: isSending, onTap: onSend, ), diff --git a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart index 8b19b74aa2e..59858740653 100644 --- a/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart +++ b/mobile/lib/features/channels/compose_bar/photo_gallery_picker.dart @@ -136,7 +136,7 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { photo: photo, selectionIndex: selectionIndex, reducedMotion: reducedMotion, - onTap: () => togglePhoto(photo), + onTap: () => _runComposerAction(() => togglePhoto(photo)), ); }, ); @@ -154,7 +154,9 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { children: [ IconButton( key: const ValueKey('photo-gallery-back'), - onPressed: isResolving.value ? null : onBack, + onPressed: isResolving.value + ? null + : () => _runComposerAction(onBack), tooltip: 'Back to attachment options', visualDensity: VisualDensity.compact, icon: const Icon(LucideIcons.arrowLeft, size: 20), @@ -212,7 +214,11 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { child: selectedCount == 0 ? OutlinedButton.icon( key: const ValueKey('photo-gallery-action'), - onPressed: isResolving.value ? null : choosePhotos, + onPressed: isResolving.value + ? null + : () => _runComposerAction( + () => unawaited(choosePhotos()), + ), icon: isResolving.value ? BuzzLoadingIndicator( size: 22, @@ -224,7 +230,11 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget { ) : FilledButton.icon( key: const ValueKey('photo-gallery-action'), - onPressed: isResolving.value ? null : choosePhotos, + onPressed: isResolving.value + ? null + : () => _runComposerAction( + () => unawaited(choosePhotos()), + ), icon: isResolving.value ? const BuzzLoadingIndicator( size: 22, diff --git a/mobile/lib/features/channels/compose_bar/send_button.dart b/mobile/lib/features/channels/compose_bar/send_button.dart index 54060ae948e..bbe9d2aacaa 100644 --- a/mobile/lib/features/channels/compose_bar/send_button.dart +++ b/mobile/lib/features/channels/compose_bar/send_button.dart @@ -17,7 +17,9 @@ class _SendButton extends StatelessWidget { width: 36, height: 36, child: IconButton( - onPressed: (isSending || isDisabled) ? null : onTap, + onPressed: (isSending || isDisabled) + ? null + : () => _runComposerAction(onTap), style: IconButton.styleFrom( backgroundColor: context.colors.primary, disabledBackgroundColor: context.colors.primary.withValues( diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index ed97284d460..7b8e7c175ba 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -111,54 +111,55 @@ class _MentionSuggestions extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints(maxHeight: 240), + return Material( + key: const ValueKey('mention-suggestions-popover'), + type: MaterialType.card, + color: appPopoverColor(context), + surfaceTintColor: Colors.transparent, + elevation: appPopoverElevation, + shadowColor: appPopoverShadowColor(context), + shape: appPopoverShape(context), clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - itemCount: suggestions.length, - separatorBuilder: (_, _) => const SizedBox.shrink(), - itemBuilder: (context, index) { - final candidate = suggestions[index]; - final name = candidate.label; - final avatarUrl = - candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl; + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + itemCount: suggestions.length, + separatorBuilder: (_, _) => const SizedBox.shrink(), + itemBuilder: (context, index) { + final candidate = suggestions[index]; + final name = candidate.label; + final avatarUrl = + candidate.avatarUrl ?? userCache[candidate.pubkey]?.avatarUrl; - return ListTile( - dense: true, - visualDensity: VisualDensity.compact, - leading: AvatarImage( - imageUrl: avatarUrl, - radius: 18, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - name[0].toUpperCase(), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - fontWeight: FontWeight.w600, + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: AvatarImage( + imageUrl: avatarUrl, + radius: 18, + backgroundColor: context.colors.primaryContainer, + fallback: Text( + name[0].toUpperCase(), + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), ), ), - ), - title: Text(name, style: context.textTheme.titleSmall), - subtitle: _MentionSuggestionInfo.build( - context, - candidate: candidate, - currentPubkey: currentPubkey, - isDmChannel: isDmChannel, - userCache: userCache, - ), - onTap: () => onSelect(candidate), - ); - }, + title: Text(name, style: context.textTheme.titleSmall), + subtitle: _MentionSuggestionInfo.build( + context, + candidate: candidate, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + userCache: userCache, + ), + onTap: () => _runComposerAction(() => onSelect(candidate)), + ); + }, + ), ), ); } @@ -261,40 +262,41 @@ class _ChannelSuggestions extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - constraints: const BoxConstraints(maxHeight: 240), + return Material( + key: const ValueKey('channel-suggestions-popover'), + type: MaterialType.card, + color: appPopoverColor(context), + surfaceTintColor: Colors.transparent, + elevation: appPopoverElevation, + shadowColor: appPopoverShadowColor(context), + shape: appPopoverShape(context), clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.dialog), - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - itemCount: suggestions.length, - separatorBuilder: (_, _) => const SizedBox.shrink(), - itemBuilder: (context, index) { - final channel = suggestions[index]; - return ListTile( - dense: true, - visualDensity: VisualDensity.compact, - horizontalTitleGap: 0, - leading: SizedBox.square( - dimension: 36, - child: Icon( - LucideIcons.hash, - size: 20, - color: context.colors.onSurfaceVariant, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + itemCount: suggestions.length, + separatorBuilder: (_, _) => const SizedBox.shrink(), + itemBuilder: (context, index) { + final channel = suggestions[index]; + return ListTile( + dense: true, + visualDensity: VisualDensity.compact, + horizontalTitleGap: 0, + leading: SizedBox.square( + dimension: 36, + child: Icon( + LucideIcons.hash, + size: 20, + color: context.colors.onSurfaceVariant, + ), ), - ), - title: Text(channel.name, style: context.textTheme.bodyLarge), - onTap: () => onSelect(channel), - ); - }, + title: Text(channel.name, style: context.textTheme.bodyLarge), + onTap: () => _runComposerAction(() => onSelect(channel)), + ); + }, + ), ), ); } diff --git a/mobile/lib/features/channels/composer_dock_size_reporter.dart b/mobile/lib/features/channels/composer_dock_size_reporter.dart new file mode 100644 index 00000000000..38730729b3c --- /dev/null +++ b/mobile/lib/features/channels/composer_dock_size_reporter.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +/// Reports the laid-out height of a floating composer dock. +/// +/// Message timelines use that height as scroll padding while still painting +/// beneath the dock, which lets the dock's fade reveal real timeline content. +class ComposerDockSizeReporter extends HookWidget { + final ValueChanged onHeightChanged; + final Widget child; + + const ComposerDockSizeReporter({ + super.key, + required this.onHeightChanged, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final sizeKey = useMemoized(GlobalKey.new); + final lastHeight = useRef(null); + + void reportHeight() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final renderObject = sizeKey.currentContext?.findRenderObject(); + if (renderObject is! RenderBox || !renderObject.hasSize) return; + final height = renderObject.size.height; + final previous = lastHeight.value; + if (previous != null && (previous - height).abs() < 0.5) return; + lastHeight.value = height; + onHeightChanged(height); + }); + } + + useEffect(() { + reportHeight(); + return null; + }, const []); + + return NotificationListener( + onNotification: (_) { + reportHeight(); + return true; + }, + child: SizeChangedLayoutNotifier( + child: KeyedSubtree(key: sizeKey, child: child), + ), + ); + } +} diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index 10292cfa284..e3cd0f8eabf 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -9,6 +9,7 @@ import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_data.dart'; import '../../shared/emoji/emoji_data_provider.dart'; import '../../shared/emoji/emoji_search.dart'; +import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/theme/theme.dart'; import 'recent_emoji_provider.dart'; @@ -30,6 +31,7 @@ const _sheetHeightFactor = 0.62; void showEmojiPicker({ required BuildContext context, required void Function(String emoji) onSelect, + VoidCallback? onDismiss, }) { showModalBottomSheet( context: context, @@ -42,7 +44,7 @@ void showEmojiPicker({ onSelect(emoji); }, ), - ); + ).whenComplete(onDismiss ?? () {}); } class EmojiPickerSheet extends HookConsumerWidget { diff --git a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart index 2ebe631edd7..354a31e44c6 100644 --- a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart +++ b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart @@ -99,10 +99,7 @@ class _EmojiTile extends StatelessWidget { button: true, label: entry.name, child: Center( - child: Text( - entry.native, - style: const TextStyle(fontSize: _emojiGlyphSize), - ), + child: NativeEmojiGlyph(emoji: entry.native, size: _emojiGlyphSize), ), ), ); diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 519d25898c0..df048000223 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -17,6 +17,7 @@ import '../../shared/theme/theme.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; +import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/widgets/sheet_divider.dart'; import '../../shared/reminders/remind_me_later_sheet.dart'; import '../../shared/reminders/reminder_service.dart'; @@ -689,7 +690,7 @@ class _QuickReactionGlyph extends StatelessWidget { ); } } - return Text(value, style: const TextStyle(fontSize: 24)); + return NativeEmojiGlyph(emoji: value, size: 24); } } diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index a9511c587df..9d01b5c8e7c 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -8,6 +8,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/emoji/emoji_burst.dart'; import '../../shared/emoji/emoji_data_provider.dart'; +import '../../shared/emoji/native_emoji_glyph.dart'; import '../../shared/emoji/positive_emoji.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; @@ -303,7 +304,7 @@ class _ReactionEmoji extends StatelessWidget { Widget build(BuildContext context) { final emojiUrl = reaction.emojiUrl; if (emojiUrl == null || emojiUrl.isEmpty) { - return Text(reaction.emoji, style: TextStyle(fontSize: size)); + return NativeEmojiGlyph(emoji: reaction.emoji, size: size); } final shortcode = reaction.emoji.substring(1, reaction.emoji.length - 1); return CustomEmojiImage(shortcode: shortcode, url: emojiUrl, size: size); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 810861aa00f..53be4488b20 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -20,6 +20,7 @@ import 'channel_typing_indicator.dart'; import 'thread_replies_provider.dart'; import 'channels_provider.dart'; import 'compose_bar.dart'; +import 'composer_dock_size_reporter.dart'; import 'date_formatters.dart'; import 'day_divider.dart'; import '../profile/user_profile_sheet.dart'; @@ -58,6 +59,7 @@ class ThreadDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final composerDockHeight = useState(0.0); // Relay thread queries are keyed by the outermost root, even when this // page displays a nested branch. Query that root, then select this head's // direct children from the returned subtree below. @@ -113,11 +115,35 @@ class ThreadDetailPage extends HookConsumerWidget { final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); final didJumpToInitialMessage = useRef(false); + final followsThreadTail = useRef(false); + final pendingTailAlignment = useRef(null); + final tailRealignmentQueued = useRef(false); // Item 0 is the thread head; reply `i` lives at `i + 1`. const headIndex = 0; int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; + bool threadTailIsVisible() { + final lastIndex = replies.isEmpty + ? headIndex + : indexForReply(replies.length - 1); + return itemPositionsListener.itemPositions.value.any( + (position) => + position.index == lastIndex && position.itemTrailingEdge <= 1.001, + ); + } + + useEffect(() { + void onPositionsChanged() { + if (threadTailIsVisible()) followsThreadTail.value = true; + } + + itemPositionsListener.itemPositions.addListener(onPositionsChanged); + return () => itemPositionsListener.itemPositions.removeListener( + onPositionsChanged, + ); + }, [itemPositionsListener, replies.length]); + useEffect(() { final messageId = initialMessageId; // Wait for the authoritative thread query before consuming the one-shot @@ -134,6 +160,11 @@ class ThreadDetailPage extends HookConsumerWidget { if (targetIndex == null || didJumpToInitialMessage.value) return null; WidgetsBinding.instance.addPostFrameCallback((_) { if (!context.mounted || !itemScrollController.isAttached) return; + // The provisional route snapshot can make the linked reply look like + // the tail. This authoritative deep-link jump intentionally leaves + // the user at an older item, so it must opt out of follow-tail first. + followsThreadTail.value = false; + pendingTailAlignment.value = null; itemScrollController.jumpTo(index: targetIndex, alignment: 0.35); didJumpToInitialMessage.value = true; }); @@ -227,6 +258,74 @@ class ThreadDetailPage extends HookConsumerWidget { // itself a root message its rootId is null, so fall back to its own id. final effectiveRootId = threadHead.rootId ?? threadHead.id; + void updateComposerDockHeight(double height) { + final previousHeight = composerDockHeight.value; + final heightDelta = height - previousHeight; + if (heightDelta.abs() < 0.5) return; + + final shouldFollowTail = followsThreadTail.value || threadTailIsVisible(); + if (shouldFollowTail) followsThreadTail.value = true; + composerDockHeight.value = height; + if (heightDelta <= 0 || !shouldFollowTail) { + pendingTailAlignment.value = null; + return; + } + final lastIndex = replies.isEmpty + ? headIndex + : indexForReply(replies.length - 1); + final lastPosition = itemPositionsListener.itemPositions.value + .where((position) => position.index == lastIndex) + .firstOrNull; + if (lastPosition == null) return; + final targetAlignment = + (pendingTailAlignment.value ?? lastPosition.itemLeadingEdge) - + (heightDelta / MediaQuery.sizeOf(context).height); + pendingTailAlignment.value = targetAlignment; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted || !itemScrollController.isAttached) return; + itemScrollController.jumpTo( + index: lastIndex, + alignment: targetAlignment, + ); + }); + } + + // Composer size changes and keyboard metrics changes are independent: + // the dock grows first, then the Scaffold's viewport shrinks once the + // keyboard appears. Re-align after that latter layout pass too, but only + // while the user was already following the thread tail. + void realignThreadTailAfterMetricsChange() { + final shouldFollowTail = followsThreadTail.value || threadTailIsVisible(); + if (!shouldFollowTail || tailRealignmentQueued.value) return; + followsThreadTail.value = true; + tailRealignmentQueued.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + tailRealignmentQueued.value = false; + if (!context.mounted || + !itemScrollController.isAttached || + !followsThreadTail.value) { + return; + } + final lastIndex = replies.isEmpty + ? headIndex + : indexForReply(replies.length - 1); + itemScrollController.scrollTo( + index: lastIndex, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + }); + } + + useEffect(() { + final observer = _ThreadTailMetricsObserver( + onMetricsChanged: realignThreadTailAfterMetricsChange, + ); + WidgetsBinding.instance.addObserver(observer); + return () => WidgetsBinding.instance.removeObserver(observer); + }, [itemScrollController, replies.length]); + // Channel names for message content rendering. final channelsAsync = ref.watch(channelsProvider); final channelNamesMap = {}; @@ -241,176 +340,213 @@ class ThreadDetailPage extends HookConsumerWidget { title: Text('Thread'), titleStyle: channelTitleTextStyle, ), - body: Column( + body: Stack( + fit: StackFit.expand, children: [ - Expanded( - child: KeyboardDismissOnDrag( - child: ScrollablePositionedList.builder( - key: const ValueKey('thread-message-list'), - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - // Top-anchored, head first, replies flowing down — matching - // desktop's thread panel. The old reversed list bottom-anchored - // the content, which jammed the head against the composer - // whenever a thread had only a handful of replies. - padding: EdgeInsets.only( - left: Grid.gutter, - right: Grid.gutter, - top: frostedAppBarHeight(context), - bottom: Grid.xs, - ), - itemCount: replies.length + 1, // +1 for thread head - itemBuilder: (context, index) { - if (index == headIndex) { - if (liveDeletionHidesHead) { - return const Padding( - key: ValueKey('thread-message-deleted'), - padding: EdgeInsets.only(bottom: Grid.xs), - child: Text('This message was deleted'), - ); - } - return Padding( - key: ValueKey('thread-message-group-${liveHead.id}'), - padding: const EdgeInsets.only(bottom: Grid.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DayDivider( - label: formatDayHeading(liveHead.createdAt), - ), - _ThreadMessage( - message: liveHead, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: true, - isHighlighted: liveHead.id == initialMessageId, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - isThreadHead: true, - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: Grid.xxs, - ), - child: Row( - children: [ - Text( - '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium - ?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), + Column( + children: [ + Expanded( + child: KeyboardDismissOnDrag( + onUserScrollStart: () { + followsThreadTail.value = false; + pendingTailAlignment.value = null; + }, + child: ScrollablePositionedList.builder( + key: const ValueKey('thread-message-list'), + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + // Top-anchored, head first, replies flowing down — matching + // desktop's thread panel. The old reversed list bottom-anchored + // the content, which jammed the head against the composer + // whenever a thread had only a handful of replies. + padding: EdgeInsets.only( + left: Grid.gutter, + right: Grid.gutter, + top: frostedAppBarHeight(context), + bottom: Grid.xs + composerDockHeight.value, + ), + itemCount: replies.length + 1, // +1 for thread head + itemBuilder: (context, index) { + if (index == headIndex) { + if (liveDeletionHidesHead) { + return const Padding( + key: ValueKey('thread-message-deleted'), + padding: EdgeInsets.only(bottom: Grid.xs), + child: Text('This message was deleted'), + ); + } + return Padding( + key: ValueKey('thread-message-group-${liveHead.id}'), + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DayDivider( + label: formatDayHeading(liveHead.createdAt), + ), + _ThreadMessage( + message: liveHead, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: true, + isHighlighted: liveHead.id == initialMessageId, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + isThreadHead: true, + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.xxs, ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Divider( - color: context.colors.outlineVariant, - ), + child: Row( + children: [ + Text( + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium + ?.copyWith( + color: + context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Divider( + color: context.colors.outlineVariant, + ), + ), + ], ), - ], - ), + ), + ], ), - ], - ), - ); - } - - // Chronological list: index 1 = oldest reply. - final chronIdx = index - 1; - final reply = replies[chronIdx]; - final prevReply = chronIdx > 0 ? replies[chronIdx - 1] : null; - final previousMessage = prevReply ?? liveHead; - final showDayDivider = !isSameDay( - previousMessage.createdAt, - reply.createdAt, - ); - final showAuthor = - prevReply == null || - showDayDivider || - prevReply.pubkey.toLowerCase() != - reply.pubkey.toLowerCase() || - (reply.createdAt - prevReply.createdAt) > 300; - - // Check if this reply itself has children (nested thread). - final nestedChildren = childrenByParent[reply.id]; - final nestedSummary = - nestedChildren != null && nestedChildren.isNotEmpty - ? _buildNestedSummary(reply.id, nestedChildren) - : null; - - return Padding( - key: ValueKey('thread-message-group-${reply.id}'), - // Tail spacing comes from the list's own bottom padding now - // that the list runs top-down; the reversed list used to - // need it here because item 0 sat against the composer. - padding: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDayDivider) - DayDivider(label: formatDayHeading(reply.createdAt)), - _ThreadMessage( - message: reply, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: showAuthor, - isHighlighted: reply.id == initialMessageId, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, + ); + } + + // Chronological list: index 1 = oldest reply. + final chronIdx = index - 1; + final reply = replies[chronIdx]; + final prevReply = chronIdx > 0 + ? replies[chronIdx - 1] + : null; + final previousMessage = prevReply ?? liveHead; + final showDayDivider = !isSameDay( + previousMessage.createdAt, + reply.createdAt, + ); + final showAuthor = + prevReply == null || + showDayDivider || + prevReply.pubkey.toLowerCase() != + reply.pubkey.toLowerCase() || + (reply.createdAt - prevReply.createdAt) > 300; + + // Check if this reply itself has children (nested thread). + final nestedChildren = childrenByParent[reply.id]; + final nestedSummary = + nestedChildren != null && nestedChildren.isNotEmpty + ? _buildNestedSummary(reply.id, nestedChildren) + : null; + + return Padding( + key: ValueKey('thread-message-group-${reply.id}'), + // Tail spacing comes from the list's own bottom padding now + // that the list runs top-down; the reversed list used to + // need it here because item 0 sat against the composer. + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showDayDivider) + DayDivider( + label: formatDayHeading(reply.createdAt), + ), + _ThreadMessage( + message: reply, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: showAuthor, + isHighlighted: reply.id == initialMessageId, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + ), + if (nestedSummary != null) + _NestedThreadSummaryRow( + summary: nestedSummary, + replyMessage: reply, + allMessages: allMsgs, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], ), - if (nestedSummary != null) - _NestedThreadSummaryRow( - summary: nestedSummary, - replyMessage: reply, - allMessages: allMsgs, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ], - ), - ); - }, + ); + }, + ), + ), ), - ), - ), - 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) + 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, - hintText: 'Reply in thread\u2026', - threadHeadId: threadHead.id, - rootId: effectiveRootId, - onSend: - ( - content, - mentionPubkeys, { - mediaTags = const >[], - }) => ref - .read(sendMessageProvider) - .call( - channelId: channelId, - content: content, - mentionPubkeys: mentionPubkeys, - parentEventId: threadHead.id, - rootEventId: effectiveRootId, - mediaTags: mediaTags, - ), + Align( + alignment: Alignment.bottomCenter, + child: ComposerDockSizeReporter( + key: const ValueKey('thread-composer-dock'), + onHeightChanged: updateComposerDockHeight, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + 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), + ), + ComposeBar( + channelId: channelId, + hintText: 'Reply in thread\u2026', + threadHeadId: threadHead.id, + rootId: effectiveRootId, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) => ref + .read(sendMessageProvider) + .call( + channelId: channelId, + content: content, + mentionPubkeys: mentionPubkeys, + parentEventId: threadHead.id, + rootEventId: effectiveRootId, + mediaTags: mediaTags, + ), + ), + ], + ), + ), ), ], ), @@ -566,6 +702,15 @@ class _NestedThreadSummaryRow extends ConsumerWidget { } } +class _ThreadTailMetricsObserver with WidgetsBindingObserver { + final VoidCallback onMetricsChanged; + + _ThreadTailMetricsObserver({required this.onMetricsChanged}); + + @override + void didChangeMetrics() => onMetricsChanged(); +} + class _ThreadMessage extends ConsumerWidget { final TimelineMessage message; final Map channelNames; diff --git a/mobile/lib/shared/emoji/native_emoji_glyph.dart b/mobile/lib/shared/emoji/native_emoji_glyph.dart new file mode 100644 index 00000000000..7831c1b32b8 --- /dev/null +++ b/mobile/lib/shared/emoji/native_emoji_glyph.dart @@ -0,0 +1,22 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// A standalone system emoji whose visual centre matches its surrounding UI. +/// +/// Apple's emoji glyphs sit slightly low inside Flutter's text box. Keep the +/// layout box unchanged and lift only the painted glyph on iOS; Android's +/// system emoji metrics are already visually centred. +class NativeEmojiGlyph extends StatelessWidget { + final String emoji; + final double size; + + const NativeEmojiGlyph({super.key, required this.emoji, required this.size}); + + @override + Widget build(BuildContext context) { + final glyph = Text(emoji, style: TextStyle(fontSize: size)); + if (defaultTargetPlatform != TargetPlatform.iOS) return glyph; + + return Transform.translate(offset: const Offset(0, -1), child: glyph); + } +} diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index f8000d79e2b..1407357062e 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -16,6 +16,7 @@ class Radii { static const double md = 8.0; static const double sm = 6.0; static const double card = 12.0; // grouped settings cards + static const double popover = 20.0; static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs /// Fully rounds pills, circles, and other capsule shapes. @@ -277,13 +278,22 @@ class AppTheme { labelPadding: EdgeInsets.zero, ), - // Popups/menus: desktop uses rounded-md (8px) + // Popups/menus share the elevated 20px mobile popover treatment. popupMenuTheme: PopupMenuThemeData( - color: scheme.surface, - elevation: 4, + color: scheme.surface.withValues(alpha: 0.98), + elevation: 8, + shadowColor: scheme.shadow.withValues(alpha: 0.18), + surfaceTintColor: Colors.transparent, + textStyle: textTheme.labelLarge?.copyWith(color: scheme.onSurface), + labelTextStyle: WidgetStatePropertyAll( + textTheme.labelLarge?.copyWith(color: scheme.onSurface), + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - side: BorderSide(color: scheme.outline), + borderRadius: BorderRadius.circular(Radii.popover), + side: BorderSide( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), ), ), diff --git a/mobile/lib/shared/theme/message_typography.dart b/mobile/lib/shared/theme/message_typography.dart index 10736263901..5a5d2c2c6e2 100644 --- a/mobile/lib/shared/theme/message_typography.dart +++ b/mobile/lib/shared/theme/message_typography.dart @@ -88,8 +88,14 @@ const contentListBodyTextStyle = TextStyle( /// Timestamps in compact content lists. const contentListTimestampTextStyle = messageMetadataTextStyle; -/// Filter chip labels use the compact 15sp type ramp. -const filterChipTextStyle = messageMetadataTextStyle; +/// Filter chip labels use a tighter 15sp Inter treatment. +const filterChipTextStyle = TextStyle( + fontFamily: _fontFamily, + fontSize: 15, + fontWeight: FontWeight.w400, + height: 1, + letterSpacing: 0, +); /// Search fields use the primary 15sp body treatment. const searchInputTextStyle = messageBodyTextStyle; diff --git a/mobile/lib/shared/widgets/anchored_popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart index 46b50b6b008..7188e021f99 100644 --- a/mobile/lib/shared/widgets/anchored_popover_menu.dart +++ b/mobile/lib/shared/widgets/anchored_popover_menu.dart @@ -9,6 +9,24 @@ const _popoverEnterDuration = Duration(milliseconds: 150); const _popoverExitDuration = Duration(milliseconds: 110); const _popoverStartScale = 0.96; +/// Elevation shared by anchored menus and composer popover surfaces. +const appPopoverElevation = 8.0; + +/// Returns the translucent surface color shared by app popovers. +Color appPopoverColor(BuildContext context) => + context.colors.surface.withValues(alpha: 0.98); + +/// Returns the shadow color shared by app popovers. +Color appPopoverShadowColor(BuildContext context) => + context.colors.shadow.withValues(alpha: 0.18); + +/// Returns the 20px shape and composer-matching hairline shared by popovers. +RoundedRectangleBorder appPopoverShape(BuildContext context) => + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.popover), + side: BorderSide(color: Colors.black.withValues(alpha: 0.04), width: 1), + ); + /// 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. @@ -25,10 +43,10 @@ Future showAnchoredPopover({ required List> items, required double width, required AnchoredPopoverAlignment alignment, - required Color color, - required ShapeBorder shape, - required double elevation, - required Color shadowColor, + Color? color, + ShapeBorder? shape, + double elevation = appPopoverElevation, + Color? shadowColor, Offset offset = Offset.zero, EdgeInsetsGeometry menuPadding = EdgeInsets.zero, Clip clipBehavior = Clip.antiAlias, @@ -56,10 +74,10 @@ Future showAnchoredPopover({ width: width, alignment: alignment, offset: offset, - color: color, - shape: shape, + color: color ?? appPopoverColor(context), + shape: shape ?? appPopoverShape(context), elevation: elevation, - shadowColor: shadowColor, + shadowColor: shadowColor ?? appPopoverShadowColor(context), menuPadding: menuPadding, clipBehavior: clipBehavior, surfaceKey: surfaceKey, diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 1fe55712a53..628267c167b 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -129,15 +129,23 @@ class FilterChipBar extends StatelessWidget { textAlign: fillWidth ? TextAlign.center : TextAlign.start, style: labelStyle, ); + final centeredLabel = Align( + alignment: Alignment.center, + widthFactor: fillWidth ? null : 1, + heightFactor: 1, + child: label, + ); final chip = FilterChip( selected: isSelected, showCheckmark: false, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.lg), + ), + side: BorderSide.none, label: fillWidth - ? SizedBox( - width: double.infinity, - child: Center(child: label), - ) - : label, + ? SizedBox(width: double.infinity, child: centeredLabel) + : centeredLabel, + labelPadding: EdgeInsets.zero, onSelected: (_) => onSelected(item.id), padding: EdgeInsets.symmetric( horizontal: fillWidth ? Grid.quarter : Grid.twelve, diff --git a/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart index f562b9f83a6..93371bdd0bd 100644 --- a/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart +++ b/mobile/lib/shared/widgets/keyboard_dismiss_on_drag.dart @@ -27,8 +27,13 @@ const keyboardDismissDragThreshold = 48.0; /// `WindowInsetsAnimationController`). class KeyboardDismissOnDrag extends HookWidget { final Widget child; + final VoidCallback? onUserScrollStart; - const KeyboardDismissOnDrag({super.key, required this.child}); + const KeyboardDismissOnDrag({ + super.key, + this.onUserScrollStart, + required this.child, + }); @override Widget build(BuildContext context) { @@ -37,8 +42,12 @@ class KeyboardDismissOnDrag extends HookWidget { final downwardTravel = useRef(0.0); bool handle(ScrollNotification notification) { - if (notification is ScrollStartNotification || - notification is ScrollEndNotification) { + if (notification is ScrollStartNotification) { + if (notification.dragDetails != null) onUserScrollStart?.call(); + downwardTravel.value = 0; + return false; + } + if (notification is ScrollEndNotification) { downwardTravel.value = 0; return false; } diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart index 687880acb19..d972b8184fd 100644 --- a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -16,6 +16,27 @@ double mobileTabFooterBackdropHeight(BuildContext context) => Grid.xl + Grid.gutter; +/// Builds the shared transparent-to-surface footer fade. +/// +/// Kept separate from [MobileTabFooterBackdrop] so floating controls such as +/// the channel composer can paint the exact same fade behind their own content. +LinearGradient mobileTabFooterBackdropGradient( + BuildContext context, { + List stops = const [0, 0.5, 1], + List opacities = const [0, 0.75, 1], +}) { + assert(stops.length == opacities.length); + final surface = context.colors.surface; + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: stops, + colors: [ + for (final opacity in opacities) surface.withValues(alpha: opacity), + ], + ); +} + /// Shared fade behind the floating mobile tab bar. class MobileTabFooterBackdrop extends StatelessWidget { /// Vertical extent of the backdrop in logical pixels. @@ -39,20 +60,15 @@ class MobileTabFooterBackdrop extends StatelessWidget { @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, + gradient: mobileTabFooterBackdropGradient( + context, stops: stops, - colors: [ - for (final opacity in opacities) - surface.withValues(alpha: opacity), - ], + opacities: opacities, ), ), ), diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index a0293455f24..aecb3303fcd 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -13,6 +13,7 @@ import 'package:buzz/features/channels/read_state/read_state_provider.dart'; import 'package:buzz/features/profile/user_cache_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/anchored_popover_menu.dart'; import 'package:buzz/shared/widgets/frosted_app_bar.dart'; import 'package:buzz/shared/widgets/avatar_image.dart'; import 'package:flutter/material.dart'; @@ -111,6 +112,7 @@ void main() { Map readContexts = const {}, List? channels, TextScaler? textScaler, + EdgeInsets mediaPadding = EdgeInsets.zero, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -133,12 +135,12 @@ void main() { ], child: MaterialApp( theme: AppTheme.light(), - builder: textScaler == null - ? null - : (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: textScaler), - child: child!, - ), + builder: (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(textScaler: textScaler, padding: mediaPadding), + child: child!, + ), home: const ActivityPage(), ), ); @@ -182,16 +184,22 @@ void main() { expect(find.byTooltip('Back'), findsNothing); }); - testWidgets('keeps bottom clearance for the floating tab bar', ( + testWidgets('keeps footer clearance inside the scrollable content', ( tester, ) async { - await tester.pumpWidget(await buildTestable()); + await tester.pumpWidget( + await buildTestable(mediaPadding: const EdgeInsets.only(bottom: 88)), + ); await tester.pumpAndSettle(); - final safeAreas = tester.widgetList(find.byType(SafeArea)); - expect(safeAreas, hasLength(1)); - expect(safeAreas.single.top, isFalse); - expect(safeAreas.single.bottom, isTrue); + final safeArea = tester.widget( + find.byKey(const ValueKey('activity-content-safe-area')), + ); + expect(safeArea.top, isFalse); + expect(safeArea.bottom, isFalse); + + final list = tester.widget(find.byType(ListView)); + expect(list.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96)); }); testWidgets('shows error view with retry button', (tester) async { @@ -253,7 +261,13 @@ void main() { final material = tester.widget(surface); final shape = material.shape! as RoundedRectangleBorder; - expect(shape.borderRadius, BorderRadius.circular(Radii.card)); + expect(shape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(shape.side.color, Colors.black.withValues(alpha: 0.04)); + expect(material.elevation, appPopoverElevation); + expect( + material.shadowColor, + appPopoverShadowColor(tester.element(surface)), + ); expect(material.surfaceTintColor, Colors.transparent); expect(material.clipBehavior, Clip.antiAlias); @@ -275,7 +289,11 @@ void main() { final optionsSurface = find.byKey( const ValueKey('activity-options-popover'), ); + final optionsMaterial = tester.widget(optionsSurface); + final optionsShape = optionsMaterial.shape! as RoundedRectangleBorder; expect(tester.getSize(optionsSurface).width, 216); + expect(optionsShape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(optionsMaterial.elevation, appPopoverElevation); expect( tester .widget( diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index f95c6fefed1..51c06d02839 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -85,6 +85,25 @@ NostrEvent _systemMsg({ sig: '', ); +NostrEvent _huddleMsg({ + required String id, + required int kind, + String pubkey = 'alice', + int createdAt = 1000, +}) => NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: [ + ['h', _channelId], + ], + content: jsonEncode({ + 'ephemeral_channel_id': '8d764100-fd8f-44cf-9c98-6d8fbd739b8c', + }), + sig: '', +); + NostrEvent _reaction({ required String id, required String targetId, @@ -158,6 +177,7 @@ Widget _buildTestable({ Map> threadReplies = const {}, Map>> pendingThreadReplies = const {}, TextScaler textScaler = TextScaler.noScaling, + bool disableAnimations = false, RelaySessionNotifier? relaySessionNotifier, }) { final resolvedChannel = channel ?? _testChannel; @@ -216,7 +236,10 @@ Widget _buildTestable({ child: MaterialApp( theme: AppTheme.light(), builder: (context, child) => MediaQuery( - data: MediaQuery.of(context).copyWith(textScaler: textScaler), + data: MediaQuery.of(context).copyWith( + textScaler: textScaler, + disableAnimations: disableAnimations, + ), child: child!, ), navigatorObservers: navigatorObservers, @@ -729,6 +752,53 @@ void main() { ); }); + testWidgets('clears the composer inset when membership is revoked', ( + tester, + ) async { + final channelsNotifier = _FakeChannelsNotifier([_testChannel]); + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'msg1', + pubkey: 'alice', + content: 'Hello', + createdAt: 1000, + ), + ], + channelsNotifier: channelsNotifier, + ), + ); + await tester.pumpAndSettle(); + + final messageListFinder = find.byKey( + const ValueKey('channel-message-list'), + ); + expect( + tester + .widget(messageListFinder) + .padding! + .bottom, + greaterThan(0), + ); + expect( + find.byKey(const ValueKey('channel-composer-dock')), + findsOneWidget, + ); + + channelsNotifier.setChannels([_testChannel.copyWith(isMember: false)]); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('channel-composer-dock')), findsNothing); + expect( + tester + .widget(messageListFinder) + .padding! + .bottom, + 0, + ); + }); + testWidgets('updates detail page state after joining a channel', ( tester, ) async { @@ -886,7 +956,15 @@ void main() { final messageList = tester.widget( find.byKey(const ValueKey('channel-message-list')), ); - expect(messageList.padding!.bottom, 0); + final composerDock = find.byKey(const ValueKey('channel-composer-dock')); + final composerDockHeight = tester.getSize(composerDock).height; + expect(messageList.padding!.bottom, composerDockHeight); + expect( + tester + .getBottomLeft(find.byKey(const ValueKey('channel-message-list'))) + .dy, + greaterThan(tester.getTopLeft(composerDock).dy), + ); final newestMessageGroup = tester.widget( find.byKey(const ValueKey('channel-message-group-msg2')), ); @@ -1115,6 +1193,26 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, ); + final latestSurface = tester.widget( + find.byKey(const ValueKey('channel-jump-to-latest-surface')), + ); + final latestDecoration = latestSurface.decoration! as BoxDecoration; + expect(latestDecoration.borderRadius, BorderRadius.circular(Radii.full)); + expect( + latestDecoration.color, + AppTheme.light().colorScheme.surface.withValues(alpha: 0.5), + ); + expect( + (latestDecoration.border! as Border).top.color, + Colors.black.withValues(alpha: 0.04), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('channel-jump-to-latest')), + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); messagesNotifier.setMessages([ ...initialMessages, @@ -1446,6 +1544,31 @@ void main() { ); }); + testWidgets('renders a huddle event like a regular message row', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [_huddleMsg(id: 'huddle-1', kind: EventKind.huddleStarted)], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Alice'), findsOneWidget); + expect(findRichText('started a huddle'), findsOneWidget); + expect( + tester.getSize(find.byType(CircleAvatar)), + const Size.square(messageAvatarSize), + ); + expect( + find.byKey(const ValueKey('system-message-timestamp-alice')), + findsOneWidget, + ); + }); + testWidgets('renders member_joined (self-join) system event', ( tester, ) async { @@ -2011,7 +2134,8 @@ void main() { }, ), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Alice is typing…'), findsOneWidget); @@ -2030,7 +2154,15 @@ void main() { expect(decoration.border, isA()); expect( tester.widget(find.text('Alice is typing…')).style?.color, - AppTheme.light().colorScheme.primary, + AppTheme.light().colorScheme.onSurfaceVariant, + ); + expect( + tester.widget(find.text('Alice is typing…')).style?.fontStyle, + isNot(FontStyle.italic), + ); + expect( + find.byKey(const ValueKey('channel-typing-shimmer')), + findsOneWidget, ); expect(tester.widget(find.byType(SmallAvatar)).size, 24); }); @@ -2055,7 +2187,8 @@ void main() { }, ), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Alice and Bob are typing…'), findsOneWidget); }); @@ -2085,10 +2218,39 @@ void main() { }, ), ); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); expect(find.text('Alice and 2 others are typing…'), findsOneWidget); }); + + testWidgets('keeps typing text static when motion is reduced', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: [], + typing: [ + TypingEntry( + pubkey: 'alice', + expiresAtMs: DateTime.now().millisecondsSinceEpoch + 8000, + ), + ], + users: { + 'alice': const UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + disableAnimations: true, + ), + ); + await tester.pump(); + await tester.pump(); + + expect(find.text('Alice is typing…'), findsOneWidget); + expect( + find.byKey(const ValueKey('channel-typing-shimmer')), + findsNothing, + ); + }); }); group('Compose bar', () { @@ -2099,7 +2261,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(TextField), findsNothing); - expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsNothing); + expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget); await tester.tap(find.text('Message #general')); await tester.pumpAndSettle(); @@ -2480,7 +2642,10 @@ void main() { find.byKey(const ValueKey('thread-message-list')), ); expect(threadList.reverse, isFalse); - expect(threadList.padding!.bottom, Grid.xs); + final threadComposerDockHeight = tester + .getSize(find.byKey(const ValueKey('thread-composer-dock'))) + .height; + expect(threadList.padding!.bottom, Grid.xs + threadComposerDockHeight); final newestThreadGroup = tester.widget( find.byKey(const ValueKey('thread-message-group-reply-next-day')), ); @@ -2507,6 +2672,96 @@ void main() { expect(oldestReplyY, lessThan(newestReplyY)); }); + testWidgets('thread keeps its tail above a growing composer dock', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 20; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-19', + ), + ), + ); + await tester.pumpAndSettle(); + + final dock = find.byKey(const ValueKey('thread-composer-dock')); + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-19'), + ); + final composerSurface = find.byKey(const ValueKey('composer-surface')); + final compactDockHeight = tester.getSize(dock).height; + expect(latestReply, findsOneWidget); + expect( + tester.getBottomLeft(latestReply).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + + expect(tester.getSize(dock).height, greaterThan(compactDockHeight)); + expect(latestReply, findsOneWidget); + expect( + tester.getBottomLeft(latestReply).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); + + // The dock size change above is separate from the later Scaffold + // viewport resize caused by the keyboard. Keep following the tail after + // that metrics change too. + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + + expect( + tester.getBottomLeft(latestReply).dy, + lessThanOrEqualTo(tester.getTopLeft(composerSurface).dy), + ); + }); + testWidgets( 'initial thread hydration keeps the head visible instead of following the tail', (tester) async { @@ -2575,6 +2830,81 @@ void main() { }, ); + testWidgets( + 'deep-linking an older reply does not resume tail following on keyboard resize', + (tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.reset); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 30; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + final completer = Completer>(); + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + pendingThreadReplies: {'thread-root': completer.future}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + final provisionalTarget = formatTimeline([replies[5]]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead, provisionalTarget], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-5', + ), + ), + ); + await tester.pumpAndSettle(); + + completer.complete(replies); + await tester.pumpAndSettle(); + + final target = find.byKey( + const ValueKey('thread-message-group-reply-5'), + ); + expect(target, findsOneWidget); + + await tester.tap(find.text('Reply in thread…').hitTestable()); + await tester.pumpAndSettle(); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + await tester.pumpAndSettle(); + + expect(target, findsOneWidget); + }, + ); + testWidgets('a reaction landing while the thread is open shows up there', ( tester, ) async { diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 991db3b5cd6..56032a5dbfc 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -200,6 +200,80 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('section menu matches desktop labels, icons, and inset', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + channelSectionsProvider.overrideWith( + () => _FakeChannelSectionsNotifier( + const ChannelSectionStore( + sections: [ + ChannelSection(id: 'section-1', name: 'Design', order: 0), + ], + ), + ), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('section-menu-section-1'))); + await tester.pumpAndSettle(); + + final popover = find.byKey(const Key('section-popover-section-1')); + expect(popover, findsOneWidget); + for (final label in [ + 'Rename section', + 'Move up', + 'Move down', + 'Delete section', + ]) { + expect( + find.descendant(of: popover, matching: find.text(label)), + findsOne, + ); + } + for (final icon in [ + LucideIcons.pencil, + LucideIcons.arrowUp, + LucideIcons.arrowDown, + LucideIcons.trash2, + ]) { + expect( + find.descendant(of: popover, matching: find.byIcon(icon)), + findsOne, + ); + } + + final menuItems = tester.widgetList>( + find.descendant( + of: popover, + matching: find.byWidgetPredicate( + (widget) => widget is PopupMenuItem, + ), + ), + ); + expect(menuItems, hasLength(4)); + for (final item in menuItems) { + expect( + item.padding, + const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.twelve, 0), + ); + } + + final error = Theme.of(tester.element(popover)).colorScheme.error; + final deleteText = tester.widget(find.text('Delete section')); + final deleteIcon = tester.widget( + find.descendant(of: popover, matching: find.byIcon(LucideIcons.trash2)), + ); + expect(deleteText.style?.color, error); + expect(deleteIcon.color, error); + }); + testWidgets('aligns the top, section, row, and skeleton label columns', ( tester, ) async { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index 919d4039eb2..7dd2c2b6089 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -23,6 +23,8 @@ 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:buzz/shared/widgets/anchored_popover_menu.dart'; +import 'package:buzz/shared/widgets/mobile_tab_footer_backdrop.dart'; import 'package:shared_preferences/shared_preferences.dart'; final _pngBytes = Uint8List.fromList([ @@ -392,6 +394,152 @@ void main() { }); group('ComposeBar', () { + testWidgets('starts compact and grows to the full-width composer', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + expect(find.byType(TextField), findsNothing); + expect(find.byTooltip('Add attachment').hitTestable(), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget); + expect(find.byKey(const ValueKey('composer-footer-gradient')), findsOne); + final composerBackdrop = find.descendant( + of: find.byKey(const ValueKey('composer-footer-gradient')), + matching: find.byType(MobileTabFooterBackdrop), + ); + expect(composerBackdrop, findsOneWidget); + expect( + tester.getSize(composerBackdrop).height, + mobileTabFooterBackdropHeight(tester.element(composerBackdrop)), + ); + final compactDecoration = + tester + .widget( + find.byKey(const ValueKey('composer-surface')), + ) + .decoration + as BoxDecoration; + expect( + compactDecoration.borderRadius, + BorderRadius.circular(Radii.dialog + Grid.quarter), + ); + final compactWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; + + await _expandComposer(tester); + + final expandedWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; + final expandedDecoration = + tester + .widget( + find.byKey(const ValueKey('composer-surface')), + ) + .decoration + as BoxDecoration; + expect(compactWidth, closeTo(expandedWidth * 0.85, 0.5)); + expect( + expandedDecoration.borderRadius, + BorderRadius.circular(Radii.dialog), + ); + expect(find.byType(TextField), findsOneWidget); + expect(find.byIcon(LucideIcons.atSign), findsOneWidget); + expect(find.byIcon(LucideIcons.hash), findsOneWidget); + expect(find.byIcon(LucideIcons.smilePlus), findsOneWidget); + expect(find.byIcon(LucideIcons.aLargeSmall), findsOneWidget); + }); + + testWidgets('returns to the compact capsule when the keyboard drops', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + final focusNode = tester + .widget(find.byType(TextField)) + .focusNode!; + expect(focusNode.hasFocus, isTrue); + tester.view.viewInsets = const FakeViewPadding(bottom: 300); + addTearDown(tester.view.reset); + await tester.pump(); + + tester.view.viewInsets = FakeViewPadding.zero; + await tester.pumpAndSettle(); + + expect(find.byType(TextField), findsNothing); + expect(focusNode.hasFocus, isFalse); + final compactDecoration = + tester + .widget( + find.byKey(const ValueKey('composer-surface')), + ) + .decoration + as BoxDecoration; + expect( + compactDecoration.borderRadius, + BorderRadius.circular(Radii.dialog + Grid.quarter), + ); + + await tester.tap(find.text('Message\u2026')); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).focusNode!.hasFocus, + isTrue, + ); + }); + + testWidgets('attachment control responds while the composer is expanding', ( + 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 tester.tap(find.text('Message\u2026')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('attachment-menu')), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets('mounted composer does not carry draft text across an in-place ' 'identity switch', (tester) async { final keysA = nostr.Keys.generate(); @@ -492,6 +640,94 @@ void main() { expect(textField.controller!.text, 'hello :meow:world'); expect(textField.controller!.selection.baseOffset, 12); + expect(find.byType(TextField), findsOneWidget); + expect(textField.focusNode!.hasFocus, isTrue); + }); + + testWidgets('composer controls use selection haptics', (tester) async { + final hapticCalls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'HapticFeedback.vibrate') { + hapticCalls.add(call); + } + return null; + }); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + hapticCalls.clear(); + + await tester.tap(find.byIcon(LucideIcons.atSign)); + tester.widget(find.byType(TextField)).controller!.clear(); + await tester.pump(); + await tester.tap(find.byIcon(LucideIcons.hash)); + await tester.pump(); + await tester.tap(find.byIcon(LucideIcons.aLargeSmall)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.bold)); + await tester.pump(); + await tester.tap(find.byTooltip('Close formatting')); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Add attachment')); + await tester.pumpAndSettle(); + + expect(hapticCalls, hasLength(6)); + expect( + hapticCalls.every( + (call) => call.arguments == 'HapticFeedbackType.selectionClick', + ), + isTrue, + ); + }); + + testWidgets('composer suggestions use the shared popover treatment', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + channels: [_makeChannel(name: 'general', channelType: 'stream')], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.hash)); + await tester.pumpAndSettle(); + + final surface = find.byKey(const ValueKey('channel-suggestions-popover')); + final material = tester.widget(surface); + final shape = material.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(shape.side.color, Colors.black.withValues(alpha: 0.04)); + expect(material.elevation, appPopoverElevation); + expect( + material.shadowColor, + appPopoverShadowColor(tester.element(surface)), + ); + expect( + tester.widget(find.text('general')).style?.fontFamily, + 'Inter', + ); }); testWidgets('native All Photos picker failures show an error', ( @@ -595,6 +831,60 @@ void main() { } }); + testWidgets('leaving a focused composer dismisses the native keyboard', ( + tester, + ) async { + final previousPlatform = debugDefaultTargetPlatformOverride; + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + var dismissCalls = 0; + _setMockNativeAttachmentPopoverHandler((call) async { + switch (call.method) { + case 'isSupported': + case 'present': + return true; + case 'dismiss': + dismissCalls += 1; + return null; + } + return null; + }); + + try { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + final focusNode = tester + .widget(find.byType(TextField)) + .focusNode!; + expect(focusNode.hasFocus, isTrue); + + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); + expect(focusNode.hasFocus, isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + + expect(focusNode.hasFocus, isFalse); + expect(dismissCalls, 1); + } 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 { @@ -1191,6 +1481,10 @@ void main() { ), ); + final compactComposerWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; + await _openAttachmentMenu(tester); await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); @@ -1201,6 +1495,12 @@ void main() { ); expect(find.byTooltip('Back to attachment options'), findsWidgets); expect(find.text('All photos'), findsOneWidget); + expect( + tester + .getSize(find.byKey(const ValueKey('attachment-surface-popover'))) + .width, + closeTo(compactComposerWidth / 0.85, 0.5), + ); await tester.tap(find.byKey(const ValueKey('recent-photo-two'))); await tester.pumpAndSettle(); @@ -1265,12 +1565,22 @@ void main() { await _openAttachmentMenu(tester); final menu = find.byKey(const ValueKey('attachment-menu')); + final surface = find.byKey(const ValueKey('attachment-surface-popover')); final rows = [ for (final label in ['camera', 'photos', 'video', 'files']) find.byKey(ValueKey('attachment-menu-item-$label')), ]; final menuRect = tester.getRect(menu); + final material = tester.widget(surface); + final shape = material.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(shape.side.color, Colors.black.withValues(alpha: 0.04)); + expect(material.elevation, appPopoverElevation); + expect( + material.shadowColor, + appPopoverShadowColor(tester.element(surface)), + ); expect(menuRect.size, const Size(216, 264)); for (final row in rows) { expect(tester.getSize(row).height, 52); @@ -1280,6 +1590,7 @@ void main() { for (final label in ['Camera', 'Photos', 'Video', 'Files']) { final text = tester.widget(find.text(label)); expect(text.style?.fontSize, 20); + expect(text.style?.fontFamily, 'Inter'); } final icons = [ for (final label in ['camera', 'photos', 'video', 'files']) @@ -1319,6 +1630,48 @@ void main() { } }); + testWidgets('tapping outside dismisses the Android attachment menu', ( + 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); + expect(find.byKey(const ValueKey('attachment-menu')), findsOneWidget); + expect( + find.byKey(const ValueKey('attachment-dismiss-barrier')), + findsOneWidget, + ); + + await tester.tapAt(const Offset(24, 24)); + await tester.pumpAndSettle(); + + expect(find.byKey(const ValueKey('attachment-menu')), findsNothing); + expect( + find.byKey(const ValueKey('attachment-dismiss-barrier')), + findsNothing, + ); + expect( + find.byKey(const ValueKey('attachment-trigger-closed')).hitTestable(), + findsOneWidget, + ); + } finally { + debugDefaultTargetPlatformOverride = previousPlatform; + } + }); + testWidgets( 'attachment menu grows rows and scrolls for accessibility text', (tester) async { @@ -1376,6 +1729,9 @@ void main() { }) async {}, ), ); + final compactComposerWidth = tester + .getSize(find.byKey(const ValueKey('composer-width-transition'))) + .width; await _openAttachmentMenu(tester); await tester.tap(find.text('Camera')); @@ -1397,6 +1753,12 @@ void main() { find.byKey(const ValueKey('camera-initialization-ready')), findsOneWidget, ); + expect( + tester + .getSize(find.byKey(const ValueKey('attachment-surface-popover'))) + .width, + closeTo(compactComposerWidth / 0.85, 0.5), + ); } finally { debugDefaultTargetPlatformOverride = previousPlatform; } diff --git a/mobile/test/shared/emoji/native_emoji_glyph_test.dart b/mobile/test/shared/emoji/native_emoji_glyph_test.dart new file mode 100644 index 00000000000..435423905fa --- /dev/null +++ b/mobile/test/shared/emoji/native_emoji_glyph_test.dart @@ -0,0 +1,37 @@ +import 'package:buzz/shared/emoji/native_emoji_glyph.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('lifts the glyph one logical pixel on iOS', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + try { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)), + ), + ); + + final transform = tester.widget(find.byType(Transform)); + expect(transform.transform.getTranslation().y, -1); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('keeps the glyph unshifted on Android', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + try { + await tester.pumpWidget( + const MaterialApp( + home: Center(child: NativeEmojiGlyph(emoji: '🔥', size: 24)), + ), + ); + + expect(find.byType(Transform), findsNothing); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); +} diff --git a/mobile/test/shared/theme/app_theme_test.dart b/mobile/test/shared/theme/app_theme_test.dart index 6231befc69f..f038c6d9747 100644 --- a/mobile/test/shared/theme/app_theme_test.dart +++ b/mobile/test/shared/theme/app_theme_test.dart @@ -7,4 +7,21 @@ void main() { expect(AppTheme.light().splashFactory, NoSplash.splashFactory); expect(AppTheme.dark().splashFactory, NoSplash.splashFactory); }); + + test('uses Inter and the shared elevated popover treatment', () { + final theme = AppTheme.light(); + final popupTheme = theme.popupMenuTheme; + final shape = popupTheme.shape! as RoundedRectangleBorder; + final side = shape.side; + + expect(popupTheme.textStyle?.fontFamily, 'Inter'); + expect(popupTheme.elevation, 8); + expect( + popupTheme.shadowColor, + theme.colorScheme.shadow.withValues(alpha: 0.18), + ); + expect(shape.borderRadius, BorderRadius.circular(Radii.popover)); + expect(side.color, Colors.black.withValues(alpha: 0.04)); + expect(side.width, 1); + }); } diff --git a/mobile/test/shared/theme/message_typography_test.dart b/mobile/test/shared/theme/message_typography_test.dart index 169bcc02003..c5bec555562 100644 --- a/mobile/test/shared/theme/message_typography_test.dart +++ b/mobile/test/shared/theme/message_typography_test.dart @@ -130,6 +130,13 @@ void main() { lineHeight: 17, letterSpacing: 0, ); + expectStyle( + filterChipTextStyle, + fontSize: 15, + fontWeight: FontWeight.w400, + lineHeight: 15, + letterSpacing: 0, + ); }); test('message and activity avatars use their surface sizes', () { diff --git a/mobile/test/shared/widgets/filter_chip_bar_test.dart b/mobile/test/shared/widgets/filter_chip_bar_test.dart index 6f651624b6e..72040e5d86d 100644 --- a/mobile/test/shared/widgets/filter_chip_bar_test.dart +++ b/mobile/test/shared/widgets/filter_chip_bar_test.dart @@ -39,10 +39,57 @@ void main() { final unselectedLabel = tester.widget(find.text('Following')); expect(selectedLabel.style?.fontSize, filterChipTextStyle.fontSize); expect(selectedLabel.style?.height, filterChipTextStyle.height); + expect(selectedLabel.style?.fontFamily, 'Inter'); expect(selectedLabel.style?.fontWeight, FontWeight.w500); expect(unselectedLabel.style?.fontSize, filterChipTextStyle.fontSize); expect(unselectedLabel.style?.height, filterChipTextStyle.height); expect(unselectedLabel.style?.fontWeight, FontWeight.w400); + final chip = tester.widget( + find.widgetWithText(FilterChip, 'Everyone'), + ); + final shape = chip.shape! as RoundedRectangleBorder; + expect(shape.borderRadius, BorderRadius.circular(Radii.lg)); + expect(chip.labelPadding, EdgeInsets.zero); + }); + + testWidgets('search labels are vertically centered in their chips', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: SizedBox( + width: 390, + child: FilterChipBar( + expandItems: true, + visualDensity: const VisualDensity(horizontal: -2), + chipVerticalPadding: Grid.xxs, + barVerticalPadding: Grid.twelve, + selected: 0, + onSelected: (_) {}, + items: const [ + FilterChipItem(id: 0, label: 'All'), + FilterChipItem(id: 1, label: 'Messages'), + FilterChipItem(id: 2, label: 'Channels'), + FilterChipItem(id: 3, label: 'People'), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + for (final label in ['All', 'Messages', 'Channels', 'People']) { + final text = find.text(label); + final chip = find.ancestor(of: text, matching: find.byType(RawChip)); + expect(chip, findsOneWidget); + expect( + tester.getCenter(text).dy, + closeTo(tester.getCenter(chip).dy, 0.01), + ); + } }); testWidgets('expanded chips preserve large accessible text scaling', ( @@ -81,7 +128,10 @@ void main() { ), findsNothing, ); - expect(tester.getSize(find.text('Messages')).height, greaterThan(32)); + expect( + tester.getSize(find.text('Messages')).height, + greaterThanOrEqualTo(30), + ); expect(tester.takeException(), isNull); }); } diff --git a/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart b/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart index ae03f55c165..19424b7c82e 100644 --- a/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart +++ b/mobile/test/shared/widgets/keyboard_dismiss_on_drag_test.dart @@ -4,7 +4,10 @@ import 'package:flutter_test/flutter_test.dart'; /// A focused field inside a `Scaffold` body, which is the only arrangement /// either message list ever runs in. -Widget _testable({required FocusNode focusNode}) { +Widget _testable({ + required FocusNode focusNode, + VoidCallback? onUserScrollStart, +}) { return MaterialApp( home: Scaffold( body: Column( @@ -12,6 +15,7 @@ Widget _testable({required FocusNode focusNode}) { TextField(focusNode: focusNode), Expanded( child: KeyboardDismissOnDrag( + onUserScrollStart: onUserScrollStart, child: ListView( children: [ for (var i = 0; i < 40; i++) @@ -104,6 +108,23 @@ void main() { expect(focusNode.hasFocus, isTrue); }); + testWidgets('reports a user-started scroll', (tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + var userScrollStarts = 0; + await tester.pumpWidget( + _testable( + focusNode: focusNode, + onUserScrollStart: () => userScrollStarts += 1, + ), + ); + + await tester.drag(find.text('row 3'), const Offset(0, -100)); + await tester.pumpAndSettle(); + + expect(userScrollStarts, 1); + }); + testWidgets('an upward drag never dismisses, however far it goes', ( 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 index 9e54177d61f..e13d17a65eb 100644 --- a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart +++ b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart @@ -3,6 +3,28 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + testWidgets('shared gradient fades from transparent to the page surface', ( + tester, + ) async { + LinearGradient? gradient; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) { + gradient = mobileTabFooterBackdropGradient(context); + return const SizedBox(); + }, + ), + ), + ); + + expect(gradient?.stops, [0, 0.5, 1]); + expect(gradient?.colors.first.a, 0); + expect(gradient?.colors[1].a, 0.75); + expect(gradient?.colors.last.a, 1); + }); + testWidgets('uses the logical bottom safe-area inset', (tester) async { double? height; From be95a8a986d02319b27e8fb57aefe59e33a1eb13 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 3 Aug 2026 11:04:49 -0400 Subject: [PATCH 013/163] fix(config-bridge): add harness-definition env tier and fix equal-value model override (#3580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All seven normalized config fields resolve through sanitized `InheritedConfigTiers` passed wholesale to `read_config_surface`. The reader's precedence tiers now match spawn's Layer 2b exactly — including harness-definition env — and the equal-value model-override regression is fixed. ## Changes **`config_bridge/types.rs`** — add `InheritedConfigTiers`: persona env, global env, harness definition env, structured model/provider/prompt for both tiers. Add `HarnessDefault` `ConfigOrigin` variant for harness-definition env values. **`commands/agent_config.rs`** — `build_inherited_tiers` now resolves the harness definition env using the same lookup path as spawn (`record.runtime` → `persona.runtime` → empty string) and applies `sanitize_inherited_env` to it. `resolve_config_surface` is unchanged in shape — tiers passed to the reader now include `definition_env`. **`config_bridge/reader.rs`** — `env_candidates` extended to 4-element return (record, persona, global, definition). All five field builders that use env candidates now include the definition-env slot below global env and above the structured block, matching spawn Layer 2b. Magic `configured[..6]` slice replaced with `configured[..configured.len()-1]` (named split: all non-file candidates). Equal-value model-override arm falls through to the normal resolve path instead of early-returning `RuntimeOverride`, so the panel shows the baseline origin (e.g. `BuzzExplicit`) rather than a spurious "Live override" label for a no-op switch. **`config_bridge/reader_tests_ext.rs`** — three new Layer 2b tests: definition env beats structured persona model, global env beats definition env, reserved-key-absent fallthrough. **`commands/agent_config_tests.rs`** — `genuine_explicit_live_switch_to_same_model_yields_clean_field` updated to assert `origin == BuzzExplicit` (not `RuntimeOverride`); wrapped in `with_no_goose_config` for hermeticity. New `reserved_key_in_definition_env_shaped_map_is_stripped_by_sanitize` test pins the shared sanitization contract. **`AgentConfigPanel.tsx` / `types.ts`** — `HarnessDefault` origin variant wired end-to-end: TS union type and provenance sentence ("Inherited from harness definition"). --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .../src-tauri/src/commands/agent_config.rs | 765 +++--------------- .../src/commands/agent_config_tests.rs | 651 +++++++++++++++ .../managed_agents/config_bridge/reader.rs | 479 ++++++----- .../config_bridge/reader_tests.rs | 497 ++++++++---- .../config_bridge/reader_tests_ext.rs | 258 ++++++ .../src/managed_agents/config_bridge/types.rs | 48 +- .../src-tauri/src/managed_agents/runtime.rs | 3 +- .../src/managed_agents/runtime/metadata.rs | 26 - .../features/agents/ui/AgentConfigPanel.tsx | 2 + desktop/src/shared/api/types.ts | 4 +- 10 files changed, 1675 insertions(+), 1058 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_config_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 5a26f0f6450..7aded79599b 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -8,14 +8,14 @@ use crate::{ read_goose_file_config, reader::read_config_surface, types::{ - AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, ConfigOrigin, - NormalizedField, RuntimeConfigSurface, SessionConfigCache, + AcpConfigOptionEntry, AcpConfigOptionValue, AcpModelEntry, InheritedConfigTiers, + RuntimeConfigSurface, SessionConfigCache, }, }, - current_instance_id, known_acp_runtime, load_managed_agents, load_personas, - resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, + current_instance_id, is_reserved_env_key, is_well_formed_env_key, known_acp_runtime, + load_managed_agents, load_personas, save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, + ManagedAgentRuntimeKey, MAX_ENV_VALUE_BYTES, }, }; @@ -36,28 +36,86 @@ pub struct RuntimeFileConfigSubset { pub satisfied_env_keys: Vec, } -/// Resolve the config surface with persona and global default values applied. -/// -/// Linked instances are definition-authoritative: the record's own -/// system_prompt/model/provider are cleared before applying, so a stale -/// materialized snapshot can never shadow the persona's current values or a -/// blank-definition fallthrough to global defaults (mirrors -/// `effective_config::resolve_linked`). Definition-less instances keep their -/// own explicit values. -/// -/// The pipeline: resolve the linked persona's prompt/model/provider, inject -/// each into the record only where the record lacks its own value, let -/// `read_config_surface` tag those injected fields `BuzzExplicit`, then re-tag -/// exactly the injected fields to `PersonaDefault`. +/// Sanitize a raw env map from an inherited tier (persona or global) with the +/// same rules `merged_user_env` applies at spawn time: reserved keys, malformed +/// keys, NUL-byte values, and oversize values are stripped silently. +fn sanitize_inherited_env( + raw: &std::collections::BTreeMap, +) -> std::collections::BTreeMap { + raw.iter() + .filter(|(k, v)| { + !is_reserved_env_key(k) + && is_well_formed_env_key(k) + && !v.contains('\0') + && v.len() <= MAX_ENV_VALUE_BYTES + }) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + +/// Normalize a structured field value: blank/whitespace-only collapses to +/// `None`, matching `effective_config`'s `non_blank` helper. +fn non_blank(v: Option<&str>) -> Option { + v.filter(|s| !s.trim().is_empty()).map(str::to_owned) +} + +/// Build a sanitized `InheritedConfigTiers` snapshot at the command boundary. /// -/// Global defaults fill in when neither the record nor the linked persona -/// provides a value. They are re-tagged to `GlobalDefault` so the UI can -/// display "inherited from global defaults". +/// Persona env, global env, and harness definition env are sanitized with +/// spawn-equivalent rules. Structured fields are normalized (blank → None). +/// A missing persona (orphaned link) yields empty persona tiers — the panel +/// still renders from record/global while spawn independently refuses. +fn build_inherited_tiers( + record_persona_id: Option<&str>, + record_runtime: Option<&str>, + personas: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> InheritedConfigTiers { + let persona = record_persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)); + + let persona_env = persona + .map(|p| sanitize_inherited_env(&p.env_vars)) + .unwrap_or_default(); + let global_env = sanitize_inherited_env(&global.env_vars); + + // Definition env: same resolution as spawn (record.runtime → persona.runtime → ""). + // Reserved keys stripped; no malformed-key / NUL / oversize check needed because + // harness definitions are local admin-authored JSON, not user-provided data — but + // we apply `sanitize_inherited_env` for defense-in-depth (same rules as the other tiers). + let definition_env = { + let runtime_id = record_runtime + .or_else(|| persona.and_then(|p| p.runtime.as_deref())) + .unwrap_or(""); + crate::managed_agents::custom_harnesses::lookup_loaded_harness_by_id(runtime_id) + .map(|def| sanitize_inherited_env(&def.env)) + .unwrap_or_default() + }; + + let persona_model = persona.and_then(|p| non_blank(p.model.as_deref())); + let persona_provider = persona.and_then(|p| non_blank(p.provider.as_deref())); + let persona_prompt = persona.and_then(|p| non_blank(Some(&p.system_prompt))); + let global_model = non_blank(global.model.as_deref()); + let global_provider = non_blank(global.provider.as_deref()); + + InheritedConfigTiers { + persona_env, + global_env, + definition_env, + persona_model, + persona_provider, + persona_prompt, + global_model, + global_provider, + } +} + +/// Resolve the config surface with inherited persona and global tiers applied. /// -/// The re-tag is triple-gated — a field is re-tagged only when (a) the record -/// did not already have it (`!had_*`), (b) the surface produced the field, and -/// (c) the reader tagged it `BuzzExplicit`. A value the user set explicitly in -/// Buzz keeps `had_* == true` and is never re-tagged. +/// Persona-linked instances have their system_prompt/model/provider cleared +/// first (definition-authoritative): stale materialized snapshots can never +/// shadow live persona values. The reader then resolves each field through its +/// full candidate list (record env > ACP > persona env > global env > structured +/// persona/global > config file) via `resolve_with_override`. fn resolve_config_surface( mut record: ManagedAgentRecord, personas: &[AgentDefinition], @@ -65,146 +123,23 @@ fn resolve_config_surface( session_cache: Option<&SessionConfigCache>, global: &GlobalAgentConfig, ) -> RuntimeConfigSurface { - // Linked instances are definition-authoritative (mirrors - // `effective_config::resolve_linked`): the record's own - // system_prompt/model/provider fields are, at best, a stale materialized - // snapshot from the last `apply_persona_snapshot` — never a legitimate - // live override, since `update_managed_agent` blocks writing these three - // fields for linked instances. Clear them before computing `had_*` below - // so a stale byte can never masquerade as BuzzExplicit and suppress - // definition/global injection. Env var overrides (set via the advanced - // env-vars editor) are untouched — those remain a legitimate - // per-instance override regardless of link status. + // Linked instances are definition-authoritative: clear stale materialized + // model/provider/prompt so they can never masquerade as BuzzExplicit and + // shadow definition values. Env var overrides are untouched. if record.persona_id.is_some() { record.system_prompt = None; record.model = None; record.provider = None; } - let had_prompt = - record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); - let had_model = record.model.is_some(); - - let provider_env_key = runtime_meta.and_then(|m| m.provider_env_var).unwrap_or(""); - let had_provider = record.env_vars.contains_key(provider_env_key); - - let (persona_prompt, persona_model, persona_provider) = resolve_effective_prompt_model_provider( + let tiers = build_inherited_tiers( record.persona_id.as_deref(), + record.runtime.as_deref(), personas, - record.system_prompt.clone(), - record.model.clone(), - record.provider.clone(), - ); - - // Build the baseline the reader overrides a live model against, paired with - // its true origin so the secondary is tagged correctly. Two sources: - // - persona-linked, no explicit record model: the persona model is the - // baseline (PersonaDefault). - // - genuine-explicit (record had its own model) that live-switched: the - // record's own model is the baseline (BuzzExplicit). Gated behind - // `model_overridden` so a persona edited mid-life (override flag false) - // never synthesizes a baseline and false-positives an override. - // An explicit pick with no live switch has no baseline to override. - let model_overridden = session_cache.is_some_and(|c| c.model_overridden); - let baseline = if had_model { - if model_overridden { - record - .model - .clone() - .map(|m| (m, ConfigOrigin::BuzzExplicit)) - } else { - None - } - } else { - // Prefer persona as baseline, fall back to global when persona has none - // and the model was overridden mid-session (global-default agent). - persona_model - .clone() - .map(|m| (m, ConfigOrigin::PersonaDefault)) - .or_else(|| { - if model_overridden { - global - .model - .clone() - .map(|m| (m, ConfigOrigin::GlobalDefault)) - } else { - None - } - }) - }; - - // Inject resolved persona values into the record where absent. - if !had_prompt { - if let Some(p) = persona_prompt { - record - .env_vars - .insert("BUZZ_ACP_SYSTEM_PROMPT".to_string(), p); - } - } - if !had_model { - record.model = persona_model.clone(); - } - if !had_provider && !provider_env_key.is_empty() { - if let Some(prov) = persona_provider { - record.env_vars.insert(provider_env_key.to_string(), prov); - } - } - - // Inject global defaults where neither the record nor the persona had a value. - // Track injection so we can re-tag to GlobalDefault after the reader. - let inject_global_model = !had_model && record.model.is_none(); - let inject_global_provider = !had_provider - && !provider_env_key.is_empty() - && !record.env_vars.contains_key(provider_env_key); - - if inject_global_model { - record.model = global.model.clone(); - } - if inject_global_provider { - if let Some(ref gprov) = global.provider { - record - .env_vars - .insert(provider_env_key.to_string(), gprov.clone()); - } - } - - let mut surface = read_config_surface( - &record, - runtime_meta, - session_cache, - baseline.as_ref().map(|(m, o)| (m.as_str(), o.clone())), + global, ); - // Re-tag persona-sourced fields from BuzzExplicit to PersonaDefault. - if !had_prompt { - retag_persona_default(&mut surface.normalized.system_prompt); - } - if !had_model && !inject_global_model { - retag_persona_default(&mut surface.normalized.model); - } - if !had_provider && !provider_env_key.is_empty() && !inject_global_provider { - retag_persona_default(&mut surface.normalized.provider); - } - - // Re-tag global-sourced fields from BuzzExplicit to GlobalDefault. - if inject_global_model { - retag_global_default(&mut surface.normalized.model); - } - if inject_global_provider { - retag_global_default(&mut surface.normalized.provider); - } - - surface -} - -/// Re-tag a field's origin from `BuzzExplicit` to `PersonaDefault`, leaving any -/// other origin untouched. No-op when the field is absent. -fn retag_persona_default(field: &mut Option) { - if let Some(field) = field { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::PersonaDefault; - } - } + read_config_surface(&record, runtime_meta, session_cache, &tiers) } /// Get the file-layer config for a runtime — used by the Create/Edit/Persona @@ -327,16 +262,6 @@ pub fn get_baked_build_env() -> Vec { .collect() } -/// Re-tag a field's origin from `BuzzExplicit` to `GlobalDefault`, leaving any -/// other origin untouched. No-op when the field is absent. -fn retag_global_default(field: &mut Option) { - if let Some(field) = field { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::GlobalDefault; - } - } -} - /// Get the full config surface for a managed agent. /// /// Returns normalized + advanced config from all available tiers. @@ -601,511 +526,5 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< } #[cfg(test)] -mod tests { - use super::*; - use crate::managed_agents::{BackendKind, RespondTo}; - - fn goose_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "", - adapter_install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - } - } - - fn agent_record() -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "agent".to_string(), - name: "Agent".to_string(), - persona_id: Some("persona-1".to_string()), - private_key_nsec: "".to_string(), - auth_tag: None, - relay_url: "ws://localhost:3000".to_string(), - avatar_url: None, - acp_command: "buzz-acp".to_string(), - agent_command: "goose".to_string(), - agent_args: vec![], - mcp_command: "".to_string(), - turn_timeout_seconds: 300, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - env_vars: Default::default(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::Local, - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: "".to_string(), - updated_at: "".to_string(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::OwnerOnly, - respond_to_allowlist: vec![], - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - agent_command_override: None, - persona_source_version: None, - provider: None, - } - } - - fn persona_with_model(model: &str) -> AgentDefinition { - AgentDefinition { - id: "persona-1".to_string(), - display_name: "Persona".to_string(), - avatar_url: None, - system_prompt: "You are a persona.".to_string(), - runtime: None, - model: Some(model.to_string()), - provider: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - env_vars: Default::default(), - respond_to: None, - respond_to_allowlist: Vec::new(), - parallelism: None, - created_at: "".to_string(), - updated_at: "".to_string(), - } - } - - /// A post-spawn session cache whose live model is `current_model` and whose - /// `model_overridden` flag records whether a `SwitchModel` control signal set - /// it (the live-switch signal). - fn session_cache(current_model: &str, model_overridden: bool) -> SessionConfigCache { - SessionConfigCache { - config_options: vec![], - available_modes: vec![], - available_models: vec![], - current_model: Some(current_model.to_string()), - model_overridden, - goose_native_config: None, - captured_at: "".to_string(), - } - } - - /// Definition-authoritative: a stale materialized `record.model` on a - /// linked instance must never outrank (or even be consulted against) the - /// linked persona's model. `update_managed_agent` already blocks writing - /// model/provider/prompt for linked instances, so a non-`None` value here - /// can only be leftover snapshot bytes from before a persona edit — the - /// panel must report the persona's current model, tagged `PersonaDefault`, - /// not the stale byte as `BuzzExplicit`. - #[test] - fn linked_stale_record_model_never_outranks_persona_model() { - let mut record = agent_record(); - record.model = Some("stale-explicit-model".to_string()); - let personas = vec![persona_with_model("persona-model")]; - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - None, - &Default::default(), - ); - - let model = surface.normalized.model.as_ref().expect("model resolved"); - assert_eq!(model.value.as_deref(), Some("persona-model")); - assert_eq!(model.origin, ConfigOrigin::PersonaDefault); - } - - /// Definition-authoritative, blank-definition case: a linked instance - /// whose persona has no model of its own must fall through to the global - /// default, tagged `GlobalDefault` — mirroring - /// `effective_config::resolve_linked`'s `None => global` arm. A stale - /// materialized record model must not shadow this fallthrough either. - #[test] - fn linked_blank_definition_model_falls_through_to_global_default() { - let mut record = agent_record(); - record.model = Some("stale-explicit-model".to_string()); - let mut persona = persona_with_model("unused"); - persona.model = None; - let personas = vec![persona]; - let global = crate::managed_agents::GlobalAgentConfig { - model: Some("global-model".to_string()), - ..Default::default() - }; - - let surface = - resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); - - let model = surface.normalized.model.as_ref().expect("model resolved"); - assert_eq!(model.value.as_deref(), Some("global-model")); - assert_eq!(model.origin, ConfigOrigin::GlobalDefault); - } - - /// A definition-less (no `persona_id`) instance's own explicit model IS - /// authoritative — the stale-record clearing above is scoped to linked - /// instances only. - #[test] - fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { - let mut record = agent_record(); - record.persona_id = None; - record.model = Some("explicit-model".to_string()); - let personas = vec![persona_with_model("persona-model")]; - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - None, - &Default::default(), - ); - - let model = surface.normalized.model.as_ref().expect("model resolved"); - assert_eq!(model.value.as_deref(), Some("explicit-model")); - assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); - } - - /// Part A — pending-pick: a genuine-explicit pick X with a divergent live - /// model Y but `model_overridden == false` (the live switch is not yet - /// applied — a restart is pending) must keep X as the primary and must NOT - /// surface Y as an override row. The live `acp_model` does not win. This - /// FAILS against a let-live-acp-win variant (one that dropped the - /// `model_overridden` gate), so it is not vacuous. - #[test] - fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { - let mut record = agent_record(); - record.persona_id = None; - record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; - let cache = session_cache("model-y", false); - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &Default::default(), - ); - let model = surface.normalized.model.expect("model resolved"); - - assert_eq!(model.value.as_deref(), Some("model-x")); - assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); - assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); - assert_ne!(model.overridden_value.as_deref(), Some("model-y")); - } - - /// W2 — genuine-explicit live switch: record.model = X, no persona, - /// `model_overridden == true`, live model = Y. The live Y must render as the - /// primary with a `RuntimeOverride` origin and X as the secondary tagged - /// `BuzzExplicit` (its true source — NOT `PersonaDefault`). FAILS against the - /// shipped no-persona early-return, which left X as primary and Y struck. - #[test] - fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { - let mut record = agent_record(); - record.persona_id = None; - record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; - let cache = session_cache("model-y", true); - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &Default::default(), - ); - let model = surface.normalized.model.expect("model resolved"); - - assert_eq!(model.value.as_deref(), Some("model-y")); - assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); - assert_eq!(model.overridden_value.as_deref(), Some("model-x")); - assert_eq!(model.overridden_origin, Some(ConfigOrigin::BuzzExplicit)); - } - - /// Y==X collision: a genuine-explicit agent live-switches to the SAME value - /// it already had. There is no real divergence, so the field must be a clean - /// single value with NO secondary row. FAILS against a naive `return base` - /// that would leak the `AcpConfigOption` row `build_model_field` populates. - #[test] - fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { - let mut record = agent_record(); - record.persona_id = None; - record.model = Some("model-x".to_string()); - let personas: Vec = vec![]; - let cache = session_cache("model-x", true); - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &Default::default(), - ); - let model = surface.normalized.model.expect("model resolved"); - - assert_eq!(model.value.as_deref(), Some("model-x")); - assert_eq!(model.overridden_value, None); - assert_eq!(model.overridden_origin, None); - } - - /// Persona parity (regression): a persona-linked agent with no explicit - /// record model that live-switches still renders the persona model as the - /// secondary tagged `PersonaDefault` — the typed-baseline change must NOT - /// regress the persona arm to a different origin. - #[test] - fn persona_linked_live_switch_keeps_persona_default_secondary() { - let record = agent_record(); - let personas = vec![persona_with_model("persona-model")]; - let cache = session_cache("model-y", true); - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &Default::default(), - ); - let model = surface.normalized.model.expect("model resolved"); - - assert_eq!(model.value.as_deref(), Some("model-y")); - assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); - assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); - assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); - } - - /// Fix 2 regression: a global-default-only agent (no record model, no - /// persona model, but global has a model) that live-switches mid-session - /// must render the global model as the secondary tagged `GlobalDefault`. - /// Before the fix, `baseline` was `None` in the `!had_model` arm when - /// persona has no model, so `read_config_surface` had no secondary to - /// surface. Fails against pre-fix code where the baseline arm returned - /// `None` when `!had_model && persona_model.is_none() && model_overridden`. - #[test] - fn global_default_live_switch_renders_global_model_as_secondary_global_default() { - // Record has no model, no persona, global provides the model. - let mut record = agent_record(); - record.persona_id = None; - // record.model = None (set by agent_record()) - let personas: Vec = vec![]; - let cache = session_cache("model-y", true); - let global = crate::managed_agents::GlobalAgentConfig { - model: Some("global-model".to_string()), - ..Default::default() - }; - - let surface = resolve_config_surface( - record, - &personas, - Some(goose_runtime()), - Some(&cache), - &global, - ); - let model = surface.normalized.model.expect("model resolved"); - - // Live model wins as primary. - assert_eq!(model.value.as_deref(), Some("model-y")); - assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); - // Global model surfaces as secondary, tagged GlobalDefault. - assert_eq!( - model.overridden_value.as_deref(), - Some("global-model"), - "global model must be the override baseline secondary" - ); - assert_eq!( - model.overridden_origin, - Some(ConfigOrigin::GlobalDefault), - "override baseline origin must be GlobalDefault, not PersonaDefault or BuzzExplicit" - ); - } - - // ── get_baked_build_env / is_secret_key tests ────────────────────────── - - /// Build a `BakedEnvEntry` vec from a synthetic map, mirroring what - /// `get_baked_build_env()` does. Used to test masking without relying on - /// compile-time `option_env!` vars (OSS builds have empty `baked_build_env`). - fn baked_env_from_map(map: &[(&str, &str)]) -> Vec { - map.iter() - .filter(|(_, v)| !v.is_empty()) - .map(|(k, v)| { - let masked = !super::is_safe_to_reveal(k); - BakedEnvEntry { - key: k.to_string(), - value: if masked { - "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string() - } else { - v.to_string() - }, - masked, - } - }) - .collect() - } - - #[test] - fn baked_env_non_secret_key_shows_real_value() { - let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "databricks_v2")]); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].key, "BUZZ_AGENT_PROVIDER"); - assert_eq!(entries[0].value, "databricks_v2"); - assert!(!entries[0].masked); - } - - #[test] - fn baked_env_api_key_is_masked() { - let entries = baked_env_from_map(&[("ANTHROPIC_API_KEY", "sk-secret")]); - assert_eq!(entries.len(), 1); - assert_eq!(entries[0].value, "••••••"); - assert!(entries[0].masked); - } - - #[test] - fn baked_env_token_key_is_masked() { - let entries = baked_env_from_map(&[("GITHUB_TOKEN", "ghp_secret")]); - assert_eq!(entries.len(), 1); - assert!(entries[0].masked); - } - - #[test] - fn baked_env_secret_key_is_masked() { - let entries = baked_env_from_map(&[("MY_DB_SECRET", "s3cr3t")]); - assert_eq!(entries.len(), 1); - assert!(entries[0].masked); - } - - #[test] - fn baked_env_password_key_is_masked() { - let entries = baked_env_from_map(&[("DB_PASSWORD", "hunter2")]); - assert_eq!(entries.len(), 1); - assert!(entries[0].masked); - } - - #[test] - fn baked_env_empty_value_filtered_out() { - let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "")]); - assert!(entries.is_empty()); - } - - #[test] - fn baked_env_mixed_keys_correct_masking() { - let entries = baked_env_from_map(&[ - ("BUZZ_AGENT_PROVIDER", "databricks_v2"), - ("BUZZ_AGENT_MODEL", "goose-claude-opus-4-8"), - ("DATABRICKS_HOST", "https://example.com"), - ("DATABRICKS_TOKEN", "dapi-secret"), - ]); - assert_eq!(entries.len(), 4); - - let provider = entries - .iter() - .find(|e| e.key == "BUZZ_AGENT_PROVIDER") - .unwrap(); - assert_eq!(provider.value, "databricks_v2"); - assert!(!provider.masked); - - let model = entries - .iter() - .find(|e| e.key == "BUZZ_AGENT_MODEL") - .unwrap(); - assert_eq!(model.value, "goose-claude-opus-4-8"); - assert!(!model.masked); - - let host = entries.iter().find(|e| e.key == "DATABRICKS_HOST").unwrap(); - assert_eq!(host.value, "https://example.com"); - assert!(!host.masked); - - let token = entries - .iter() - .find(|e| e.key == "DATABRICKS_TOKEN") - .unwrap(); - assert_eq!(token.value, "••••••"); - assert!(token.masked); - } - - #[test] - fn baked_env_thinking_effort_is_unmasked() { - // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. - let entries = baked_env_from_map(&[("BUZZ_AGENT_THINKING_EFFORT", "medium")]); - assert_eq!(entries.len(), 1); - let effort = entries - .iter() - .find(|e| e.key == "BUZZ_AGENT_THINKING_EFFORT") - .unwrap(); - assert_eq!(effort.value, "medium"); - assert!(!effort.masked); - } - - #[test] - fn baked_env_allowlist_is_case_insensitive() { - // Known-safe keys — case-insensitive match must allow them. - assert!(super::is_safe_to_reveal("buzz_agent_provider")); - assert!(super::is_safe_to_reveal("BUZZ_AGENT_PROVIDER")); - assert!(super::is_safe_to_reveal("buzz_agent_model")); - assert!(super::is_safe_to_reveal("BUZZ_AGENT_MODEL")); - assert!(super::is_safe_to_reveal("buzz_agent_thinking_effort")); - assert!(super::is_safe_to_reveal("BUZZ_AGENT_THINKING_EFFORT")); - assert!(super::is_safe_to_reveal("databricks_host")); - assert!(super::is_safe_to_reveal("DATABRICKS_HOST")); - assert!(super::is_safe_to_reveal("databricks_model")); - assert!(super::is_safe_to_reveal("DATABRICKS_MODEL")); - // Keys NOT in the allowlist — masked regardless of naming pattern. - assert!(!super::is_safe_to_reveal("my_api_key")); - assert!(!super::is_safe_to_reveal("GITHUB_TOKEN")); - assert!(!super::is_safe_to_reveal("DB_SECRET")); - assert!(!super::is_safe_to_reveal("DB_PASSWORD")); - // Bare names that old heuristic (contains("_TOKEN") etc.) would have missed. - assert!(!super::is_safe_to_reveal("APIKEY")); - assert!(!super::is_safe_to_reveal("TOKEN")); - assert!(!super::is_safe_to_reveal("SECRET")); - assert!(!super::is_safe_to_reveal("PASSWORD")); - assert!(!super::is_safe_to_reveal("PRIVATE_KEY")); - // Unknown key → masked by default. - assert!(!super::is_safe_to_reveal("SOME_UNKNOWN_KEY")); - } -} +#[path = "agent_config_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs new file mode 100644 index 00000000000..f3667cff451 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -0,0 +1,651 @@ +//! Unit tests for `commands/agent_config.rs` (split to keep `agent_config.rs` +//! under the 1000-line file-size ratchet). +//! +//! Included via `#[path = "agent_config_tests.rs"] mod tests;` at the bottom of +//! `agent_config.rs`, so `use super::*` gives access to all items in that module. + +use super::*; +use crate::managed_agents::config_bridge::types::ConfigOrigin; +use crate::managed_agents::{BackendKind, RespondTo}; + +use std::sync::Mutex; + +static GOOSE_PATH_ROOT_LOCK: Mutex<()> = Mutex::new(()); + +/// Run a test body with GOOSE_PATH_ROOT set to a non-existent path so that the +/// goose config file read returns `None`. Restores the prior value on exit. +fn with_no_goose_config(body: impl FnOnce() -> T) -> T { + let _guard = GOOSE_PATH_ROOT_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()); + let prior = std::env::var_os("GOOSE_PATH_ROOT"); + std::env::set_var("GOOSE_PATH_ROOT", "/nonexistent-buzz-test-path"); + let output = body(); + match prior { + Some(value) => std::env::set_var("GOOSE_PATH_ROOT", value), + None => std::env::remove_var("GOOSE_PATH_ROOT"), + } + output +} + +fn goose_runtime() -> &'static KnownAcpRuntime { + &KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "", + adapter_install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.config/goose/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: true, + thinking_env_var: Some("GOOSE_THINKING_EFFORT"), + max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), + context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + } +} + +fn agent_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "agent".to_string(), + name: "Agent".to_string(), + persona_id: Some("persona-1".to_string()), + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: Default::default(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + agent_command_override: None, + persona_source_version: None, + provider: None, + } +} + +fn persona_with_model(model: &str) -> AgentDefinition { + AgentDefinition { + id: "persona-1".to_string(), + display_name: "Persona".to_string(), + avatar_url: None, + system_prompt: "You are a persona.".to_string(), + runtime: None, + model: Some(model.to_string()), + provider: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: Default::default(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + } +} + +/// A post-spawn session cache whose live model is `current_model` and whose +/// `model_overridden` flag records whether a `SwitchModel` control signal set +/// it (the live-switch signal). +fn session_cache(current_model: &str, model_overridden: bool) -> SessionConfigCache { + SessionConfigCache { + config_options: vec![], + available_modes: vec![], + available_models: vec![], + current_model: Some(current_model.to_string()), + model_overridden, + goose_native_config: None, + captured_at: "".to_string(), + } +} + +/// Definition-authoritative: a stale materialized `record.model` on a +/// linked instance must never outrank (or even be consulted against) the +/// linked persona's model. `update_managed_agent` already blocks writing +/// model/provider/prompt for linked instances, so a non-`None` value here +/// can only be leftover snapshot bytes from before a persona edit — the +/// panel must report the persona's current model, tagged `PersonaDefault`, +/// not the stale byte as `BuzzExplicit`. +#[test] +fn linked_stale_record_model_never_outranks_persona_model() { + let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &Default::default(), + ); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} + +/// Definition-authoritative, blank-definition case: a linked instance +/// whose persona has no model of its own must fall through to the global +/// default, tagged `GlobalDefault` — mirroring +/// `effective_config::resolve_linked`'s `None => global` arm. A stale +/// materialized record model must not shadow this fallthrough either. +#[test] +fn linked_blank_definition_model_falls_through_to_global_default() { + let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let mut persona = persona_with_model("unused"); + persona.model = None; + let personas = vec![persona]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); +} + +/// A definition-less (no `persona_id`) instance's own explicit model IS +/// authoritative — the stale-record clearing above is scoped to linked +/// instances only. +#[test] +fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &Default::default(), + ); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("explicit-model")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); +} + +/// Part A — pending-pick: a genuine-explicit pick X with a divergent live +/// model Y but `model_overridden == false` (the live switch is not yet +/// applied — a restart is pending) must keep X as the primary and must NOT +/// surface Y as an override row. The live `acp_model` does not win. This +/// FAILS against a let-live-acp-win variant (one that dropped the +/// `model_overridden` gate), so it is not vacuous. +#[test] +fn pending_pick_keeps_explicit_x_and_does_not_surface_live_y() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-y", false); + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-x")); + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_ne!(model.overridden_value.as_deref(), Some("model-y")); +} + +/// W2 — genuine-explicit live switch: record.model = X, no persona, +/// `model_overridden == true`, live model = Y. The live Y must render as the +/// primary with a `RuntimeOverride` origin and X as the secondary tagged +/// `BuzzExplicit` (its true source — NOT `PersonaDefault`). FAILS against the +/// shipped no-persona early-return, which left X as primary and Y struck. +#[test] +fn genuine_explicit_live_switch_renders_y_over_x_buzz_explicit_secondary() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-y", true); + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value.as_deref(), Some("model-x")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::BuzzExplicit)); +} + +/// Y==X collision: a genuine-explicit agent live-switches to the SAME value +/// it already had. There is no real divergence, so the field must be a clean +/// single value with NO secondary row and origin matching the baseline (not +/// RuntimeOverride). FAILS against a naive `return base` that would leak the +/// `AcpConfigOption` row `build_model_field` populates, and against the +/// prior implementation that stamped `RuntimeOverride` on the equal-value arm. +/// +/// `with_no_goose_config` suppresses the goose config file read so that the +/// fall-through to normal resolution cannot pick up a local `~/.config/goose/config.yaml` +/// model as a spurious secondary — the test is about tier precedence, not the +/// local developer's goose install. +#[test] +fn genuine_explicit_live_switch_to_same_model_yields_clean_field() { + let mut record = agent_record(); + record.persona_id = None; + record.model = Some("model-x".to_string()); + let personas: Vec = vec![]; + let cache = session_cache("model-x", true); + + let surface = with_no_goose_config(|| { + resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ) + }); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-x")); + // Equal-value switch must NOT stamp RuntimeOverride — baseline origin wins. + assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value, None); + assert_eq!(model.overridden_origin, None); +} + +/// Persona parity (regression): a persona-linked agent with no explicit +/// record model that live-switches still renders the persona model as the +/// secondary tagged `PersonaDefault` — the typed-baseline change must NOT +/// regress the persona arm to a different origin. +#[test] +fn persona_linked_live_switch_keeps_persona_default_secondary() { + let record = agent_record(); + let personas = vec![persona_with_model("persona-model")]; + let cache = session_cache("model-y", true); + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &Default::default(), + ); + let model = surface.normalized.model.expect("model resolved"); + + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); +} + +/// Fix 2 regression: a global-default-only agent (no record model, no +/// persona model, but global has a model) that live-switches mid-session +/// must render the global model as the secondary tagged `GlobalDefault`. +/// Before the fix, `baseline` was `None` in the `!had_model` arm when +/// persona has no model, so `read_config_surface` had no secondary to +/// surface. Fails against pre-fix code where the baseline arm returned +/// `None` when `!had_model && persona_model.is_none() && model_overridden`. +#[test] +fn global_default_live_switch_renders_global_model_as_secondary_global_default() { + // Record has no model, no persona, global provides the model. + let mut record = agent_record(); + record.persona_id = None; + // record.model = None (set by agent_record()) + let personas: Vec = vec![]; + let cache = session_cache("model-y", true); + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + Some(&cache), + &global, + ); + let model = surface.normalized.model.expect("model resolved"); + + // Live model wins as primary. + assert_eq!(model.value.as_deref(), Some("model-y")); + assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); + // Global model surfaces as secondary, tagged GlobalDefault. + assert_eq!( + model.overridden_value.as_deref(), + Some("global-model"), + "global model must be the override baseline secondary" + ); + assert_eq!( + model.overridden_origin, + Some(ConfigOrigin::GlobalDefault), + "override baseline origin must be GlobalDefault, not PersonaDefault or BuzzExplicit" + ); +} + +// ── Snapshot constructor tests (build_inherited_tiers) ────────────────────── +// +// These test the sanitized snapshot constructor — the command-boundary +// function that builds InheritedConfigTiers from raw persona/global data. + +/// Orphaned persona link: a record whose persona_id references a non-existent +/// persona should produce empty persona tiers (not a panic), and the panel +/// still renders from the record and global tiers. +#[test] +fn orphaned_persona_link_yields_empty_persona_tiers() { + let mut record = agent_record(); + record.persona_id = Some("missing-persona".to_string()); + // No personas in the list — dangling link. + let personas: Vec = vec![]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let tiers = build_inherited_tiers(record.persona_id.as_deref(), None, &personas, &global); + + // Persona tier is empty — the orphan yields no persona inheritance. + assert!(tiers.persona_env.is_empty()); + assert!(tiers.persona_model.is_none()); + assert!(tiers.persona_provider.is_none()); + assert!(tiers.persona_prompt.is_none()); + // Global tiers are unaffected. + assert_eq!(tiers.global_model.as_deref(), Some("global-model")); +} + +/// Reserved key in persona env is stripped by sanitization — it must never +/// reach the reader or the display surface. +#[test] +fn reserved_key_in_inherited_persona_env_is_stripped() { + let mut persona = persona_with_model("model"); + // BUZZ_PRIVATE_KEY is a reserved key — must be stripped. + persona + .env_vars + .insert("BUZZ_PRIVATE_KEY".to_string(), "nsec-secret".to_string()); + // A safe key — must survive. + persona + .env_vars + .insert("GOOSE_MODEL".to_string(), "persona-model".to_string()); + let personas = vec![persona]; + let global = crate::managed_agents::GlobalAgentConfig::default(); + + let tiers = build_inherited_tiers(Some("persona-1"), None, &personas, &global); + + assert!( + !tiers.persona_env.contains_key("BUZZ_PRIVATE_KEY"), + "reserved key must be stripped from persona env tier" + ); + assert!( + tiers.persona_env.contains_key("GOOSE_MODEL"), + "safe key must survive sanitization" + ); +} + +/// `sanitize_inherited_env` strips reserved keys from a definition-env-shaped +/// map. This pins the shared sanitization contract for definition_env — the +/// same function is applied to all three env tiers (persona, global, definition) +/// at the command boundary. +#[test] +fn reserved_key_in_definition_env_shaped_map_is_stripped_by_sanitize() { + // Exercise sanitize_inherited_env directly with a definition-env-shaped map. + let mut raw = std::collections::BTreeMap::new(); + raw.insert("BUZZ_PRIVATE_KEY".to_string(), "nsec-secret".to_string()); + raw.insert("GOOSE_MODEL".to_string(), "harness-model".to_string()); + + let sanitized = sanitize_inherited_env(&raw); + + assert!( + !sanitized.contains_key("BUZZ_PRIVATE_KEY"), + "reserved key must be stripped by sanitize_inherited_env" + ); + assert!( + sanitized.contains_key("GOOSE_MODEL"), + "safe key must survive sanitize_inherited_env" + ); +} + +/// Malformed key in global env is stripped by sanitization — keys must be +/// POSIX-shaped (`[A-Za-z_][A-Za-z0-9_]*`). +#[test] +fn malformed_key_in_inherited_global_env_is_stripped() { + let mut global = crate::managed_agents::GlobalAgentConfig::default(); + // Key with an `=` — would bypass env-var security if passed to spawn. + global + .env_vars + .insert("BAD=KEY".to_string(), "value".to_string()); + // A valid key — must survive. + global + .env_vars + .insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); + + let tiers = build_inherited_tiers(None, None, &[], &global); + + assert!( + !tiers.global_env.contains_key("BAD=KEY"), + "malformed key must be stripped from global env tier" + ); + assert!( + tiers.global_env.contains_key("GOOSE_PROVIDER"), + "valid key must survive sanitization" + ); +} + +// ── get_baked_build_env / is_secret_key tests ────────────────────────── + +/// Build a `BakedEnvEntry` vec from a synthetic map, mirroring what +/// `get_baked_build_env()` does. Used to test masking without relying on +/// compile-time `option_env!` vars (OSS builds have empty `baked_build_env`). +fn baked_env_from_map(map: &[(&str, &str)]) -> Vec { + map.iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| { + let masked = !super::is_safe_to_reveal(k); + BakedEnvEntry { + key: k.to_string(), + value: if masked { + "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}".to_string() + } else { + v.to_string() + }, + masked, + } + }) + .collect() +} + +#[test] +fn baked_env_non_secret_key_shows_real_value() { + let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "databricks_v2")]); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].key, "BUZZ_AGENT_PROVIDER"); + assert_eq!(entries[0].value, "databricks_v2"); + assert!(!entries[0].masked); +} + +#[test] +fn baked_env_api_key_is_masked() { + let entries = baked_env_from_map(&[("ANTHROPIC_API_KEY", "sk-secret")]); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].value, "••••••"); + assert!(entries[0].masked); +} + +#[test] +fn baked_env_token_key_is_masked() { + let entries = baked_env_from_map(&[("GITHUB_TOKEN", "ghp_secret")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); +} + +#[test] +fn baked_env_secret_key_is_masked() { + let entries = baked_env_from_map(&[("MY_DB_SECRET", "s3cr3t")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); +} + +#[test] +fn baked_env_password_key_is_masked() { + let entries = baked_env_from_map(&[("DB_PASSWORD", "hunter2")]); + assert_eq!(entries.len(), 1); + assert!(entries[0].masked); +} + +#[test] +fn baked_env_empty_value_filtered_out() { + let entries = baked_env_from_map(&[("BUZZ_AGENT_PROVIDER", "")]); + assert!(entries.is_empty()); +} + +#[test] +fn baked_env_mixed_keys_correct_masking() { + let entries = baked_env_from_map(&[ + ("BUZZ_AGENT_PROVIDER", "databricks_v2"), + ("BUZZ_AGENT_MODEL", "goose-claude-opus-4-8"), + ("DATABRICKS_HOST", "https://example.com"), + ("DATABRICKS_TOKEN", "dapi-secret"), + ]); + assert_eq!(entries.len(), 4); + + let provider = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_PROVIDER") + .unwrap(); + assert_eq!(provider.value, "databricks_v2"); + assert!(!provider.masked); + + let model = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_MODEL") + .unwrap(); + assert_eq!(model.value, "goose-claude-opus-4-8"); + assert!(!model.masked); + + let host = entries.iter().find(|e| e.key == "DATABRICKS_HOST").unwrap(); + assert_eq!(host.value, "https://example.com"); + assert!(!host.masked); + + let token = entries + .iter() + .find(|e| e.key == "DATABRICKS_TOKEN") + .unwrap(); + assert_eq!(token.value, "••••••"); + assert!(token.masked); +} + +#[test] +fn baked_env_thinking_effort_is_unmasked() { + // BUZZ_AGENT_THINKING_EFFORT is a non-secret enum — must not be masked. + let entries = baked_env_from_map(&[("BUZZ_AGENT_THINKING_EFFORT", "medium")]); + assert_eq!(entries.len(), 1); + let effort = entries + .iter() + .find(|e| e.key == "BUZZ_AGENT_THINKING_EFFORT") + .unwrap(); + assert_eq!(effort.value, "medium"); + assert!(!effort.masked); +} + +#[test] +fn baked_env_allowlist_is_case_insensitive() { + // Known-safe keys — case-insensitive match must allow them. + assert!(super::is_safe_to_reveal("buzz_agent_provider")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_PROVIDER")); + assert!(super::is_safe_to_reveal("buzz_agent_model")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_MODEL")); + assert!(super::is_safe_to_reveal("buzz_agent_thinking_effort")); + assert!(super::is_safe_to_reveal("BUZZ_AGENT_THINKING_EFFORT")); + assert!(super::is_safe_to_reveal("databricks_host")); + assert!(super::is_safe_to_reveal("DATABRICKS_HOST")); + assert!(super::is_safe_to_reveal("databricks_model")); + assert!(super::is_safe_to_reveal("DATABRICKS_MODEL")); + // Keys NOT in the allowlist — masked regardless of naming pattern. + assert!(!super::is_safe_to_reveal("my_api_key")); + assert!(!super::is_safe_to_reveal("GITHUB_TOKEN")); + assert!(!super::is_safe_to_reveal("DB_SECRET")); + assert!(!super::is_safe_to_reveal("DB_PASSWORD")); + // Bare names that old heuristic (contains("_TOKEN") etc.) would have missed. + assert!(!super::is_safe_to_reveal("APIKEY")); + assert!(!super::is_safe_to_reveal("TOKEN")); + assert!(!super::is_safe_to_reveal("SECRET")); + assert!(!super::is_safe_to_reveal("PASSWORD")); + assert!(!super::is_safe_to_reveal("PRIVATE_KEY")); + // Unknown key → masked by default. + assert!(!super::is_safe_to_reveal("SOME_UNKNOWN_KEY")); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1e..c51f325cf3b 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -3,15 +3,17 @@ use crate::managed_agents::types::ManagedAgentRecord; use super::types::*; -/// Build the full config surface for an agent, merging all four tiers. +/// Build the full config surface for an agent, merging all tiers. /// -/// Pre-spawn (no session cache): tiers 2a (env vars / record) and 2b (config files). -/// Post-spawn (session cache present): adds tiers 1a (ACP native) and 1b (ACP configOptions). +/// Inherited values flow through `tiers` — a sanitized snapshot of the +/// persona and global tiers assembled at the command boundary. Each field +/// builder constructs its own candidate list and resolves via +/// `resolve_with_override`. pub(crate) fn read_config_surface( record: &ManagedAgentRecord, runtime_meta: Option<&KnownAcpRuntime>, session_cache: Option<&SessionConfigCache>, - baseline: Option<(&str, ConfigOrigin)>, + tiers: &InheritedConfigTiers, ) -> RuntimeConfigSurface { let is_pre_spawn = session_cache.is_none(); @@ -27,14 +29,7 @@ pub(crate) fn read_config_surface( }) .unwrap_or_else(|| (RuntimeFileConfig::default(), false)); - // Tier 2a: record-level values (Buzz-explicit). - let record_model = record.model.clone(); - let record_provider = record - .env_vars - .get(runtime_meta.and_then(|m| m.provider_env_var).unwrap_or("")) - .cloned() - .or_else(|| record.provider.clone()); // structured provider field as fallback - + // Runtime-specific env var keys. let supports_acp_model = runtime_meta.is_some_and(|m| m.supports_acp_model_switching); let model_env_var = runtime_meta.and_then(|m| m.model_env_var); let provider_env_var = runtime_meta.and_then(|m| m.provider_env_var); @@ -48,10 +43,6 @@ pub(crate) fn read_config_surface( let context_limit_env_var = runtime_meta.and_then(|m| m.context_limit_env_var); // Tier 1b: ACP configOptions from session cache. - // For unstable/switchable agents, current_model comes from the `models` - // field. For stable agents that only report model via configOptions - // (category="model", current_value), fall back to find_config_option_value - // so their current model is surfaced in the panel. let acp_model = session_cache.and_then(|c| { c.current_model .clone() @@ -59,61 +50,53 @@ pub(crate) fn read_config_surface( }); let acp_mode = session_cache.and_then(|c| find_config_option_value(c, "mode")); let acp_effort = session_cache.and_then(|c| find_config_option_value(c, "effort")); - let record_effort = thinking_env_var - .and_then(|k| record.env_vars.get(k)) - .cloned(); let model_overridden = session_cache.is_some_and(|c| c.model_overridden); let normalized = NormalizedConfig { - model: Some(apply_runtime_override( - build_model_field( - &record_model, - &file_config.model, - &acp_model, - model_env_var, - supports_acp_model, - is_pre_spawn, - session_cache, - required_fields.contains(&"model"), - ), - acp_model.as_deref(), - baseline, + model: Some(build_model_field( + record, + &file_config.model, + &acp_model, + model_env_var, + supports_acp_model, + is_pre_spawn, + session_cache, + required_fields.contains(&"model"), model_overridden, + tiers, )), provider: build_provider_field( - &record_provider, + record, &file_config.provider, provider_env_var, provider_locked, required_fields.contains(&"provider"), + tiers, ), mode: build_mode_field(&file_config.mode, &acp_mode, is_pre_spawn, session_cache), thinking_effort: build_thinking_field( - &record_effort, + record, &file_config.thinking_effort, &acp_effort, thinking_env_var, is_pre_spawn, session_cache, + tiers, ), max_output_tokens: build_numeric_env_field( max_tokens_env_var, - &record.env_vars, + record, &file_config.max_output_tokens, + tiers, ), context_limit: build_numeric_env_field( context_limit_env_var, - &record.env_vars, + record, &file_config.context_limit, + tiers, ), - system_prompt: build_system_prompt_field( - &record - .system_prompt - .clone() - .or_else(|| record.env_vars.get("BUZZ_ACP_SYSTEM_PROMPT").cloned()), - &file_config.system_prompt, - ), + system_prompt: build_system_prompt_field(record, &file_config.system_prompt, tiers), }; // Advanced fields from config file extras. @@ -130,7 +113,7 @@ pub(crate) fn read_config_surface( }) .collect(); - // Collect the env var keys already covered by normalized fields so we don't double-surface them. + // Collect the env var keys already covered by normalized fields. let normalized_env_keys: Vec<&str> = [ model_env_var, provider_env_var, @@ -144,15 +127,13 @@ pub(crate) fn read_config_surface( .collect(); // Tier 2a: remaining env vars not covered by normalized fields. - // Env var wins over config file for the same key (tier 2a > 2b), so skip - // keys already present in file_config.extra. let mut advanced = advanced; for (k, v) in &record.env_vars { if normalized_env_keys.contains(&k.as_str()) { continue; } if file_config.extra.contains_key(k) { - continue; // config file already surfaced this key + continue; } advanced.push(ConfigField { key: k.clone(), @@ -178,8 +159,6 @@ pub(crate) fn read_config_surface( { ConfigTierStatus::Available } else { - // Post-spawn without native config data is also Pending — it arrives - // asynchronously after the session/new response. ConfigTierStatus::Pending } } else { @@ -226,9 +205,27 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option } } +/// Extract an env-backed candidate value for `env_key` from each tier in +/// spawn precedence: record env > persona env > global env > definition env. +/// Returns `[record, persona, global, definition]` — `None` when key is absent. +fn env_candidates<'a>( + env_key: &str, + record_env: &'a std::collections::BTreeMap, + persona_env: &'a std::collections::BTreeMap, + global_env: &'a std::collections::BTreeMap, + definition_env: &'a std::collections::BTreeMap, +) -> [Option<&'a str>; 4] { + [ + record_env.get(env_key).map(String::as_str), + persona_env.get(env_key).map(String::as_str), + global_env.get(env_key).map(String::as_str), + definition_env.get(env_key).map(String::as_str), + ] +} + #[allow(clippy::too_many_arguments)] fn build_model_field( - record_model: &Option, + record: &ManagedAgentRecord, file_model: &Option, acp_model: &Option, model_env_var: Option<&str>, @@ -236,30 +233,109 @@ fn build_model_field( is_pre_spawn: bool, session_cache: Option<&SessionConfigCache>, is_required: bool, + model_overridden: bool, + tiers: &InheritedConfigTiers, ) -> NormalizedField { - // Precedence: Buzz-explicit > ACP current > config file - let (value, origin) = if let Some(ref m) = record_model { - (Some(m.clone()), ConfigOrigin::BuzzExplicit) - } else if let Some(ref m) = acp_model { - (Some(m.clone()), ConfigOrigin::AcpConfigOption) - } else if let Some(ref m) = file_model { - (Some(m.clone()), ConfigOrigin::ConfigFile) - } else { - // No value from any tier. EnvVar is the sentinel origin for "no value - // resolved" — there is no dedicated None-origin variant. The panel - // renders this as an empty/absent field. - (None, ConfigOrigin::EnvVar) - }; + let [rec_env, pers_env, glob_env, def_env] = model_env_var + .map(|k| { + env_candidates( + k, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ) + }) + .unwrap_or([None, None, None, None]); + + // Structured record model (definition-less only; linked cleared upstream). + let struct_record = record.model.as_deref(); + let struct_persona = tiers.persona_model.as_deref(); + let struct_global = tiers.global_model.as_deref(); + + // Configured candidates in spawn order: record env > persona env > global env > + // definition env > struct record > struct persona > struct global > file. + // The file entry is always last; everything before it is a "configured" candidate + // that gates whether ACP participates as a fallback (see any_configured below). + let configured: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), + (pers_env, ConfigOrigin::PersonaDefault), + (glob_env, ConfigOrigin::GlobalDefault), + (def_env, ConfigOrigin::HarnessDefault), + (struct_record, ConfigOrigin::BuzzExplicit), + (struct_persona, ConfigOrigin::PersonaDefault), + (struct_global, ConfigOrigin::GlobalDefault), + (file_model.as_deref(), ConfigOrigin::ConfigFile), + ]; + // "Configured" = any non-file candidate. The file entry is always last, so + // slicing to len()-1 is equivalent to the old magic `[..6]` and stays correct + // if the array ever grows again. + let any_configured = configured[..configured.len() - 1] + .iter() + .any(|(v, _)| v.is_some()); + + // When model_overridden is true and ACP is present, ACP is the live winner. + // The top configured candidate becomes the secondary (the overridden baseline). + // Equal-value case: ACP == baseline → fall through to normal resolution so + // the field carries the correct baseline origin rather than RuntimeOverride. + if model_overridden { + if let Some(acp) = acp_model.as_deref() { + let baseline = configured.iter().find(|(v, _)| v.is_some()); + match baseline { + Some((Some(baseline_value), _)) if acp == *baseline_value => { + // Equal-value switch: no real divergence. + // Fall through to the normal resolve path below — it will + // return the same value with its true baseline origin, with + // no secondary row. + } + Some((Some(baseline_value), baseline_origin)) => { + return NormalizedField { + value: Some(acp.to_string()), + origin: ConfigOrigin::RuntimeOverride, + write_via: model_write_mechanism( + is_pre_spawn, + supports_acp_model, + session_cache, + model_env_var, + ), + overridden_value: Some(baseline_value.to_string()), + overridden_origin: Some(baseline_origin.clone()), + is_required, + }; + } + _ => { + // No configured baseline — ACP is the only source. + return NormalizedField { + value: Some(acp.to_string()), + origin: ConfigOrigin::RuntimeOverride, + write_via: model_write_mechanism( + is_pre_spawn, + supports_acp_model, + session_cache, + model_env_var, + ), + overridden_value: None, + overridden_origin: None, + is_required, + }; + } + } + } + } - // The secondary expresses ONLY the static record-vs-file precedence: a - // Buzz-explicit model shadowing a config-file model. The live-session - // override (acp vs record/persona) is exclusively `apply_runtime_override`'s - // job, gated on `model_overridden`. Surfacing `acp_model` here would leak an - // override row even when no live switch has been applied. - let (overridden_value, overridden_origin) = if record_model.is_some() && file_model.is_some() { - (file_model.clone(), Some(ConfigOrigin::ConfigFile)) + let (value, origin, overridden_value, overridden_origin) = if !any_configured { + // No configured candidate: ACP participates as AcpConfigOption fallback. + let full: &[(Option<&str>, ConfigOrigin)] = &[ + (acp_model.as_deref(), ConfigOrigin::AcpConfigOption), + (file_model.as_deref(), ConfigOrigin::ConfigFile), + ]; + resolve_with_override(full).unwrap_or((None, ConfigOrigin::EnvVar, None, None)) } else { - (None, None) + // ACP excluded: a configured value is pending and wins over live ACP. + match resolve_with_override(configured) { + Some(r) => r, + None => (None, ConfigOrigin::EnvVar, None, None), + } }; let write_via = model_write_mechanism( @@ -280,7 +356,6 @@ fn build_model_field( } /// Resolve how the model field is written back to the runtime. -/// Prefer ACP `set_config_option`/`set_model` post-spawn, else env-var respawn. fn model_write_mechanism( is_pre_spawn: bool, supports_acp_model: bool, @@ -301,67 +376,13 @@ fn model_write_mechanism( } } -/// Re-key the model field as a live runtime override when the harness signals -/// that a `SwitchModel` control signal set the model (Phase 3c). -/// -/// The override-active signal is `model_overridden` from the -/// `session_config_captured` payload — NOT `acp_model != persona_model`, which -/// would false-positive when a persona model is edited mid-life while the -/// session is stale on the old model. -/// -/// `baseline` is the value the live model overrides, paired with its true -/// origin — `(persona_model, PersonaDefault)` for a persona-linked agent, or -/// `(record_model, BuzzExplicit)` for a genuine-explicit agent that live- -/// switched. It is `Some` only when there is such a baseline to override -/// against; otherwise the field passes through unchanged. Carrying the origin -/// in the pair (rather than hardcoding it) lets the secondary be tagged by its -/// real source instead of always reading `PersonaDefault`. -/// -/// The `acp == baseline_value` short-circuit keeps a live pick of the baseline -/// model itself from rendering a no-op "override of X with X". It yields a -/// CLEAN single-value field — `overridden_value`/`overridden_origin` cleared — -/// rather than passing `base` through, because `build_model_field` already -/// populates `base`'s secondary with an `AcpConfigOption` row for the -/// record-model-plus-live-session case; returning `base` would leak that -/// spurious row. The override preserves the base field's write mechanism — only -/// the displayed value, origin, and secondary change. -fn apply_runtime_override( - base: NormalizedField, - acp_model: Option<&str>, - baseline: Option<(&str, ConfigOrigin)>, - model_overridden: bool, -) -> NormalizedField { - if !model_overridden { - return base; - } - let (Some(acp), Some((baseline_value, baseline_origin))) = (acp_model, baseline) else { - return base; - }; - if acp == baseline_value { - // Live pick equals the baseline — no real divergence. Strip any - // secondary `build_model_field` may have produced so the panel shows a - // single clean value rather than "X overridden by X". - return NormalizedField { - overridden_value: None, - overridden_origin: None, - ..base - }; - } - NormalizedField { - value: Some(acp.to_string()), - origin: ConfigOrigin::RuntimeOverride, - overridden_value: Some(baseline_value.to_string()), - overridden_origin: Some(baseline_origin), - ..base - } -} - fn build_provider_field( - record_provider: &Option, + record: &ManagedAgentRecord, file_provider: &Option, provider_env_var: Option<&str>, provider_locked: bool, is_required: bool, + tiers: &InheritedConfigTiers, ) -> Option { if provider_locked { return Some(NormalizedField { @@ -374,15 +395,43 @@ fn build_provider_field( }); } - let tiers: &[(Option<&str>, ConfigOrigin)] = &[ - (record_provider.as_deref(), ConfigOrigin::BuzzExplicit), + let [rec_env, pers_env, glob_env, def_env] = provider_env_var + .map(|k| { + env_candidates( + k, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ) + }) + .unwrap_or([None, None, None, None]); + + let struct_record = record.provider.as_deref(); + + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), + (pers_env, ConfigOrigin::PersonaDefault), + (glob_env, ConfigOrigin::GlobalDefault), + (def_env, ConfigOrigin::HarnessDefault), + (struct_record, ConfigOrigin::BuzzExplicit), + ( + tiers.persona_provider.as_deref(), + ConfigOrigin::PersonaDefault, + ), + ( + tiers.global_provider.as_deref(), + ConfigOrigin::GlobalDefault, + ), (file_provider.as_deref(), ConfigOrigin::ConfigFile), ]; - let (value, origin, overridden_value, overridden_origin) = match resolve_with_override(tiers) { - Some(resolved) => resolved, - None if is_required => (None, ConfigOrigin::EnvVar, None, None), - None => return None, - }; + + let (value, origin, overridden_value, overridden_origin) = + match resolve_with_override(tiers_list) { + Some(resolved) => resolved, + None if is_required => (None, ConfigOrigin::EnvVar, None, None), + None => return None, + }; let write_via = if let Some(env_key) = provider_env_var { ConfigWriteMechanism::RespawnWithEnvVar { @@ -432,20 +481,38 @@ fn build_mode_field( }) } +#[allow(clippy::too_many_arguments)] fn build_thinking_field( - record_effort: &Option, + record: &ManagedAgentRecord, file_effort: &Option, acp_effort: &Option, thinking_env_var: Option<&str>, is_pre_spawn: bool, session_cache: Option<&SessionConfigCache>, + tiers: &InheritedConfigTiers, ) -> Option { - let tiers: &[(Option<&str>, ConfigOrigin)] = &[ - (record_effort.as_deref(), ConfigOrigin::BuzzExplicit), + // Tier ordering: record env > ACP > persona env > global env > definition env > config file. + let [rec_env, pers_env, glob_env, def_env] = thinking_env_var + .map(|k| { + env_candidates( + k, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ) + }) + .unwrap_or([None, None, None, None]); + + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), (acp_effort.as_deref(), ConfigOrigin::AcpConfigOption), + (pers_env, ConfigOrigin::PersonaDefault), + (glob_env, ConfigOrigin::GlobalDefault), + (def_env, ConfigOrigin::HarnessDefault), (file_effort.as_deref(), ConfigOrigin::ConfigFile), ]; - let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers)?; + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; let write_via = if !is_pre_spawn && has_config_option(session_cache, "effort") { ConfigWriteMechanism::AcpSetConfigOption { @@ -469,67 +536,105 @@ fn build_thinking_field( }) } -/// Numeric fields (max_output_tokens, context_limit) — env-var tier wins over -/// config-file tier. When an env var key is given and present in the record's -/// env_vars map the field is BuzzExplicit + RespawnWithEnvVar; otherwise if the -/// config file supplied a value it is ConfigFile + ReadOnly; otherwise None. +/// Numeric fields (max_output_tokens, context_limit). +/// Tier ordering: record env > persona env > global env > config file. fn build_numeric_env_field( env_var: Option<&'static str>, - record_env: &std::collections::BTreeMap, + record: &ManagedAgentRecord, file_value: &Option, + tiers: &InheritedConfigTiers, ) -> Option { - if let Some(key) = env_var { - if let Some(v) = record_env.get(key) { - return Some(NormalizedField { - value: Some(v.clone()), - origin: ConfigOrigin::BuzzExplicit, - write_via: ConfigWriteMechanism::RespawnWithEnvVar { - env_key: key.to_string(), - }, - overridden_value: file_value.clone(), - overridden_origin: file_value.as_ref().map(|_| ConfigOrigin::ConfigFile), - is_required: false, - }); + let [rec_env, pers_env, glob_env, def_env] = env_var + .map(|k| { + env_candidates( + k, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ) + }) + .unwrap_or([None, None, None, None]); + + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), + (pers_env, ConfigOrigin::PersonaDefault), + (glob_env, ConfigOrigin::GlobalDefault), + (def_env, ConfigOrigin::HarnessDefault), + (file_value.as_deref(), ConfigOrigin::ConfigFile), + ]; + + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; + + let write_via = if let Some(key) = env_var { + ConfigWriteMechanism::RespawnWithEnvVar { + env_key: key.to_string(), } - } - file_value.as_ref().map(|v| NormalizedField { - value: Some(v.clone()), - origin: ConfigOrigin::ConfigFile, - write_via: ConfigWriteMechanism::ReadOnly, - overridden_value: None, - overridden_origin: None, + } else { + ConfigWriteMechanism::ReadOnly + }; + + Some(NormalizedField { + value, + origin, + write_via, + overridden_value, + overridden_origin, is_required: false, }) } -/// Record/env prompt wins (BuzzExplicit, respawnable); a config-file prompt it -/// shadows is reported as the overridden secondary. A config-file-only prompt -/// — no record/env value to shadow it — is surfaced directly (read-only) -/// instead of being dropped: a prompt that drives the agent should always be -/// visible somewhere in the panel. +/// System prompt field. +/// +/// Tier ordering per v3 plan: record env > persona env > global env > +/// struct record > struct persona > config file. +/// +/// Env tiers sit above structured per spawn contract: `descriptor.env` is +/// written last (after the structured prompt), so env wins on collision. +/// `GlobalAgentConfig` has no structured system_prompt, so the global tier +/// is env-only. `BUZZ_ACP_SYSTEM_PROMPT` is not reserved and is therefore +/// a real global env tier. fn build_system_prompt_field( - record_prompt: &Option, + record: &ManagedAgentRecord, file_prompt: &Option, + tiers: &InheritedConfigTiers, ) -> Option { - if let Some(v) = record_prompt { - return Some(NormalizedField { - value: Some(v.clone()), - origin: ConfigOrigin::BuzzExplicit, - write_via: ConfigWriteMechanism::RespawnWithEnvVar { - env_key: "BUZZ_ACP_SYSTEM_PROMPT".to_string(), - }, - overridden_value: file_prompt.clone(), - overridden_origin: file_prompt.as_ref().map(|_| ConfigOrigin::ConfigFile), - is_required: false, - }); - } + const PROMPT_ENV_KEY: &str = "BUZZ_ACP_SYSTEM_PROMPT"; + + let [rec_env, pers_env, glob_env, def_env] = env_candidates( + PROMPT_ENV_KEY, + &record.env_vars, + &tiers.persona_env, + &tiers.global_env, + &tiers.definition_env, + ); + + // Structured record prompt (definition-less only; linked cleared upstream). + let struct_record = record.system_prompt.as_deref(); + + let tiers_list: &[(Option<&str>, ConfigOrigin)] = &[ + (rec_env, ConfigOrigin::BuzzExplicit), // record env + (pers_env, ConfigOrigin::PersonaDefault), // persona env + (glob_env, ConfigOrigin::GlobalDefault), // global env + (def_env, ConfigOrigin::HarnessDefault), // definition env + (struct_record, ConfigOrigin::BuzzExplicit), // struct record + ( + tiers.persona_prompt.as_deref(), + ConfigOrigin::PersonaDefault, + ), // struct persona + (file_prompt.as_deref(), ConfigOrigin::ConfigFile), + ]; - file_prompt.as_ref().map(|v| NormalizedField { - value: Some(v.clone()), - origin: ConfigOrigin::ConfigFile, - write_via: ConfigWriteMechanism::ReadOnly, - overridden_value: None, - overridden_origin: None, + let (value, origin, overridden_value, overridden_origin) = resolve_with_override(tiers_list)?; + + Some(NormalizedField { + value, + origin, + write_via: ConfigWriteMechanism::RespawnWithEnvVar { + env_key: PROMPT_ENV_KEY.to_string(), + }, + overridden_value, + overridden_origin, is_required: false, }) } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4ee4ec79c32..153db1bbd8a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -120,11 +120,53 @@ fn test_record() -> ManagedAgentRecord { } } +/// Default empty tiers: no persona or global inheritance. +fn no_tiers() -> InheritedConfigTiers { + InheritedConfigTiers::default() +} + +/// Tiers with only global env set (for AC-1 style tests). +fn global_env_tiers(key: &str, val: &str) -> InheritedConfigTiers { + let mut global_env = BTreeMap::new(); + global_env.insert(key.to_string(), val.to_string()); + InheritedConfigTiers { + global_env, + ..Default::default() + } +} + +/// Tiers with only persona env set. +fn persona_env_tiers(key: &str, val: &str) -> InheritedConfigTiers { + let mut persona_env = BTreeMap::new(); + persona_env.insert(key.to_string(), val.to_string()); + InheritedConfigTiers { + persona_env, + ..Default::default() + } +} + +/// Tiers with both persona and global env set for the same key. +fn persona_and_global_env_tiers( + key: &str, + persona_val: &str, + global_val: &str, +) -> InheritedConfigTiers { + let mut persona_env = BTreeMap::new(); + persona_env.insert(key.to_string(), persona_val.to_string()); + let mut global_env = BTreeMap::new(); + global_env.insert(key.to_string(), global_val.to_string()); + InheritedConfigTiers { + persona_env, + global_env, + ..Default::default() + } +} + #[test] fn pre_spawn_surface_reports_pending_acp_tiers() { let record = test_record(); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); assert!(surface.is_pre_spawn); assert_eq!(surface.sources.acp_native, ConfigTierStatus::Pending); @@ -140,7 +182,7 @@ fn surface_reports_mcp_specific_config_path() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(None, || { - read_config_surface(&record, Some(runtime), None, None) + read_config_surface(&record, Some(runtime), None, &no_tiers()) }); let path = surface @@ -159,7 +201,7 @@ fn goose_mcp_config_path_follows_path_root_override() { let record = test_record(); let runtime = test_runtime(); let surface = with_goose_path_root(Some("/tmp/buzz-goose-root"), || { - read_config_surface(&record, Some(runtime), None, None) + read_config_surface(&record, Some(runtime), None, &no_tiers()) }); let expected_path = Path::new("/tmp/buzz-goose-root") @@ -183,7 +225,7 @@ fn claude_surface_uses_mcp_config_path_not_settings_path() { config_file_path: Some("~/.claude/settings.json"), ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); assert!(surface .sources @@ -203,7 +245,7 @@ fn record_model_overrides_file_model() { record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); @@ -216,7 +258,7 @@ fn provider_locked_shows_locked() { provider_locked: true, ..*test_runtime() }; - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let provider = surface.normalized.provider.unwrap(); assert_eq!(provider.value.as_deref(), Some("Anthropic (locked)")); assert_eq!(provider.origin, ConfigOrigin::HarnessConstraint); @@ -242,7 +284,7 @@ fn post_spawn_with_model_config_option_uses_acp() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), None); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); assert!(!surface.is_pre_spawn); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("claude-opus-4")); @@ -266,53 +308,86 @@ fn acp_model_overrides_file_model_with_override_tracking() { captured_at: "".to_string(), }; - let surface = read_config_surface(&record, Some(runtime), Some(&cache), None); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &no_tiers()); let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("acp-model")); assert_eq!(model.origin, ConfigOrigin::AcpConfigOption); - // The goose config file might have a model too — since we can't control - // the actual file in a unit test, just verify the override fields are populated - // when we manually construct the scenario via build_model_field. } -// ── Persona resolution integration tests ──────────────────────────── +// ── Persona / global tier integration tests ────────────────────────────────── // -// These simulate the call-site pattern in agent_config.rs: -// 1. Inject persona-resolved values into the record (as if absent) -// 2. Call read_config_surface (reader tags them BuzzExplicit) -// 3. Re-tag injected fields to PersonaDefault -// -// This exercises the same logic path as get_agent_config_surface without -// requiring Tauri AppHandle/State infrastructure. +// These exercise the tiers-based candidate resolution for model, provider, and +// system_prompt via `InheritedConfigTiers` — replacing the old inject+retag +// simulation tests. #[test] -fn persona_model_injection_produces_persona_default_origin() { - let mut record = test_record(); - // Simulate: record has no model, persona provides one. - // The call-site injects it before calling the reader. - record.model = Some("persona-model".to_string()); +fn persona_model_tier_produces_persona_default_origin() { + let record = test_record(); // no record.model let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + persona_model: Some("persona-model".to_string()), + ..Default::default() + }; - let mut surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &tiers); - // Reader sees injected model as BuzzExplicit. - let model = surface.normalized.model.as_ref().unwrap(); + let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("persona-model")); - assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} - // Call-site re-tags (simulating had_model == false). - if let Some(ref mut field) = surface.normalized.model { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::PersonaDefault; - } - } +#[test] +fn global_model_tier_produces_global_default_origin() { + let record = test_record(); + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + global_model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); let model = surface.normalized.model.unwrap(); - assert_eq!(model.value.as_deref(), Some("persona-model")); - assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); +} + +#[test] +fn persona_provider_tier_produces_persona_default_origin() { + let record = test_record(); + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + persona_provider: Some("anthropic".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let provider = surface.normalized.provider.unwrap(); + assert_eq!(provider.value.as_deref(), Some("anthropic")); + assert_eq!(provider.origin, ConfigOrigin::PersonaDefault); +} + +#[test] +fn persona_prompt_tier_produces_persona_default_origin() { + let record = test_record(); + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + persona_prompt: Some("You are a helpful assistant.".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!( + prompt.value.as_deref(), + Some("You are a helpful assistant.") + ); + assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault); } -// ── Runtime override (Phase 3c) ────────────────────────────────────── +// ── Runtime override (model_overridden gate) ────────────────────────────────── // // A live ModelPicker switch is signalled by `model_overridden: true` in the // `session_config_captured` payload. The reader keys the override-active @@ -321,7 +396,7 @@ fn persona_model_injection_produces_persona_default_origin() { #[test] fn runtime_override_wins_display_when_model_overridden_is_true() { - // Persona-linked agent (record.model == None); persona == "persona-model". + // Persona-linked agent (record.model == None); persona model via tiers. // A live switch pushed "live-model" to the session and set model_overridden. let record = test_record(); let runtime = test_runtime(); @@ -334,29 +409,27 @@ fn runtime_override_wins_display_when_model_overridden_is_true() { goose_native_config: None, captured_at: "".to_string(), }; + let tiers = InheritedConfigTiers { + persona_model: Some("persona-model".to_string()), + ..Default::default() + }; - let surface = read_config_surface( - &record, - Some(runtime), - Some(&cache), - Some(("persona-model", ConfigOrigin::PersonaDefault)), - ); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); let model = surface.normalized.model.unwrap(); // Override wins the display value with a runtime-override origin. assert_eq!(model.value.as_deref(), Some("live-model")); assert_eq!(model.origin, ConfigOrigin::RuntimeOverride); - // Persona is the secondary value (not struck through — the UI keys off - // the RuntimeOverride origin to suppress strikethrough). + // Persona is the secondary value. assert_eq!(model.overridden_value.as_deref(), Some("persona-model")); assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); } #[test] fn no_runtime_override_when_model_overridden_is_false() { - // At spawn the session's current_model == persona model (BUZZ_ACP_MODEL - // is set to the persona model) and model_overridden is false. No override; - // the field falls through to normal precedence. + // At spawn the session's current_model == persona model and + // model_overridden is false. No override; field falls through to normal + // precedence. let record = test_record(); let runtime = test_runtime(); let cache = SessionConfigCache { @@ -368,17 +441,15 @@ fn no_runtime_override_when_model_overridden_is_false() { goose_native_config: None, captured_at: "".to_string(), }; + let tiers = InheritedConfigTiers { + persona_model: Some("persona-model".to_string()), + ..Default::default() + }; - let surface = read_config_surface( - &record, - Some(runtime), - Some(&cache), - Some(("persona-model", ConfigOrigin::PersonaDefault)), - ); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); let model = surface.normalized.model.unwrap(); - // model_overridden is false => the override branch is not taken: origin - // is the normal precedence result, never RuntimeOverride. + // model_overridden is false => the override branch is not taken. assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); assert_eq!(model.value.as_deref(), Some("persona-model")); assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); @@ -402,106 +473,51 @@ fn no_false_positive_override_when_persona_edited_mid_life() { goose_native_config: None, captured_at: "".to_string(), }; + let tiers = InheritedConfigTiers { + persona_model: Some("new-persona-model".to_string()), + ..Default::default() + }; - let surface = read_config_surface( - &record, - Some(runtime), - Some(&cache), - Some(("new-persona-model", ConfigOrigin::PersonaDefault)), - ); + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); let model = surface.normalized.model.unwrap(); // model_overridden is false => no RuntimeOverride, even though // acp_model != persona_model. The old divergence-based signal would - // have false-positived here. The persona is never surfaced as the - // overridden secondary (that marker is exclusive to a real override). + // have false-positived here. assert_ne!(model.origin, ConfigOrigin::RuntimeOverride); assert_ne!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); } -#[test] -fn persona_provider_injection_produces_persona_default_origin() { - let mut record = test_record(); - // Simulate: record has no provider env var, persona provides one. - // The call-site injects it as GOOSE_PROVIDER before calling the reader. - record - .env_vars - .insert("GOOSE_PROVIDER".to_string(), "anthropic".to_string()); - let runtime = test_runtime(); - - let mut surface = read_config_surface(&record, Some(runtime), None, None); - - // Reader sees injected provider as BuzzExplicit. - let provider = surface.normalized.provider.as_ref().unwrap(); - assert_eq!(provider.value.as_deref(), Some("anthropic")); - assert_eq!(provider.origin, ConfigOrigin::BuzzExplicit); - - // Call-site re-tags (simulating had_provider == false). - if let Some(ref mut field) = surface.normalized.provider { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::PersonaDefault; - } - } - - let provider = surface.normalized.provider.unwrap(); - assert_eq!(provider.value.as_deref(), Some("anthropic")); - assert_eq!(provider.origin, ConfigOrigin::PersonaDefault); -} - -#[test] -fn persona_system_prompt_injection_produces_persona_default_origin() { - let mut record = test_record(); - // Simulate: record has no system_prompt, persona provides one via env var. - // The call-site injects it as BUZZ_ACP_SYSTEM_PROMPT before calling the reader. - record.env_vars.insert( - "BUZZ_ACP_SYSTEM_PROMPT".to_string(), - "You are a helpful assistant.".to_string(), - ); - let runtime = test_runtime(); - - let mut surface = read_config_surface(&record, Some(runtime), None, None); - - // Reader sees injected prompt as BuzzExplicit. - let prompt = surface.normalized.system_prompt.as_ref().unwrap(); - assert_eq!( - prompt.value.as_deref(), - Some("You are a helpful assistant.") - ); - assert_eq!(prompt.origin, ConfigOrigin::BuzzExplicit); - - // Call-site re-tags (simulating had_prompt == false). - if let Some(ref mut field) = surface.normalized.system_prompt { - if field.origin == ConfigOrigin::BuzzExplicit { - field.origin = ConfigOrigin::PersonaDefault; - } - } - - let prompt = surface.normalized.system_prompt.unwrap(); - assert_eq!( - prompt.value.as_deref(), - Some("You are a helpful assistant.") - ); - assert_eq!(prompt.origin, ConfigOrigin::PersonaDefault); -} +// ── system_prompt builder unit tests ───────────────────────────────────────── #[test] -fn config_file_only_system_prompt_surfaces_as_read_only_config_file_field() { - // Record/env has no prompt; the config file does. It must NOT be - // dropped — it should surface with ConfigFile origin, read-only. - let field = build_system_prompt_field(&None, &Some("File-driven prompt.".to_string())).unwrap(); +fn config_file_only_system_prompt_surfaces_as_config_file_origin() { + // Record/env has no prompt; the config file does. Must surface with + // ConfigFile origin. Write mechanism is always RespawnWithEnvVar for + // system_prompt — the UI writes back via BUZZ_ACP_SYSTEM_PROMPT. + let record = test_record(); + let field = build_system_prompt_field( + &record, + &Some("File-driven prompt.".to_string()), + &no_tiers(), + ) + .unwrap(); assert_eq!(field.value.as_deref(), Some("File-driven prompt.")); assert_eq!(field.origin, ConfigOrigin::ConfigFile); - assert!(matches!(field.write_via, ConfigWriteMechanism::ReadOnly)); + assert!(matches!( + field.write_via, + ConfigWriteMechanism::RespawnWithEnvVar { ref env_key } + if env_key == "BUZZ_ACP_SYSTEM_PROMPT" + )); assert!(field.overridden_value.is_none()); } #[test] fn record_system_prompt_shadows_config_file_prompt_as_secondary() { - let field = build_system_prompt_field( - &Some("Record prompt.".to_string()), - &Some("File prompt.".to_string()), - ) - .unwrap(); + let mut record = test_record(); + record.system_prompt = Some("Record prompt.".to_string()); + let field = + build_system_prompt_field(&record, &Some("File prompt.".to_string()), &no_tiers()).unwrap(); assert_eq!(field.value.as_deref(), Some("Record prompt.")); assert_eq!(field.origin, ConfigOrigin::BuzzExplicit); assert_eq!(field.overridden_value.as_deref(), Some("File prompt.")); @@ -510,19 +526,19 @@ fn record_system_prompt_shadows_config_file_prompt_as_secondary() { #[test] fn no_system_prompt_from_any_tier_yields_none() { - assert!(build_system_prompt_field(&None, &None).is_none()); + let record = test_record(); + assert!(build_system_prompt_field(&record, &None, &no_tiers()).is_none()); } #[test] fn explicit_record_model_not_retagged_when_already_present() { let mut record = test_record(); - // Record already has its own model — persona resolution should NOT re-tag. + // Record already has its own model — origin stays BuzzExplicit. record.model = Some("explicit-model".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); - // had_model == true, so no re-tagging occurs. Origin stays BuzzExplicit. let model = surface.normalized.model.unwrap(); assert_eq!(model.value.as_deref(), Some("explicit-model")); assert_eq!(model.origin, ConfigOrigin::BuzzExplicit); @@ -544,7 +560,7 @@ fn extra_env_vars_appear_in_advanced_as_buzz_explicit() { .insert("SPROUT_ACP_MEMORY".to_string(), "mem-value".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -575,21 +591,15 @@ fn extra_env_vars_appear_in_advanced_as_buzz_explicit() { #[test] fn extra_env_var_skipped_when_already_in_file_config_extra() { - // If a key is in both record.env_vars and file_config.extra, the config - // file entry wins (it was already added to advanced). The env var must - // not produce a second entry. - // - // We can't inject into file_config.extra directly in a unit test (it - // comes from disk), so we verify the dedup logic via the normalized-key - // path: GOOSE_THINKING_EFFORT is a normalized key and must not appear - // in advanced even if set in env_vars. + // If a key is normalized, it must not appear in advanced even if set + // in env_vars. let mut record = test_record(); record .env_vars .insert("GOOSE_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = test_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -598,7 +608,7 @@ fn extra_env_var_skipped_when_already_in_file_config_extra() { ); } -// ── buzz-agent normalized env-var field tests ─────────────────────────────── +// ── buzz-agent normalized env-var field tests ───────────────────────────────── // // buzz-agent uses env vars (not a config file) for max_output_tokens and // context_limit. build_numeric_env_field must surface these as BuzzExplicit @@ -649,7 +659,7 @@ fn buzz_agent_max_output_tokens_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let field = surface.normalized.max_output_tokens.unwrap(); assert_eq!(field.value.as_deref(), Some("8192")); @@ -670,7 +680,7 @@ fn buzz_agent_context_limit_from_env_is_buzz_explicit() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let field = surface.normalized.context_limit.unwrap(); assert_eq!(field.value.as_deref(), Some("100000")); @@ -688,7 +698,7 @@ fn buzz_agent_max_tokens_absent_when_no_env_var_or_file() { let record = test_record(); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); assert!( surface.normalized.max_output_tokens.is_none(), @@ -713,7 +723,7 @@ fn buzz_agent_max_tokens_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -734,7 +744,7 @@ fn buzz_agent_thinking_effort_from_env_is_buzz_explicit() { .insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "high".to_string()); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let field = surface.normalized.thinking_effort.unwrap(); assert_eq!(field.value.as_deref(), Some("high")); @@ -755,7 +765,7 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { ); let runtime = buzz_agent_runtime(); - let surface = read_config_surface(&record, Some(runtime), None, None); + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); let advanced_keys: Vec<&str> = surface.advanced.iter().map(|f| f.key.as_str()).collect(); assert!( @@ -764,10 +774,20 @@ fn buzz_agent_thinking_effort_env_var_not_double_surfaced_in_advanced() { ); } +// ── provider builder unit tests ─────────────────────────────────────────────── + #[test] fn missing_required_provider_still_returns_dropdown_field() { - let provider = build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, true) - .expect("required provider field should be surfaced even when empty"); + let record = test_record(); + let provider = build_provider_field( + &record, + &None, + Some("GOOSE_PROVIDER"), + false, + true, + &no_tiers(), + ) + .expect("required provider field should be surfaced even when empty"); assert_eq!(provider.value, None); assert_eq!(provider.origin, ConfigOrigin::EnvVar); @@ -776,5 +796,156 @@ fn missing_required_provider_still_returns_dropdown_field() { #[test] fn missing_optional_provider_stays_hidden() { - assert!(build_provider_field(&None, &None, Some("GOOSE_PROVIDER"), false, false).is_none()); + let record = test_record(); + assert!(build_provider_field( + &record, + &None, + Some("GOOSE_PROVIDER"), + false, + false, + &no_tiers() + ) + .is_none()); +} + +// ── thinking_effort persona/global tier tests (AC-1..5) ────────────────────── +// +// The plan's acceptance criteria for effort tier resolution. +// Tier ordering: record env > ACP > persona env > global env > config file. + +fn buzz_agent_rt() -> &'static KnownAcpRuntime { + crate::managed_agents::discovery::known_acp_runtime_exact("buzz-agent") + .expect("buzz-agent must be in catalog") +} + +/// AC-1: no record effort, global env has effort → GlobalDefault. +/// Real-world case: global-agent-config has BUZZ_AGENT_THINKING_EFFORT=high, +/// per-agent record has no env_vars → effort must surface with GlobalDefault origin. +#[test] +fn global_effort_surfaces_as_global_default_when_record_has_none() { + let record = test_record(); + let runtime = buzz_agent_rt(); + let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from global tier"); + assert_eq!(effort.value.as_deref(), Some("high")); + assert_eq!(effort.origin, ConfigOrigin::GlobalDefault); +} + +/// AC-2: persona env has effort, global also has effort → PersonaDefault wins, shadows global. +#[test] +fn persona_effort_shadows_global_and_tags_persona_default() { + let record = test_record(); + let runtime = buzz_agent_rt(); + let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from persona tier"); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::PersonaDefault); + // global is the overridden baseline + assert_eq!(effort.overridden_value.as_deref(), Some("high")); + assert_eq!(effort.overridden_origin, Some(ConfigOrigin::GlobalDefault)); } + +/// AC-3: record-level effort wins over persona and global, stays BuzzExplicit. +#[test] +fn record_effort_outranks_persona_and_global_keeps_buzz_explicit() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_THINKING_EFFORT".to_string(), + "xhigh".to_string(), + ); + let runtime = buzz_agent_rt(); + let tiers = persona_and_global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium", "high"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from record tier"); + assert_eq!(effort.value.as_deref(), Some("xhigh")); + assert_eq!(effort.origin, ConfigOrigin::BuzzExplicit); +} + +/// AC-4: no effort from any tier → thinking_effort field is absent. +#[test] +fn no_effort_anywhere_yields_no_thinking_effort_field() { + let record = test_record(); + let runtime = buzz_agent_rt(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + + assert!( + surface.normalized.thinking_effort.is_none(), + "thinking_effort must be None when no tier has a value" + ); +} + +/// AC-5 (conflicting-ACP): inherited effort set (global=high) + live ACP effort=low +/// → ACP wins as primary (AcpConfigOption), global is the overridden secondary. +#[test] +fn acp_effort_wins_over_inherited_global_effort_as_secondary() { + let record = test_record(); + let runtime = buzz_agent_rt(); + let cache = SessionConfigCache { + config_options: vec![AcpConfigOptionEntry { + config_id: "effort".to_string(), + category: Some("effort".to_string()), + display_name: Some("Effort".to_string()), + current_value: Some("low".to_string()), + options: vec![], + }], + available_modes: vec![], + available_models: vec![], + current_model: None, + model_overridden: false, + goose_native_config: None, + captured_at: "".to_string(), + }; + let tiers = global_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "high"); + + let surface = read_config_surface(&record, Some(runtime), Some(&cache), &tiers); + + let effort = surface + .normalized + .thinking_effort + .expect("effort must surface from ACP tier"); + // Live ACP value wins. + assert_eq!(effort.value.as_deref(), Some("low")); + assert_eq!(effort.origin, ConfigOrigin::AcpConfigOption); + // Global is surfaced as the overridden baseline. + assert_eq!(effort.overridden_value.as_deref(), Some("high")); + assert_eq!(effort.overridden_origin, Some(ConfigOrigin::GlobalDefault)); +} + +// ── Numerics inheritance tests ──────────────────────────────────────────────── +// +// max_output_tokens and context_limit gain persona/global tiers. + +#[test] +fn numeric_max_tokens_inherits_from_global_env() { + let record = test_record(); + let runtime = buzz_agent_runtime(); + let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let field = surface.normalized.max_output_tokens.unwrap(); + assert_eq!(field.value.as_deref(), Some("16384")); + assert_eq!(field.origin, ConfigOrigin::GlobalDefault); +} + +// ── Extended tests (split file to respect line-count ratchet) ──────────────── +#[path = "reader_tests_ext.rs"] +mod ext; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs new file mode 100644 index 00000000000..8613124f259 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -0,0 +1,258 @@ +//! Additional tests for `config_bridge/reader.rs` — split out to keep +//! `reader_tests.rs` under the 1000-line file-size ratchet. +//! +//! Included as `mod ext` inside `reader_tests.rs`, so `use super::*` gives +//! access to all helpers and types from that module. + +use super::*; + +// ── Numerics inheritance tests ──────────────────────────────────────────────── +// +// max_output_tokens and context_limit gain persona/global tiers. + +#[test] +fn numeric_context_limit_inherits_from_persona_env() { + let record = test_record(); + let runtime = buzz_agent_runtime(); + let tiers = persona_env_tiers("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let field = surface.normalized.context_limit.unwrap(); + assert_eq!(field.value.as_deref(), Some("200000")); + assert_eq!(field.origin, ConfigOrigin::PersonaDefault); +} + +#[test] +fn record_max_tokens_overrides_global_env_with_secondary() { + let mut record = test_record(); + record.env_vars.insert( + "BUZZ_AGENT_MAX_OUTPUT_TOKENS".to_string(), + "8192".to_string(), + ); + let runtime = buzz_agent_runtime(); + let tiers = global_env_tiers("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "16384"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let field = surface.normalized.max_output_tokens.unwrap(); + assert_eq!(field.value.as_deref(), Some("8192")); + assert_eq!(field.origin, ConfigOrigin::BuzzExplicit); + // Global value is the overridden secondary. + assert_eq!(field.overridden_value.as_deref(), Some("16384")); + assert_eq!(field.overridden_origin, Some(ConfigOrigin::GlobalDefault)); +} + +// ── Env-vs-structured collision tests (plan v3, Phase 2) ───────────────────── + +/// Collision test 1: persona structured prompt + global env BUZZ_ACP_SYSTEM_PROMPT +/// → global env wins (env block sits entirely above structured). +#[test] +fn global_env_prompt_wins_over_persona_structured_prompt() { + let record = test_record(); + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + global_env: { + let mut m = BTreeMap::new(); + m.insert( + "BUZZ_ACP_SYSTEM_PROMPT".to_string(), + "global-env-prompt".to_string(), + ); + m + }, + persona_prompt: Some("persona-structured-prompt".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!(prompt.value.as_deref(), Some("global-env-prompt")); + assert_eq!(prompt.origin, ConfigOrigin::GlobalDefault); +} + +/// Collision test 2: structured persona/record model + higher user-env value at +/// the runtime's model key → env value wins. +#[test] +fn persona_env_model_wins_over_persona_structured_model() { + let record = test_record(); // no record.model + let runtime = test_runtime(); // GOOSE_MODEL + let tiers = InheritedConfigTiers { + persona_env: { + let mut m = BTreeMap::new(); + m.insert("GOOSE_MODEL".to_string(), "env-model".to_string()); + m + }, + persona_model: Some("struct-persona-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + // persona env outranks persona struct because env candidates precede struct + assert_eq!(model.value.as_deref(), Some("env-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} + +/// Collision test 3: no env representation → structured persona/record/global +/// fallback and provenance remain intact. +#[test] +fn structured_fallback_intact_when_no_env_representation() { + let record = test_record(); // no record.model, no env vars + let runtime = test_runtime(); + let tiers = InheritedConfigTiers { + persona_model: Some("struct-persona-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("struct-persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} + +// ── Post-sanitization fallthrough test ─────────────────────────────────────── +// +// Sanitization itself happens at the command boundary in `build_inherited_tiers` +// (a value with a NUL byte or an oversize value is dropped from the tier) and is +// pinned by the tests in `commands/agent_config_tests.rs`. The reader only ever +// sees the sanitized result, so what it must guarantee is the downstream half: +// a key stripped from one tier falls through to the next. + +/// A key absent from the global env tier — the shape the reader sees after the +/// command boundary strips an invalid value — falls through to the persona tier. +#[test] +fn post_sanitization_empty_global_env_falls_through_to_persona_tier() { + let record = test_record(); + let runtime = buzz_agent_rt(); + // No global env (stripped); persona provides the valid fallback. + let tiers = persona_env_tiers("BUZZ_AGENT_THINKING_EFFORT", "medium"); + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + // Persona value surfaces instead of the stripped global value. + let effort = surface.normalized.thinking_effort.unwrap(); + assert_eq!(effort.value.as_deref(), Some("medium")); + assert_eq!(effort.origin, ConfigOrigin::PersonaDefault); +} + +// ── Pass-3 prompt collision test ───────────────────────────────────────────── +// +// From Thufir's pass-3 verdict MINOR clarification (promoted to required): +// definition-less record with both structured and env prompt — env wins. + +/// Pass-3 clarification: record.system_prompt = A + record env +/// BUZZ_ACP_SYSTEM_PROMPT = B → B wins as BuzzExplicit. +/// The env block sits above the struct block per v3 candidate-preparation +/// contract; current reader semantics (struct before env) would be wrong. +#[test] +fn record_env_prompt_wins_over_record_struct_prompt_as_buzz_explicit() { + let mut record = test_record(); + record.system_prompt = Some("struct-prompt-A".to_string()); + record.env_vars.insert( + "BUZZ_ACP_SYSTEM_PROMPT".to_string(), + "env-prompt-B".to_string(), + ); + let runtime = test_runtime(); + + let surface = read_config_surface(&record, Some(runtime), None, &no_tiers()); + + let prompt = surface.normalized.system_prompt.unwrap(); + assert_eq!(prompt.value.as_deref(), Some("env-prompt-B")); + assert_eq!(prompt.origin, ConfigOrigin::BuzzExplicit); + // Struct prompt is the secondary. + assert_eq!(prompt.overridden_value.as_deref(), Some("struct-prompt-A")); + assert_eq!(prompt.overridden_origin, Some(ConfigOrigin::BuzzExplicit)); +} + +// ── Definition env tier tests (Layer 2b) ───────────────────────────────────── +// +// The harness definition's `env` block sits below global env and above +// structured values in spawn's precedence (Layer 2b). These tests exercise +// the reader's mapping of that tier to `HarnessDefault` origin. + +/// Definition env wins over structured persona model when no user-env or +/// global-env candidate is present. +#[test] +fn definition_env_beats_structured_persona_model() { + let record = test_record(); // no record.model, no record.env_vars + let runtime = test_runtime(); // model_env_var = "GOOSE_MODEL" + let tiers = InheritedConfigTiers { + definition_env: { + let mut m = BTreeMap::new(); + m.insert("GOOSE_MODEL".to_string(), "harness-model".to_string()); + m + }, + persona_model: Some("persona-struct-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("harness-model")); + assert_eq!(model.origin, ConfigOrigin::HarnessDefault); + // Structured persona model is the overridden secondary. + assert_eq!( + model.overridden_value.as_deref(), + Some("persona-struct-model") + ); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::PersonaDefault)); +} + +/// Global env beats definition env — user-settable tiers always win over the +/// harness author's defaults. +#[test] +fn global_env_beats_definition_env() { + let record = test_record(); + let runtime = test_runtime(); // model_env_var = "GOOSE_MODEL" + let tiers = InheritedConfigTiers { + global_env: { + let mut m = BTreeMap::new(); + m.insert("GOOSE_MODEL".to_string(), "global-model".to_string()); + m + }, + definition_env: { + let mut m = BTreeMap::new(); + m.insert("GOOSE_MODEL".to_string(), "harness-model".to_string()); + m + }, + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); + // Harness default is the overridden secondary. + assert_eq!(model.overridden_value.as_deref(), Some("harness-model")); + assert_eq!(model.overridden_origin, Some(ConfigOrigin::HarnessDefault)); +} + +/// A reserved key in the definition env is stripped by sanitization and must +/// not reach the reader. This test exercises the reader's contract (a key +/// absent from the tier falls through) — sanitization itself is pinned in +/// the `agent_config_tests.rs` constructor tests. +#[test] +fn reserved_key_absent_from_definition_env_falls_through() { + let record = test_record(); + let runtime = test_runtime(); // model_env_var = "GOOSE_MODEL" + // definition_env contains only an unrelated key — the env map here is what + // the command boundary would produce after stripping a reserved key; the + // reader must fall through to the next tier (persona structured model). + let tiers = InheritedConfigTiers { + definition_env: BTreeMap::new(), // stripped — nothing survives + persona_model: Some("persona-struct-model".to_string()), + ..Default::default() + }; + + let surface = read_config_surface(&record, Some(runtime), None, &tiers); + + let model = surface.normalized.model.unwrap(); + // Falls through to persona structured model. + assert_eq!(model.value.as_deref(), Some("persona-struct-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs index 15ccb718e7f..6ca2592538a 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/types.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/types.rs @@ -2,6 +2,41 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; +/// Sanitized inherited config tiers passed to the reader. +/// +/// Built at the `agent_config` command boundary with spawn-equivalent +/// sanitization: reserved, malformed, NUL-value, and oversize-value env keys +/// are stripped (matching `merged_user_env`). Structured fields are +/// normalized: blank/whitespace-only values collapse to `None`. +/// +/// Orphaned persona links (persona_id references a missing persona) produce +/// an empty persona env tier and `None` for all structured persona fields — +/// the panel still renders from record/global. This diverges deliberately from +/// spawn's `OrphanedInstance` refusal, which is a spawn-safety property the +/// display surface does not need to enforce. +#[derive(Debug, Clone, Default)] +pub struct InheritedConfigTiers { + /// Sanitized env vars from the linked persona definition. + pub persona_env: BTreeMap, + /// Sanitized env vars from the global agent config. + pub global_env: BTreeMap, + /// Sanitized env vars from the resolved harness definition (`HarnessDefinition::env`). + /// Sits below global env and above structured values, matching spawn Layer 2b. + /// Empty for preset harnesses (all shipped presets have `env: {}`); only + /// user-authored custom harness JSONs with a non-empty `env` block contribute here. + pub definition_env: BTreeMap, + /// Structured model from the linked persona (non-blank only). + pub persona_model: Option, + /// Structured provider from the linked persona (non-blank only). + pub persona_provider: Option, + /// Structured system_prompt from the linked persona (non-blank only). + pub persona_prompt: Option, + /// Structured model from global config (non-blank only). + pub global_model: Option, + /// Structured provider from global config (non-blank only). + pub global_provider: Option, +} + /// Where a config value came from — determines precedence and UI annotations. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -17,14 +52,12 @@ pub enum ConfigOrigin { /// Read from harness config file on disk (tier 2b, lowest precedence). ConfigFile, /// Value inherited from persona defaults. - /// Populated by the `get_agent_config_surface` call site: persona values are - /// resolved before calling the reader, then the surface is post-processed to - /// re-tag injected fields from `BuzzExplicit` to `PersonaDefault`. + /// Populated when a persona's env var or structured field wins for this + /// field in the reader's candidate resolution. PersonaDefault, /// Value inherited from global agent configuration defaults. /// The lowest user-settable layer — active when neither the agent record nor - /// the linked persona specifies a value. Re-tagged from `BuzzExplicit` by the - /// `resolve_config_surface` call site, analogously to `PersonaDefault`. + /// the linked persona specifies a value. GlobalDefault, /// Live runtime model override applied via the ModelPicker (Phase 3). /// The ACP session's current model diverges from the persona model because @@ -35,6 +68,11 @@ pub enum ConfigOrigin { /// env var. E.g. Claude Code only supports Anthropic as a provider; the /// "locked" display is synthesized by the config bridge, not read from disk. HarnessConstraint, + /// Value comes from a custom harness definition's `env` block. + /// Sits below global env and above structured persona/global values, + /// matching spawn Layer 2b. Only reachable for user-authored custom harness + /// JSONs with a non-empty `env` block; preset harnesses always have empty env. + HarnessDefault, } /// How a config field can be written back to the runtime. diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 37927961ed4..3173126b902 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -22,8 +22,7 @@ pub(crate) use path::should_use_inherited; mod metadata; pub(crate) use metadata::{ - resolve_effective_prompt_model_provider, resolve_session_title, runtime_metadata_env_vars, - SESSION_TITLE_ENV_VAR, + resolve_session_title, runtime_metadata_env_vars, SESSION_TITLE_ENV_VAR, }; mod stop; diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 288ce06b0ad..96ac73e347b 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -57,32 +57,6 @@ pub(crate) fn resolve_session_title(display_name: Option<&str>, name: &str) -> O .find(|value| !value.is_empty()) } -/// Resolve effective prompt/model/provider using definition-authoritative -/// semantics for linked instances. -/// -/// Used by `agent_config.rs` to inject persona defaults into the config surface -/// before running the reader. -pub(crate) fn resolve_effective_prompt_model_provider( - persona_id: Option<&str>, - personas: &[crate::managed_agents::types::AgentDefinition], - record_prompt: Option, - record_model: Option, - record_provider: Option, -) -> (Option, Option, Option) { - match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { - Some(p) => { - fn non_blank(v: Option<&str>) -> Option { - v.filter(|s| !s.trim().is_empty()).map(str::to_owned) - } - let prompt = non_blank(Some(&p.system_prompt)); - let model = non_blank(p.model.as_deref()); - let provider = non_blank(p.provider.as_deref()); - (prompt, model, provider) - } - None => (record_prompt, record_model, record_provider), - } -} - #[cfg(test)] mod tests { use super::resolve_session_title; diff --git a/desktop/src/features/agents/ui/AgentConfigPanel.tsx b/desktop/src/features/agents/ui/AgentConfigPanel.tsx index 3236b4d8085..67c544257ce 100644 --- a/desktop/src/features/agents/ui/AgentConfigPanel.tsx +++ b/desktop/src/features/agents/ui/AgentConfigPanel.tsx @@ -137,6 +137,8 @@ function provenanceSentence( return "From ACP session"; case "globalDefault": return "Inherited from global defaults"; + case "harnessDefault": + return "Inherited from harness definition"; } } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 3f07e9ad9ae..d0e8ee0047f 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -602,7 +602,6 @@ export type AgentModelInfo = { }; // ── Config bridge types ────────────────────────────────────────────────────── - export type ConfigOrigin = | "buzzExplicit" | "acpNativeRead" @@ -612,7 +611,8 @@ export type ConfigOrigin = | "personaDefault" | "globalDefault" | "runtimeOverride" - | "harnessConstraint"; + | "harnessConstraint" + | "harnessDefault"; export type ConfigWriteMechanism = | { type: "respawnWithEnvVar"; envKey: string } From f810a2f49e213d25119f2aa75b5b577655119b74 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 3 Aug 2026 11:12:09 -0400 Subject: [PATCH 014/163] fix(desktop): make OpenAI key re-enterable after first save in card mint dialog (#4140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes a write-once dead-end in the card mint dialog where a user with an expired OpenAI key had no way to replace it. **Source-aware key status (Rust + TypeScript).** `card_mint_key_status` returns a layer discriminant (`"none" | "global" | "persona" | "agent" | "process"`) instead of a boolean. A pure `resolve_key_layer()` helper in `card.rs` owns the classification logic; `card_mint_key_status` delegates to it, so the production path is under direct test with no duplicate logic. **Mint form always reachable.** The key panel replaces the mint form only for `none` (first-time setup) or when the user explicitly opens the edit panel (`editingKey`). Keys from agent/persona/process layers show an inline provenance row on the mint form with a "Why?" affordance; clicking it shows the read-only redirect in a panel with a Cancel button that returns to the mint form — never a terminal state. **Precise auth-error matching.** The 401 handling in `cardMintStore.ts` matches `startsWith("Card mint failed (HTTP 401 ")` plus the specific `Incorrect API key` text, so avatar-fetch 401 errors pass through unchanged. **Tri-state key status row.** "Using your saved OpenAI key · Update" renders only when `keyLayer === "global"` (confirmed writable key). Query pending or errored hides the row without asserting key existence. **Real tests.** Panel visibility derivations live in `cardMintKeyUtils.ts`, which `AgentCardMintDialog.tsx` imports directly. Tests cover all layers including the mint-reachability invariant (Mint reachable for every resolved layer; only `none` gates setup). - `card.rs` — new `resolve_key_layer()` pure helper; `card_mint_key_status` delegates to it; 999 lines (under the 1000-line ratchet) - `card/tests.rs` — precedence test calls `resolve_key_layer()` directly (no test-local closure); adds process-layer and blank-value cases - `tauriPersonas.ts` — `CardMintKeyLayer` type; updated `cardMintKeyStatus` signature - `cardMintKeyUtils.ts` — `showKeyPanel`, `showReadOnlyRow`, `showCancelButton`, `keyPanelTitle`, and helpers; component imports all of them - `AgentCardMintDialog.tsx` — inline provenance rows for all key sources; key panel only for setup/edit; no unused variables - `cardMintStore.ts` — precise 401 prefix matching - `e2eBridge.ts` — `card_mint_key_status` stub returns `"global"` (not boolean) - Tests: 3959 JS passing, 2089 Rust passing, `tsc --noEmit` clean Related: [block/buzz#4406](https://github.com/block/buzz/pull/4406) --------- Signed-off-by: Will Pfleger Co-authored-by: npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg <9a232143a0336e34814a6a0f4947e11bc50bc641f21572c886fd0ae1f7a4e867@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/personas/card.rs | 43 ++- .../src/commands/personas/card/tests.rs | 75 +++++ .../features/agents/cardMintStore.test.mjs | 49 ++++ desktop/src/features/agents/cardMintStore.ts | 9 + .../agents/ui/AgentCardMintDialog.test.mjs | 257 ++++++++++++++++++ .../agents/ui/AgentCardMintDialog.tsx | 193 +++++++++---- .../features/agents/ui/cardMintKeyUtils.ts | 75 +++++ desktop/src/shared/api/tauriPersonas.ts | 29 +- desktop/src/testing/e2eBridge.ts | 5 +- 9 files changed, 674 insertions(+), 61 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs create mode 100644 desktop/src/features/agents/ui/cardMintKeyUtils.ts diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs index c516db1736b..29a5c35e6a7 100644 --- a/desktop/src-tauri/src/commands/personas/card.rs +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -283,6 +283,35 @@ pub(crate) fn resolve_env_from_layers( process_value.filter(|k| !k.trim().is_empty()) } +/// Pure classification: same four env inputs as `resolve_env_from_layers`, +/// returns which layer supplies `OPENAI_API_KEY` (agent > persona > global > +/// process > none). +pub(crate) fn resolve_key_layer( + global_env: &std::collections::BTreeMap, + persona_env: &std::collections::BTreeMap, + record_env: &std::collections::BTreeMap, + process_value: Option, +) -> &'static str { + let key = "OPENAI_API_KEY"; + let nonempty = |m: &std::collections::BTreeMap| { + m.get(key).is_some_and(|v| !v.trim().is_empty()) + }; + if nonempty(record_env) { + return "agent"; + } + if nonempty(persona_env) { + return "persona"; + } + if nonempty(global_env) { + return "global"; + } + let proc = process_value.as_deref().unwrap_or(""); + if !proc.trim().is_empty() { + return "process"; + } + "none" +} + /// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env /// layering as the key) overrides the default host, supporting endpoints and /// proxies that speak the OpenAI Responses shape with Bearer auth. Azure @@ -450,16 +479,15 @@ pub fn card_mint_save_openai_key( save_global_agent_config(&app, &config) } -/// Report whether an OpenAI key would resolve for a card mint of agent `id`, -/// using exactly the same env layering as `mint_agent_card`. Lets the mint -/// dialog offer inline key setup BEFORE the user commits to a mint, instead -/// of failing after the fact. Never returns the key itself. +/// Report which env layer resolves the OpenAI key for a card mint of agent +/// `id` — same layering as `mint_agent_card`. Delegates to `resolve_key_layer` +/// for the classification; see that helper for the return-value contract. #[tauri::command] pub fn card_mint_key_status( id: String, app: AppHandle, state: State<'_, AppState>, -) -> Result { +) -> Result { let _store_guard = state .managed_agents_store_lock .lock() @@ -478,14 +506,13 @@ pub fn card_mint_key_status( .map(|p| p.env_vars.clone()) .unwrap_or_default(); - Ok(resolve_env_from_layers( - "OPENAI_API_KEY", + Ok(resolve_key_layer( &global.env_vars, &persona_env, &record.env_vars, std::env::var("OPENAI_API_KEY").ok(), ) - .is_some()) + .to_string()) } /// Mint a trading card for the agent identified by `id` (instance pubkey, diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs index ca69c438664..407ab449744 100644 --- a/desktop/src-tauri/src/commands/personas/card/tests.rs +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -71,6 +71,81 @@ fn key_resolution_layering_record_wins() { assert!(resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).is_none()); } +/// Prove that `resolve_key_layer` classifies layers in the same precedence +/// order that `mint_agent_card`/`resolve_env_from_layers` uses, so the dialog +/// update path is only offered when writing global will actually win. +#[test] +fn key_status_layer_matches_mint_resolution_priority() { + let key = "OPENAI_API_KEY"; + let mut global = BTreeMap::new(); + let mut persona = BTreeMap::new(); + let mut record = BTreeMap::new(); + + // No key anywhere → "none" + assert_eq!(resolve_key_layer(&global, &persona, &record, None), "none"); + + // Only global → "global" (the only writable layer) + global.insert(key.to_string(), "sk-global".to_string()); + assert_eq!( + resolve_key_layer(&global, &persona, &record, None), + "global" + ); + // mint resolution also picks global when record and persona are empty + assert_eq!( + resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(), + Some("sk-global") + ); + + // Persona overrides global → status must report "persona", NOT "global" + persona.insert(key.to_string(), "sk-persona".to_string()); + assert_eq!( + resolve_key_layer(&global, &persona, &record, None), + "persona" + ); + // mint would use the persona key + assert_eq!( + resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(), + Some("sk-persona") + ); + // Writing to global would NOT change what mint resolves — status correctly + // returns "persona" so the dialog shows a read-only redirect instead. + let mut global_updated = global.clone(); + global_updated.insert(key.to_string(), "sk-new-global".to_string()); + assert_eq!( + resolve_env_from_layers(key, &global_updated, &persona, &record, None).as_deref(), + Some("sk-persona"), + "writing global must not change resolution when persona key exists" + ); + + // Agent record overrides both → status must report "agent" + record.insert(key.to_string(), "sk-agent".to_string()); + assert_eq!(resolve_key_layer(&global, &persona, &record, None), "agent"); + assert_eq!( + resolve_env_from_layers(key, &global, &persona, &record, None).as_deref(), + Some("sk-agent") + ); + + // Process env is last resort (only when all map layers are empty) + let empty = BTreeMap::new(); + assert_eq!( + resolve_key_layer(&empty, &empty, &empty, Some("sk-process".to_string())), + "process" + ); + + // Blank values are skipped — process wins over a whitespace global + let mut blank_global = BTreeMap::new(); + blank_global.insert(key.to_string(), " ".to_string()); + assert_eq!( + resolve_key_layer( + &blank_global, + &empty, + &empty, + Some("sk-process".to_string()) + ), + "process" + ); +} + #[test] fn key_resolution_skips_blank_values() { let mut record = BTreeMap::new(); diff --git a/desktop/src/features/agents/cardMintStore.test.mjs b/desktop/src/features/agents/cardMintStore.test.mjs index eb9c0f07b07..6c2f4522abd 100644 --- a/desktop/src/features/agents/cardMintStore.test.mjs +++ b/desktop/src/features/agents/cardMintStore.test.mjs @@ -96,6 +96,55 @@ describe("cardMintStore", () => { assert.equal(getCardMintJobs()[0].error, "No OPENAI_API_KEY found."); }); + it("replaces a 401 HTTP error with an actionable update-key message", async () => { + await runCardMintJob(INPUT, () => + Promise.reject( + new Error( + "Card mint failed (HTTP 401 Unauthorized): Incorrect API key provided: sk-proj-***", + ), + ), + ); + const { error } = getCardMintJobs()[0]; + assert.ok( + error?.includes("invalid or expired"), + `expected 'invalid or expired' in: ${error}`, + ); + assert.ok( + error?.includes("Update API key"), + `expected 'Update API key' in: ${error}`, + ); + }); + + it("replaces an 'Incorrect API key' error without an HTTP status code", async () => { + await runCardMintJob(INPUT, () => + Promise.reject(new Error("Incorrect API key provided: sk-proj-***")), + ); + const { error } = getCardMintJobs()[0]; + assert.ok( + error?.includes("invalid or expired"), + `expected 'invalid or expired' in: ${error}`, + ); + }); + + it("does not apply the 401 branch to generic non-auth errors", async () => { + await runCardMintJob(INPUT, () => + Promise.reject(new Error("Connection timeout")), + ); + assert.equal(getCardMintJobs()[0].error, "Connection timeout"); + }); + + it("does not rewrite avatar fetch 401 as an API key error", async () => { + // Avatar fetch failures have a different error prefix — rewriting them + // would send the user down a path that cannot fix the avatar failure. + const avatarError = "Avatar fetch failed: HTTP 401 Unauthorized"; + await runCardMintJob(INPUT, () => Promise.reject(new Error(avatarError))); + assert.equal( + getCardMintJobs()[0].error, + avatarError, + "avatar 401 must pass through unchanged", + ); + }); + it("viewMintedCardJob moves a done job into the viewer and clears the chip", async () => { await runCardMintJob(INPUT, () => Promise.resolve(CARD)); const jobId = getCardMintJobs()[0].jobId; diff --git a/desktop/src/features/agents/cardMintStore.ts b/desktop/src/features/agents/cardMintStore.ts index 0e4746d445c..c38bf393cc8 100644 --- a/desktop/src/features/agents/cardMintStore.ts +++ b/desktop/src/features/agents/cardMintStore.ts @@ -123,6 +123,15 @@ export async function runCardMintJob( // removed between dialog-open and mint. The dialog's key-setup panel is // long gone — surface a plain instruction instead of the wire prefix. message = message.slice(NO_OPENAI_KEY_PREFIX.length).trim(); + } else if ( + message.startsWith("Card mint failed (HTTP 401 ") || + message.includes("Incorrect API key") + ) { + // The saved OpenAI key is invalid or expired. Only match the OpenAI-call + // envelope prefix and the specific Incorrect-API-key message to avoid + // rewriting unrelated 401s (e.g. "Avatar fetch failed: HTTP 401 …"). + message = + 'The OpenAI API key is invalid or expired. Open the mint dialog and use "Update API key" to replace it.'; } updateJob(jobId, { phase: "error", error: message }); toast.error(`Minting ${input.agentName}'s card failed`, { diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs b/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs new file mode 100644 index 00000000000..ff3efaec927 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.test.mjs @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// Tests for the key-panel visibility derivations that AgentCardMintDialog +// imports from cardMintKeyUtils. These tests exercise the exact production +// module — changes to any exported function will cause failures here. + +import { + isReadOnlyLayer, + isWritableLayer, + keyPanelTitle, + showCancelButton, + showKeyPanel, + showKeyStatusRow, + showReadOnlyRow, +} from "./cardMintKeyUtils.ts"; + +describe("cardMintKeyUtils — key panel derivations", () => { + // ── isWritableLayer ──────────────────────────────────────────────────────── + + it("isWritableLayer_none_true", () => { + assert.equal(isWritableLayer("none"), true); + }); + + it("isWritableLayer_global_true", () => { + assert.equal(isWritableLayer("global"), true); + }); + + it("isWritableLayer_agent_false", () => { + assert.equal(isWritableLayer("agent"), false); + }); + + it("isWritableLayer_persona_false", () => { + assert.equal(isWritableLayer("persona"), false); + }); + + it("isWritableLayer_process_false", () => { + assert.equal(isWritableLayer("process"), false); + }); + + it("isWritableLayer_undefined_false", () => { + // Unknown (pending/error) — don't offer a write path + assert.equal(isWritableLayer(undefined), false); + }); + + // ── isReadOnlyLayer ──────────────────────────────────────────────────────── + + it("isReadOnlyLayer_agent_true", () => { + assert.equal(isReadOnlyLayer("agent"), true); + }); + + it("isReadOnlyLayer_persona_true", () => { + assert.equal(isReadOnlyLayer("persona"), true); + }); + + it("isReadOnlyLayer_process_true", () => { + assert.equal(isReadOnlyLayer("process"), true); + }); + + it("isReadOnlyLayer_global_false", () => { + assert.equal(isReadOnlyLayer("global"), false); + }); + + it("isReadOnlyLayer_none_false", () => { + assert.equal(isReadOnlyLayer("none"), false); + }); + + it("isReadOnlyLayer_undefined_false", () => { + assert.equal(isReadOnlyLayer(undefined), false); + }); + + // ── showKeyPanel ─────────────────────────────────────────────────────────── + // Key panel replaces the mint form ONLY for first-time setup (none) or + // user-initiated editing (editingKey). Read-only layers do NOT replace the + // mint form — they show an inline status row instead. + + it("showKeyPanel_none_notEditing_shows", () => { + // First-time user: key not set → show setup panel + assert.equal(showKeyPanel("none", false), true); + }); + + it("showKeyPanel_global_notEditing_hides", () => { + // Normal state: key in global defaults, not editing → show mint form + assert.equal(showKeyPanel("global", false), false); + }); + + it("showKeyPanel_global_editing_shows", () => { + // User clicked Update → show the update panel + assert.equal(showKeyPanel("global", true), true); + }); + + it("showKeyPanel_agent_notEditing_hides", () => { + // Read-only layer: mint form stays visible; inline row shown instead + assert.equal(showKeyPanel("agent", false), false); + }); + + it("showKeyPanel_persona_notEditing_hides", () => { + assert.equal(showKeyPanel("persona", false), false); + }); + + it("showKeyPanel_process_notEditing_hides", () => { + assert.equal(showKeyPanel("process", false), false); + }); + + it("showKeyPanel_agent_editing_shows", () => { + // User clicked Why? on a read-only row → show the redirect panel + assert.equal(showKeyPanel("agent", true), true); + }); + + it("showKeyPanel_persona_editing_shows", () => { + assert.equal(showKeyPanel("persona", true), true); + }); + + it("showKeyPanel_process_editing_shows", () => { + assert.equal(showKeyPanel("process", true), true); + }); + + it("showKeyPanel_undefined_notEditing_hides", () => { + // Query pending/error → show mint form (fail-open, no panel claim) + assert.equal(showKeyPanel(undefined, false), false); + }); + + // ── showCancelButton ─────────────────────────────────────────────────────── + + it("showCancelButton_global_editing_shows", () => { + // Update mode for a global key: Cancel returns to the mint form + assert.equal(showCancelButton("global", true), true); + }); + + it("showCancelButton_none_editing_hides", () => { + // First-time setup: no cancel (no mint form to return to) + assert.equal(showCancelButton("none", true), false); + }); + + it("showCancelButton_global_notEditing_hides", () => { + assert.equal(showCancelButton("global", false), false); + }); + + it("showCancelButton_agent_editing_shows", () => { + // Read-only layer + user clicked Why?: Cancel returns to the mint form + assert.equal(showCancelButton("agent", true), true); + }); + + it("showCancelButton_persona_editing_shows", () => { + assert.equal(showCancelButton("persona", true), true); + }); + + it("showCancelButton_process_editing_shows", () => { + assert.equal(showCancelButton("process", true), true); + }); + + // ── showKeyStatusRow ─────────────────────────────────────────────────────── + + it("showKeyStatusRow_global_notEditing_shows", () => { + // Confirmed writable key: show "Using your saved OpenAI key · Update" + assert.equal(showKeyStatusRow("global", false), true); + }); + + it("showKeyStatusRow_global_editing_hides", () => { + // In update panel: status row is redundant while editing + assert.equal(showKeyStatusRow("global", true), false); + }); + + it("showKeyStatusRow_none_notEditing_hides", () => { + // No key: show setup panel, not status row + assert.equal(showKeyStatusRow("none", false), false); + }); + + it("showKeyStatusRow_agent_notEditing_hides", () => { + // Read-only layer: use showReadOnlyRow instead + assert.equal(showKeyStatusRow("agent", false), false); + }); + + it("showKeyStatusRow_undefined_notEditing_hides", () => { + // Query pending/error: do not assert key existence + assert.equal(showKeyStatusRow(undefined, false), false); + }); + + // ── showReadOnlyRow ──────────────────────────────────────────────────────── + // Inline provenance row on the mint form for keys the dialog cannot update. + + it("showReadOnlyRow_agent_notEditing_shows", () => { + assert.equal(showReadOnlyRow("agent", false), true); + }); + + it("showReadOnlyRow_persona_notEditing_shows", () => { + assert.equal(showReadOnlyRow("persona", false), true); + }); + + it("showReadOnlyRow_process_notEditing_shows", () => { + assert.equal(showReadOnlyRow("process", false), true); + }); + + it("showReadOnlyRow_agent_editing_hides", () => { + // User clicked Why? → redirect panel shown; row hidden + assert.equal(showReadOnlyRow("agent", true), false); + }); + + it("showReadOnlyRow_global_notEditing_hides", () => { + // Global key uses showKeyStatusRow instead + assert.equal(showReadOnlyRow("global", false), false); + }); + + it("showReadOnlyRow_none_hides", () => { + assert.equal(showReadOnlyRow("none", false), false); + }); + + it("showReadOnlyRow_undefined_hides", () => { + assert.equal(showReadOnlyRow(undefined, false), false); + }); + + // ── Mint-reachability invariant ──────────────────────────────────────────── + // The mint form (and Mint button) must be reachable whenever a key resolves. + // showKeyPanel returns true only for setup (none) or user-initiated edit. + + it("mintReachable_global_noEdit", () => { + assert.equal(showKeyPanel("global", false), false); + }); + + it("mintReachable_agent_noEdit", () => { + assert.equal(showKeyPanel("agent", false), false); + }); + + it("mintReachable_persona_noEdit", () => { + assert.equal(showKeyPanel("persona", false), false); + }); + + it("mintReachable_process_noEdit", () => { + assert.equal(showKeyPanel("process", false), false); + }); + + it("mintBlocked_none_noEdit", () => { + // Only when no key is set at all does the panel gate minting + assert.equal(showKeyPanel("none", false), true); + }); + + // ── keyPanelTitle ────────────────────────────────────────────────────────── + + it("keyPanelTitle_none_firstTimeSetup", () => { + assert.equal( + keyPanelTitle("none", false), + "One-time setup: OpenAI API key", + ); + }); + + it("keyPanelTitle_global_editing_update", () => { + assert.equal(keyPanelTitle("global", true), "Update OpenAI API key"); + }); + + it("keyPanelTitle_agent_readOnly", () => { + assert.equal(keyPanelTitle("agent", false), "OpenAI API key"); + }); + + it("keyPanelTitle_persona_readOnly", () => { + assert.equal(keyPanelTitle("persona", true), "OpenAI API key"); + }); +}); diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx index 219de4aefa1..b2bffc00df7 100644 --- a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx @@ -20,6 +20,7 @@ import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfi import { cardMintKeyStatus, cardMintSaveOpenaiKey, + type CardMintKeyLayer, type SnapshotMemoryLevel, } from "@/shared/api/tauriPersonas"; import { Button } from "@/shared/ui/button"; @@ -34,6 +35,14 @@ import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; import { Textarea } from "@/shared/ui/textarea"; import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; +import { + isReadOnlyLayer, + keyPanelTitle, + showCancelButton, + showKeyPanel, + showKeyStatusRow, + showReadOnlyRow, +} from "./cardMintKeyUtils"; const OPENAI_KEYS_URL = "https://platform.openai.com/api-keys"; @@ -121,6 +130,7 @@ export function AgentCardMintDialog({ const [memoryLevel, setMemoryLevel] = React.useState("none"); const [keyDraft, setKeyDraft] = React.useState(""); + const [editingKey, setEditingKey] = React.useState(false); const queryClient = useQueryClient(); @@ -130,14 +140,16 @@ export function AgentCardMintDialog({ // (owner, agent) pair, so the plaintext warning would be false there. const showMemoryWarning = memoryLevel !== "none" && !effectiveLock; - // Whether a key already resolves through the agent's env layering. While - // unknown (loading/error) we show the normal mint form — the mint itself - // still fails cleanly if no key exists. + // Whether a key already resolves through the agent's env layering, and from + // which layer. While unknown (loading/error) we treat as if no verified key + // exists — mint still works fail-open, but we don't assert a key is present. const keyStatusQuery = useQuery({ queryKey: ["cardMintKeyStatus", agentId], queryFn: () => cardMintKeyStatus(agentId), }); - const needsKey = keyStatusQuery.data === false; + const keyLayer: CardMintKeyLayer | undefined = keyStatusQuery.data; + // True when the key resolves from a layer this dialog cannot update. + const keyIsReadOnly = isReadOnlyLayer(keyLayer); // Save the pasted key into the global Agent Defaults env — the same single // source of truth every agent inherits. Narrow Rust seam: validated @@ -146,13 +158,19 @@ export function AgentCardMintDialog({ const saveKeyMutation = useMutation({ mutationFn: (key: string) => cardMintSaveOpenaiKey(key), onSuccess: () => { - queryClient.setQueryData(["cardMintKeyStatus", agentId], true); + // The key now lives in global defaults — update the cached layer so the + // status row shows correctly without waiting for a refetch. + queryClient.setQueryData( + ["cardMintKeyStatus", agentId], + "global", + ); // The Agent Defaults editor caches the whole config — refetch it so a // later-opened settings view shows the key we just wrote. void queryClient.invalidateQueries({ queryKey: globalAgentConfigQueryKey, }); setKeyDraft(""); + setEditingKey(false); toast.success( "API key saved to your agent defaults. Running agents pick it up on their next restart.", ); @@ -188,7 +206,7 @@ export function AgentCardMintDialog({ - {needsKey ? ( + {showKeyPanel(keyLayer, editingKey) ? (
- One-time setup: OpenAI API key + {keyPanelTitle(keyLayer, editingKey)} -

- Minting a card costs money — it generates the art and card text - through the OpenAI API with your key (typically well under a - dollar per mint, billed by OpenAI). The key is saved to your - agent defaults, so you only do this once. -

- - setKeyDraft(e.target.value)} - placeholder="sk-…" - type="password" - value={keyDraft} - /> + {keyIsReadOnly ? ( + // Key resolves from a layer the dialog cannot write to — show + // a read-only redirect instead of an input that would be + // shadowed by the higher-priority layer. +

+ {keyLayer === "agent" + ? "This agent's OpenAI key is set in its own agent settings — update it there." + : keyLayer === "persona" + ? "This agent's OpenAI key comes from its linked persona settings — update it there." + : "This agent's OpenAI key is set in the process environment — update it in your shell or launch config."} +

+ ) : ( + <> +

+ Minting a card costs money — it generates the art and card + text through the OpenAI API with your key (typically well + under a dollar per mint, billed by OpenAI). The key is saved + as OPENAI_API_KEY in your + agent defaults env — that's the row to update in Settings if + you ever need to change it there. +

+ + setKeyDraft(e.target.value)} + placeholder="sk-…" + type="password" + value={keyDraft} + /> + + )}
-
- +
+ {showCancelButton(keyLayer, editingKey) ? ( + + ) : null} + {!keyIsReadOnly ? ( + + ) : null}
) : ( @@ -324,6 +379,50 @@ export function AgentCardMintDialog({ onCheckedChange={setLockCard} /> + {showKeyStatusRow(keyLayer, editingKey) ? ( +
+ + Using your saved OpenAI key + · + +
+ ) : null} + {showReadOnlyRow(keyLayer, editingKey) ? ( +
+ + + {keyLayer === "agent" + ? "OpenAI key from agent settings" + : keyLayer === "persona" + ? "OpenAI key from persona settings" + : "OpenAI key from environment"} + + · + +
+ ) : null}

{ - return invokeTauri("card_mint_key_status", { id }); +export async function cardMintKeyStatus(id: string): Promise { + return invokeTauri("card_mint_key_status", { id }); } /** diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index f17faa218b0..037274176ec 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11852,8 +11852,9 @@ export function maybeInstallE2eTauriMocks() { // command was invoked via `__BUZZ_E2E_COMMANDS__`, not the dialog. return true; case "card_mint_key_status": - // Cards: pretend a key is configured so the mint form renders. - return true; + // Cards: pretend a key is configured in global defaults so the mint + // form renders and the key-status row is shown. + return "global"; case "list_agent_cards": // Cards archive starts empty in E2E; specs exercising the gallery // can extend this with a seeded config knob when needed. From 5e0efb0bb95182f588390b55cc5affa09114c87e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 3 Aug 2026 11:12:34 -0400 Subject: [PATCH 015/163] fix(desktop): disambiguate provider API key labels and annotate mint key (#4406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two different credentials were presented under the same name throughout the app. The top-level credential field for non-Anthropic providers (OpenAI, OpenAI-compatible, OpenRouter) was labeled "OpenAI API Key" via a hardcoded binary ternary repeated in three dialogs. The card-minting key (`OPENAI_API_KEY`) and the runtime credential (`OPENAI_COMPAT_API_KEY`) have independent endpoint namespaces and consumers (`OPENAI_COMPAT_BASE_URL`/`OPENAI_COMPAT_API_KEY` for runtime, `OPENAI_BASE_URL`/`OPENAI_API_KEY` for minting) and must remain separate — either may require a different credential. This PR makes them impossible to confuse in the UI. ## Changes **Provider-accurate labels from the credential table.** `PROVIDER_CREDENTIAL_CONFIG` entries now carry an `apiKeyLabel` paired with `secretEnvVar` as a discriminated union (both present or neither — a future provider cannot ship a secret field with no label). `getProviderApiKeyLabel(providerId)` is the single source of truth. The three hardcoded ternaries in `AgentConfigFields`, `AgentInstanceEditDialog`, and `AgentDefinitionDialog` are replaced by this helper. Labels: `openai` → "OpenAI Runtime API Key", `openai-compat` → "OpenAI-compatible Runtime API Key", `openrouter` → "OpenRouter API Key" (was incorrectly "OpenAI API Key"), `anthropic` → "Anthropic API Key" (unchanged). **Field names its backing env var.** `PersonaProviderApiKeyField` renders the env var name as a monospace hint beneath the label with `aria-describedby` wiring. All three call sites pass their `secretEnvVar`. A user who sees `OPENAI_API_KEY` in the mint dialog can now confirm at a glance that the credential field shows `OPENAI_COMPAT_API_KEY` — a different key. **Signpost visible at the decision point.** `CARD_MINT_KEY_ANNOTATIONS` is exported from `agentConfigOptions.tsx` (single source) and passed as `keyAnnotations` to all three generic env editors: both `EnvVarsEditor` branches in Agent Defaults, `EditAgentAdvancedFields`, and `PersonaAdvancedFields`. `CardMintKeyCue` — a new small component — renders an always-visible muted cue beneath the Advanced toggle when `OPENAI_API_KEY` is present in global env (Advanced is collapsed by default, so the per-row annotation is invisible until the cue guides the user to open it). **Model discovery error copy.** The `OPENAI_COMPAT_API_KEY required` message now reads "Enter an OpenAI runtime API key (OPENAI_COMPAT_API_KEY) to load OpenAI models." — naming the env var explicitly so it cannot be confused with the mint key. ## Tests - `getProviderApiKeyLabel` helper: pinned correct label per provider including the new distinct labels for `openai` and `openai-compat` - `PersonaProviderApiKeyField` render: semantic label present; env-var hint rendered when `envVarName` provided; `aria-describedby` wired to hint id; hint and describedby absent when prop omitted - `EnvVarsEditor` render: annotation appears exactly once on the matching row; absent for non-matching rows - `personaModelDiscoveryStatus`: pinned new copy naming `OPENAI_COMPAT_API_KEY` explicitly - Playwright: stale `"OpenAI API Key"` selectors updated; new `card-mint-key-cue-visible-and-annotation-in-advanced` test covers Will's exact path (databricks_v2 global provider + saved `OPENAI_API_KEY` → cue visible before opening Advanced → annotation present after opening) ## File sizes (post-format) | File | Lines | |------|-------| | `AgentConfigFields.tsx` | 994 (≤ 996) | | `AgentInstanceEditDialog.tsx` | 1228 (≤ 1228) | | `AgentDefinitionDialog.tsx` | 1045 (≤ 1047) | Related: [#4140](https://github.com/block/buzz/pull/4140) --------- Signed-off-by: Will Pfleger Co-authored-by: npub1ng3jzsaqxdhrfq22dg85j3lpr0zsh3jp7g2h9jyxl59wraayapnsu6kvfg <9a232143a0336e34814a6a0f4947e11bc50bc641f21572c886fd0ae1f7a4e867@buzz.block.builderlab.xyz> --- .../features/agents/ui/AgentConfigFields.tsx | 22 ++- .../agents/ui/AgentDefinitionDialog.tsx | 8 +- .../agents/ui/AgentInstanceEditDialog.tsx | 12 +- .../src/features/agents/ui/CardMintKeyCue.tsx | 29 +++ .../agents/ui/EditAgentAdvancedFields.tsx | 2 + .../features/agents/ui/EnvVarsEditor.test.mjs | 94 ++++++++++ .../src/features/agents/ui/EnvVarsEditor.tsx | 25 +++ .../agents/ui/PersonaAdvancedFields.tsx | 2 + .../ui/PersonaProviderApiKeyField.test.mjs | 170 ++++++++++++++++++ .../agents/ui/PersonaProviderApiKeyField.tsx | 20 ++- .../agents/ui/agentConfigOptions.test.mjs | 48 +++++ .../features/agents/ui/agentConfigOptions.tsx | 62 +++++-- .../ui/personaModelDiscoveryStatus.test.mjs | 3 +- .../agents/ui/personaModelDiscoveryStatus.ts | 3 +- .../global-agent-config-screenshots.spec.ts | 40 +++++ desktop/tests/e2e/persona-env-vars.spec.ts | 4 +- 16 files changed, 505 insertions(+), 39 deletions(-) create mode 100644 desktop/src/features/agents/ui/CardMintKeyCue.tsx create mode 100644 desktop/src/features/agents/ui/PersonaProviderApiKeyField.test.mjs diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index 1bd8af89761..ce3d2522037 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -32,9 +32,11 @@ import { import { AUTO_PROVIDER_DROPDOWN_VALUE, BLOCK_BUILD_HIDDEN_PROVIDER_IDS, + CARD_MINT_KEY_ANNOTATIONS, CUSTOM_PROVIDER_DROPDOWN_VALUE, getPersonaProviderOptions, getProviderApiKeyEnvVar, + getProviderApiKeyLabel, runtimeSupportsLlmProviderSelection, } from "@/features/agents/ui/agentConfigOptions"; import { @@ -54,6 +56,7 @@ import { } from "@/features/agents/ui/buzzAgentModelTuningFields"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; +import { CardMintKeyCue } from "./CardMintKeyCue"; import { getGlobalAgentCredentialState } from "./globalAgentCredentialState"; export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { @@ -74,7 +77,6 @@ const PROGRESSIVE_FIELDS_TRANSITION = { duration: 0.22, ease: [0.23, 1, 0.32, 1], } as const; - type AgentConfigDisclosure = | "full" | "onboarding-essential" @@ -85,13 +87,9 @@ type AgentConfigDisclosure = // - auto-select a valid model when the provider changes // - keep the model select usable during discovery // - preserve credential env vars across provider switches (the abandoned -// provider's key stays in env_vars — visible/deletable under Advanced — -// so flipping back never loses a typed key; spawned agents may therefore -// see credentials for providers they don't use) +// provider's key stays in env_vars — visible/deletable under Advanced) // - require a provider before model/effort are editable (no saveable -// invalid state — design principle #4). Note: legacy configs saved with -// a model but no provider are cleared by the pre-existing orphan-model -// effect on next edit — deliberate data healing, documented in PR. +// invalid state — design principle #4) const autoSelectModelOnProviderChange = true; const disableModelSelectDuringDiscovery = false; const preserveCredentialEnvVarsOnProviderChange = true; @@ -747,6 +745,7 @@ export function AgentConfigFields({

onConfigChange({ ...config, @@ -869,6 +864,7 @@ export function AgentConfigFields({ {showAdvancedFields ? (
+
); })} @@ -596,6 +613,14 @@ export function EnvVarsEditor({

); })()} + {row.key.length > 0 && keyAnnotations?.[row.key] ? ( +

+ {keyAnnotations[row.key]} +

+ ) : null}
); })} diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index 99cf3e97a82..01485dd9eb6 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -6,6 +6,7 @@ import type { PersonaBehaviorDraft } from "./personaBehaviorDraft"; import { isBuzzAgentRuntime } from "./buzzAgentConfig"; import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; import { + CARD_MINT_KEY_ANNOTATIONS, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, PERSONA_LABEL_OPTIONAL_CLASS, @@ -142,6 +143,7 @@ export function PersonaAdvancedFields({ disabled={disabled} fileSatisfiedKeys={fileSatisfiedEnvKeys} hiddenKeys={hiddenEnvKeys} + keyAnnotations={CARD_MINT_KEY_ANNOTATIONS} onChange={onEnvVarsChange} requiredKeys={requiredEnvKeys} value={envVars} diff --git a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.test.mjs b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.test.mjs new file mode 100644 index 00000000000..62a6ff416c9 --- /dev/null +++ b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.test.mjs @@ -0,0 +1,170 @@ +/** + * Behavioral tests for PersonaProviderApiKeyField. + * + * Tests the rendering invariants that matter for the disambiguation story: + * - semantic label is present in the rendered output + * - envVarName hint is rendered when the prop is present + * - hint id is wired to the input via aria-describedby + * - hint is absent when envVarName is omitted + * - two simultaneous instances produce unique IDs (no duplicate-ID collision + * in the nested AgentInstanceEditDialog + AgentDefaultsDialog path) + */ + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { PersonaProviderApiKeyField } from "./PersonaProviderApiKeyField.tsx"; + +function makeProps(overrides = {}) { + return { + disabled: false, + isInherited: false, + inheritedLabel: "Set in global defaults", + isRequired: false, + label: "OpenAI Runtime API Key", + onValueChange: () => {}, + value: "", + ...overrides, + }; +} + +/** Extract the value of the first attribute matching `name="..."` in html. */ +function extractAttr(html, attrName) { + const re = new RegExp(`${attrName}="([^"]+)"`); + const m = re.exec(html); + return m ? m[1] : null; +} + +/** Extract ALL values of an attribute from html, in document order. */ +function extractAllAttrs(html, attrName) { + const re = new RegExp(`${attrName}="([^"]+)"`, "g"); + return Array.from(html.matchAll(re), (m) => m[1]); +} + +test("PersonaProviderApiKeyField_renders_semantic_label", () => { + const html = renderToStaticMarkup( + React.createElement(PersonaProviderApiKeyField, makeProps()), + ); + assert.ok( + html.includes("OpenAI Runtime API Key"), + "semantic label must appear in rendered output", + ); +}); + +test("PersonaProviderApiKeyField_renders_env_var_hint_when_envVarName_present", () => { + const html = renderToStaticMarkup( + React.createElement( + PersonaProviderApiKeyField, + makeProps({ envVarName: "OPENAI_COMPAT_API_KEY" }), + ), + ); + assert.ok( + html.includes("OPENAI_COMPAT_API_KEY"), + "env-var hint must appear when envVarName is provided", + ); +}); + +test("PersonaProviderApiKeyField_wires_hint_id_via_aria_describedby", () => { + const html = renderToStaticMarkup( + React.createElement( + PersonaProviderApiKeyField, + makeProps({ envVarName: "OPENAI_COMPAT_API_KEY" }), + ), + ); + // Extract the dynamically-generated hint id from the rendered paragraph. + const hintId = extractAttr(html, "id"); + assert.ok(hintId, "hint paragraph must have an id"); + assert.ok( + hintId.startsWith("persona-provider-api-key-hint-"), + `hint id must follow the expected prefix, got: ${hintId}`, + ); + // The input's aria-describedby must point at the same id. + const describedBy = extractAttr(html, "aria-describedby"); + assert.equal( + describedBy, + hintId, + "input aria-describedby must reference the hint's id", + ); +}); + +test("PersonaProviderApiKeyField_omits_hint_when_envVarName_absent", () => { + const html = renderToStaticMarkup( + React.createElement(PersonaProviderApiKeyField, makeProps()), + ); + assert.ok( + !html.includes("aria-describedby"), + "no aria-describedby when envVarName is omitted", + ); + assert.ok( + !html.includes("persona-provider-api-key-hint"), + "hint id must not appear when envVarName is omitted", + ); +}); + +test("PersonaProviderApiKeyField_two_instances_have_unique_ids_and_each_aria_describedby_resolves_to_own_hint", () => { + // Render BOTH instances in a single renderToStaticMarkup call — this + // mirrors the real nested-dialog DOM where AgentInstanceEditDialog's + // credential field and the nested AgentDefaultsDialog's field are + // simultaneously mounted under the same React root. A shared root is what + // makes React.useId() guarantee uniqueness; two separate renderToStaticMarkup + // calls each reset the counter and would produce the same ID. + const combined = renderToStaticMarkup( + React.createElement( + React.Fragment, + null, + React.createElement( + PersonaProviderApiKeyField, + makeProps({ + label: "Anthropic API Key", + envVarName: "ANTHROPIC_API_KEY", + }), + ), + React.createElement( + PersonaProviderApiKeyField, + makeProps({ + label: "OpenAI Runtime API Key", + envVarName: "OPENAI_COMPAT_API_KEY", + }), + ), + ), + ); + + // Two hint paragraph ids must be present and distinct. + const allHintIds = extractAllAttrs(combined, "id").filter((id) => + id.startsWith("persona-provider-api-key-hint-"), + ); + assert.equal(allHintIds.length, 2, "exactly two hint ids must be present"); + const [hintIdA, hintIdB] = allHintIds; + assert.notEqual(hintIdA, hintIdB, "two instances must not share a hint id"); + + // Each input's aria-describedby must match its own hint id (same order). + const allDescribedBy = extractAllAttrs(combined, "aria-describedby"); + assert.equal( + allDescribedBy.length, + 2, + "exactly two aria-describedby attributes must be present", + ); + assert.equal( + allDescribedBy[0], + hintIdA, + "instance A: aria-describedby must reference instance A's own hint", + ); + assert.equal( + allDescribedBy[1], + hintIdB, + "instance B: aria-describedby must reference instance B's own hint", + ); + + // Confirm each instance names its own env var in the rendered output. + assert.ok( + combined.includes("ANTHROPIC_API_KEY"), + "combined output must name ANTHROPIC_API_KEY", + ); + assert.ok( + combined.includes("OPENAI_COMPAT_API_KEY"), + "combined output must name OPENAI_COMPAT_API_KEY", + ); +}); diff --git a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx index 17f9e2e826c..2be1f1c28d8 100644 --- a/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx +++ b/desktop/src/features/agents/ui/PersonaProviderApiKeyField.tsx @@ -25,6 +25,7 @@ import { */ export function PersonaProviderApiKeyField({ disabled, + envVarName, isInherited, inheritedLabel, isRequired, @@ -33,6 +34,13 @@ export function PersonaProviderApiKeyField({ value, }: { disabled: boolean; + /** + * The backing environment variable name, e.g. `OPENAI_COMPAT_API_KEY`. + * Rendered as a monospace hint beneath the label so users can distinguish + * this field from other keys with similar names (e.g. `OPENAI_API_KEY`). + * When present, the input's `aria-describedby` points at the hint element. + */ + envVarName?: string; /** True when the key is satisfied by an inherited layer. */ isInherited: boolean; /** Human-readable source of the inherited value. */ @@ -46,13 +54,22 @@ export function PersonaProviderApiKeyField({ value: string; }) { const [showValue, setShowValue] = React.useState(false); - const inputId = "persona-provider-api-key"; + const uid = React.useId(); + const inputId = `persona-provider-api-key-${uid}`; + const hintId = envVarName + ? `persona-provider-api-key-hint-${uid}` + : undefined; return (
{label} + {envVarName ? ( +

+ {envVarName} +

+ ) : null}
{ + assert.equal(getProviderApiKeyLabel("anthropic"), "Anthropic API Key"); +}); + +test("getProviderApiKeyLabel_openai_returns_openai_runtime_label", () => { + assert.equal(getProviderApiKeyLabel("openai"), "OpenAI Runtime API Key"); +}); + +test("getProviderApiKeyLabel_openai_compat_returns_distinct_label", () => { + // openai and openai-compat must have distinct labels — both use + // OPENAI_COMPAT_API_KEY but carry different semantic identities. + assert.equal( + getProviderApiKeyLabel("openai-compat"), + "OpenAI-compatible Runtime API Key", + ); +}); + +test("getProviderApiKeyLabel_openrouter_returns_openrouter_label", () => { + // Key fix: OpenRouter was mislabeled "OpenAI API Key" before this change. + assert.equal(getProviderApiKeyLabel("openrouter"), "OpenRouter API Key"); +}); + +test("getProviderApiKeyLabel_databricks_returns_null", () => { + // Databricks uses OAuth PKCE — no typed-secret label. + assert.equal(getProviderApiKeyLabel("databricks"), null); +}); + +test("getProviderApiKeyLabel_databricks_v2_returns_null", () => { + assert.equal(getProviderApiKeyLabel("databricks_v2"), null); +}); + +test("getProviderApiKeyLabel_unknown_provider_returns_null", () => { + assert.equal(getProviderApiKeyLabel("some-unknown-provider"), null); +}); + +test("getProviderApiKeyLabel_provider_id_trimmed_and_lowercased", () => { + // Mirrors getProviderApiKeyEnvVar normalisation behaviour. + assert.equal(getProviderApiKeyLabel(" Anthropic "), "Anthropic API Key"); +}); diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index d51c970f297..38865ca1486 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -63,19 +63,30 @@ export type PersonaDropdownOption = { * * `requiredEnvKeys`: keys that must be present in the agent's effective env for * the provider to work (surfaced as amber required rows in EnvVarsEditor). - * `secretEnvVar`: the one env key that holds a user-typed secret (API key). - * Only set for providers where the credential is a plaintext secret the user - * pastes in. Cleared automatically when the user switches away from the - * provider. Databricks uses OAuth PKCE (no typed secret), so it has no - * secretEnvVar. + * `secretEnvVar` + `apiKeyLabel`: paired — either both are present or neither + * is. `secretEnvVar` is the env key holding the user-typed secret; clearing + * it when the user switches providers ensures no orphaned credentials remain. + * Databricks uses OAuth PKCE (no typed secret), so it carries neither field. + * `apiKeyLabel` is the human-readable label shown in the credential field; + * derived by `getProviderApiKeyLabel` — single source of truth for all UI + * surfaces so they never drift. * * Mirrors the Rust `readiness::buzz_agent_requirements` / * `readiness::goose_requirements` logic — keep in sync. */ -export type ProviderCredentialConfig = { - requiredEnvKeys: readonly string[]; - secretEnvVar?: string; -}; +export type ProviderCredentialConfig = + | { + requiredEnvKeys: readonly string[]; + secretEnvVar?: undefined; + apiKeyLabel?: undefined; + } + | { + requiredEnvKeys: readonly string[]; + /** The env key holding the user-typed API secret. */ + secretEnvVar: string; + /** Display label for the credential input field, e.g. "Anthropic API Key". */ + apiKeyLabel: string; + }; /** * Unified provider credential config table. Single source of truth for both @@ -87,20 +98,23 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< anthropic: { requiredEnvKeys: ["ANTHROPIC_API_KEY"], secretEnvVar: "ANTHROPIC_API_KEY", + apiKeyLabel: "Anthropic API Key", }, openai: { requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"], secretEnvVar: "OPENAI_COMPAT_API_KEY", + apiKeyLabel: "OpenAI Runtime API Key", }, "openai-compat": { requiredEnvKeys: ["OPENAI_COMPAT_API_KEY"], secretEnvVar: "OPENAI_COMPAT_API_KEY", + apiKeyLabel: "OpenAI-compatible Runtime API Key", }, databricks: { // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. requiredEnvKeys: ["DATABRICKS_HOST"], - // No secretEnvVar: DATABRICKS_HOST is a URL, not a secret credential, and - // is not cleared on provider switch (unlike API keys). + // No secretEnvVar / apiKeyLabel: DATABRICKS_HOST is a URL, not a secret + // credential, and is not cleared on provider switch (unlike API keys). }, databricks_v2: { // DATABRICKS_TOKEN is NOT required — OAuth PKCE is the normal path. @@ -113,6 +127,7 @@ const PROVIDER_CREDENTIAL_CONFIG: Partial< openrouter: { requiredEnvKeys: ["OPENROUTER_API_KEY"], secretEnvVar: "OPENROUTER_API_KEY", + apiKeyLabel: "OpenRouter API Key", }, }; @@ -402,6 +417,31 @@ export function getProviderApiKeyEnvVar(providerId: string): string | null { ); } +/** + * Returns the display label for the provider's API key field, if any. + * Derived from PROVIDER_CREDENTIAL_CONFIG.apiKeyLabel — single source of truth + * for all credential field labels so every surface stays in sync. + * + * Returns null when the provider has no typed-secret credential (e.g., + * Databricks, which uses OAuth PKCE). + */ +export function getProviderApiKeyLabel(providerId: string): string | null { + return ( + PROVIDER_CREDENTIAL_CONFIG[providerId.trim().toLowerCase()]?.apiKeyLabel ?? + null + ); +} + +/** + * Muted contextual hint for the `OPENAI_API_KEY` row in env editors. + * Pass as `keyAnnotations` to every `EnvVarsEditor` that may surface this key + * (Agent Defaults, agent edit dialog, persona definition dialog). Exported + * so the constant is defined once and never duplicated across surfaces. + */ +export const CARD_MINT_KEY_ANNOTATIONS: Readonly> = { + OPENAI_API_KEY: "Used for minting agent trading cards", +}; + export function shouldClearKnownModelForSelectionScope({ model, provider, diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs index 23d79aaf7fb..548c9ccc0a2 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs @@ -21,7 +21,8 @@ test("model discovery status names missing OpenAI-compatible credentials", () => ); assert.equal(status?.tone, "warning"); - assert.match(status?.message ?? "", /OpenAI API key/); + assert.match(status?.message ?? "", /OpenAI runtime API key/); + assert.match(status?.message ?? "", /OPENAI_COMPAT_API_KEY/); assert.match(status?.message ?? "", /OpenAI models/); }); diff --git a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts index c6991fdd015..b943f6455cd 100644 --- a/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts +++ b/desktop/src/features/agents/ui/personaModelDiscoveryStatus.ts @@ -110,7 +110,8 @@ export function formatModelDiscoveryErrorStatus( if (message.includes("OPENAI_COMPAT_API_KEY required")) { return { - message: "Enter an OpenAI API key to load OpenAI models.", + message: + "Enter an OpenAI runtime API key (OPENAI_COMPAT_API_KEY) to load OpenAI models.", tone: "warning", }; } diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 7ec1daa2366..4c72b72f755 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -980,4 +980,44 @@ test.describe("global agent config screenshots", () => { path: `${SHOTS}/11-edit-runtime-less-provider-required-save-blocked.png`, }); }); + + // Will's exact stuck path: databricks_v2 global provider + saved global + // OPENAI_API_KEY. The cue must be visible without opening Advanced; once + // Advanced is opened the annotation must appear on the matching row. + test("card-mint-key-cue-visible-and-annotation-in-advanced", async ({ + page, + }) => { + await installMockBridge(page, { + globalAgentConfig: { + provider: "databricks_v2", + model: null, + preferred_runtime: "buzz-agent", + env_vars: { OPENAI_API_KEY: "sk-placeholder" }, + }, + }); + + await openAiDefaultsSettings(page); + + const card = page.getByTestId("settings-global-agent-config"); + + // The cue must be visible without the user opening Advanced. + await expect(card.getByTestId("card-mint-key-cue")).toBeVisible(); + await expect(card.getByTestId("card-mint-key-cue")).toContainText( + "OPENAI_API_KEY", + ); + await expect(card.getByTestId("card-mint-key-cue")).toContainText( + "Advanced → Environment variables", + ); + + // Advanced is collapsed at this point. + const advancedToggle = card.getByTestId("global-agent-advanced-toggle"); + await expect(advancedToggle).toHaveAttribute("aria-expanded", "false"); + + // Open Advanced — the OPENAI_API_KEY row's annotation must be visible. + await advancedToggle.click(); + await expect(advancedToggle).toHaveAttribute("aria-expanded", "true"); + await expect( + card.getByText("Used for minting agent trading cards"), + ).toBeVisible(); + }); }); diff --git a/desktop/tests/e2e/persona-env-vars.spec.ts b/desktop/tests/e2e/persona-env-vars.spec.ts index 1e9b077a82a..60a8e888b2a 100644 --- a/desktop/tests/e2e/persona-env-vars.spec.ts +++ b/desktop/tests/e2e/persona-env-vars.spec.ts @@ -329,7 +329,7 @@ test("persona model options follow the selected LLM provider", async ({ await selectDropdownOption(page, llmProvider, "OpenAI"); const dialog = page.getByRole("dialog"); - await expect(dialog.getByLabel("OpenAI API Key")).toBeVisible(); + await expect(dialog.getByLabel("OpenAI Runtime API Key")).toBeVisible(); await expect( dialog.getByRole("button", { name: "Advanced", exact: true }), ).toHaveAttribute("aria-expanded", "false"); @@ -343,7 +343,7 @@ test("persona model options follow the selected LLM provider", async ({ await selectDropdownOption(page, llmProvider, "Anthropic"); await expect(dialog.getByLabel("Anthropic API Key")).toBeVisible(); - await expect(dialog.getByLabel("OpenAI API Key")).not.toBeVisible(); + await expect(dialog.getByLabel("OpenAI Runtime API Key")).not.toBeVisible(); await expect(model).toBeVisible(); // Switch back to inherited defaults — per-agent provider, credential, and From f865c0054b0a400657126c9321b4d4cb7d9cc746 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:08:29 -0400 Subject: [PATCH 016/163] feat(desktop): show saved Run on settings when editing an agent (#4539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What When editing an agent, show where it runs. The edit dialog previously showed nothing about the backend; the "Where to run" section only existed in the create flow. This adds a read-only **Run on** section to `AgentInstanceEditDialog`: - **Local agents:** "This computer". - **Provider agents (e.g. Kubernetes):** the provider id plus its saved config rows — context, namespace, image, resources, etc. — with labels humanized from the stored keys and rows in provider-schema order (locators first, request/limit pairs adjacent, alphabetical spillover for unknown providers). - Copy states these are the settings **saved at creation** and that the run location can't be changed afterwards (a new agent is required). ## Design decisions (from thread review with @Wren + @Sami) - **No provider probe on edit.** `info` is executable work, and its schema reflects the plugin *today* (including a freshly generated random namespace default) — not what this agent was deployed with. The stored record is the only honest source. - **Saved settings, not effective settings.** Optional fields a record omits (e.g. `service_account`) are defaulted by the provider at deploy time; we render only what was persisted and never synthesize today's defaults. - **Safe rendering of opaque provider config.** Values render as safe scalars only; arrays/objects degrade to a summary row (React throws on object children — a hand-edited record must not crash the dialog). Falsy-but-present values (`0`, `false`) render honestly. Secret-shaped keys are redacted using the same word-split heuristic as the create-time `validate_provider_config` gate — one definition of "looks like a secret". The gate already blocks such keys on every app write path; display-side redaction is screenshot hygiene and covers hand-edited records. - **`backendAgentId` intentionally excluded:** deploy-time runtime state written on start, not saved creation intent. - **Read-only, no form state.** The backend is immutable post-create (`UpdateManagedAgentRequest` has no backend field), so the section renders straight from `agent.backend` with no reset effect. - `ADVANCED_FIELDS_MOTION_TRANSITION` was duplicated in both agent dialogs; hoisted to `agentConfigOptions` (also keeps the edit dialog inside the file-size ratchet). ## Testing - Unit contract for `summarizeRunOn` (9 tests): scalar honesty incl. `0`/`false`, structured-value fallback, secret redaction fail-safe, preferred ordering with spillover, key humanization. - Playwright spec (4 tests, registered in the smoke project): kubernetes agent with the exact eight-key record a real create flow persisted, local agent, blox agent (`workstation_name`), and redacted secret-shaped keys from a hypothetical future provider. - `pnpm typecheck`, `pnpm check`, full `pnpm test` (3937 pass) green at this head. - Live screenshots posted in the originating Buzz thread. --------- Signed-off-by: Tyler <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + .../agents/ui/AgentDefinitionDialog.tsx | 6 +- .../agents/ui/AgentInstanceEditDialog.tsx | 9 +- .../agents/ui/RunOnSummarySection.tsx | 73 ++++++ .../features/agents/ui/agentConfigOptions.tsx | 6 + .../features/agents/ui/runOnSummary.test.mjs | 171 ++++++++++++++ .../src/features/agents/ui/runOnSummary.ts | 156 +++++++++++++ desktop/tests/e2e/edit-agent-run-on.spec.ts | 212 ++++++++++++++++++ 8 files changed, 624 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/agents/ui/RunOnSummarySection.tsx create mode 100644 desktop/src/features/agents/ui/runOnSummary.test.mjs create mode 100644 desktop/src/features/agents/ui/runOnSummary.ts create mode 100644 desktop/tests/e2e/edit-agent-run-on.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 773c2e6bf5e..82147777809 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -126,6 +126,7 @@ export default defineConfig({ "**/agent-provider-dropdowns.spec.ts", "**/agent-lifecycle-feedback.spec.ts", "**/agent-access-warning.spec.ts", + "**/edit-agent-run-on.spec.ts", "**/inbox-live-update.spec.ts", "**/mesh-compute.spec.ts", "**/observer-archive-policy.spec.ts", diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 19f35165ca1..1916b57069d 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -32,6 +32,7 @@ import { personaBehaviorDraftValid, } from "./personaBehaviorDraft"; import { + ADVANCED_FIELDS_MOTION_TRANSITION, AUTO_MODEL_DROPDOWN_VALUE, AUTO_PROVIDER_DROPDOWN_VALUE, BLOCK_BUILD_HIDDEN_PROVIDER_IDS, @@ -118,11 +119,6 @@ export type AgentDefinitionSubmitOptions = { publishCatalogUpdates: boolean; }; -const ADVANCED_FIELDS_MOTION_TRANSITION = { - duration: 0.18, - ease: [0.23, 1, 0.32, 1], -} as const; - export function AgentDefinitionDialog({ open, title, diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 48cde58aa7b..d717d773e88 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -26,6 +26,7 @@ import { Input } from "@/shared/ui/input"; import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents"; import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields"; import { + ADVANCED_FIELDS_MOTION_TRANSITION, AUTO_PROVIDER_DROPDOWN_VALUE, BLOCK_BUILD_HIDDEN_PROVIDER_IDS, CUSTOM_PROVIDER_DROPDOWN_VALUE, @@ -65,6 +66,7 @@ import { AgentCreationPreview } from "./AgentCreationPreview"; import type { EnvVarsValue } from "./EnvVarsEditor"; import { useRequiredCredentialState } from "./useRequiredCredentialState"; import { CreateAgentRespondToField } from "./RespondToField"; +import { RunOnSummarySection } from "./RunOnSummarySection"; import { PersonaDropdownField } from "./PersonaDropdownField"; import { MODEL_DISCOVERY_LOADING_VALUE, @@ -93,11 +95,6 @@ import { usePendingHarnessSelection, } from "./addCustomHarness"; -const ADVANCED_FIELDS_MOTION_TRANSITION = { - duration: 0.18, - ease: [0.23, 1, 0.32, 1], -} as const; - export function AgentInstanceEditDialog({ agent, initialFocus, @@ -949,6 +946,8 @@ export function AgentInstanceEditDialog({ variant="persona" /> + + {/* Provider (runtime) */}
+ ); +} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 38865ca1486..5c515a05073 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -30,6 +30,12 @@ export const PERSONA_FIELD_CONTROL_CLASS = export const PERSONA_LABEL_OPTIONAL_CLASS = "ml-1 text-xs font-normal text-muted-foreground/50"; +/** Shared advanced-fields expand/collapse easing for the agent dialogs. */ +export const ADVANCED_FIELDS_MOTION_TRANSITION = { + duration: 0.18, + ease: [0.23, 1, 0.32, 1], +} as const; + export const AUTO_MODEL_DROPDOWN_VALUE = "__auto_model__"; export const CUSTOM_MODEL_DROPDOWN_VALUE = "__custom_model__"; export const AUTO_PROVIDER_DROPDOWN_VALUE = "__auto_provider__"; diff --git a/desktop/src/features/agents/ui/runOnSummary.test.mjs b/desktop/src/features/agents/ui/runOnSummary.test.mjs new file mode 100644 index 00000000000..137ce5481ad --- /dev/null +++ b/desktop/src/features/agents/ui/runOnSummary.test.mjs @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { humanizeConfigKey, summarizeRunOn } from "./runOnSummary.ts"; + +test("local backend summarizes to the local location with no rows", () => { + assert.deepEqual(summarizeRunOn({ type: "local" }), { location: "local" }); +}); + +test("provider backend carries the provider id", () => { + const summary = summarizeRunOn({ + type: "provider", + id: "kubernetes", + config: {}, + }); + assert.equal(summary.location, "provider"); + assert.equal(summary.providerId, "kubernetes"); + assert.deepEqual(summary.rows, []); +}); + +test("a saved kubernetes config renders labeled scalar rows", () => { + const summary = summarizeRunOn({ + type: "provider", + id: "kubernetes", + config: { + namespace: "buzz-agents-x7k2mp", + image: "ghcr.io/block/buzz-sprig@sha256:17facfc7", + cpu_request: "1", + inactivity_seconds: 7200, + }, + }); + assert.equal(summary.location, "provider"); + const byKey = Object.fromEntries(summary.rows.map((r) => [r.key, r])); + assert.equal(byKey.namespace.label, "Namespace"); + assert.equal(byKey.namespace.value, "buzz-agents-x7k2mp"); + assert.equal(byKey.cpu_request.label, "CPU request"); + assert.equal(byKey.cpu_request.value, "1"); + assert.equal(byKey.inactivity_seconds.label, "Inactivity seconds"); + assert.equal(byKey.inactivity_seconds.value, "7200"); + assert.equal(byKey.image.value, "ghcr.io/block/buzz-sprig@sha256:17facfc7"); + assert.ok(summary.rows.every((r) => r.redacted === false)); +}); + +test("rows follow the provider-schema preferred order, spillover alphabetical", () => { + const summary = summarizeRunOn({ + type: "provider", + id: "kubernetes", + config: { + // Deliberately shuffled persisted order. + memory_limit: "1Gi", + zeta_extra: "z", + namespace: "n", + cpu_limit: "1", + alpha_extra: "a", + image: "i", + inactivity_seconds: 7200, + cpu_request: "1", + context: "c", + memory_request: "1Gi", + }, + }); + assert.deepEqual( + summary.rows.map((r) => r.key), + [ + "context", + "namespace", + "image", + "cpu_request", + "memory_request", + "cpu_limit", + "memory_limit", + "inactivity_seconds", + "alpha_extra", + "zeta_extra", + ], + ); +}); + +test("null, empty-string, boolean, and number values all display honestly", () => { + const summary = summarizeRunOn({ + type: "provider", + id: "p", + config: { a_null: null, b_empty: "", c_flag: true, d_num: 0, e_off: false }, + }); + const values = Object.fromEntries(summary.rows.map((r) => [r.key, r.value])); + assert.equal(values.a_null, "Not set"); + assert.equal(values.b_empty, "Not set"); + assert.equal(values.c_flag, "true"); + // Falsy-but-present values must never read as missing: coerceConfigValues + // emits real numbers/booleans, so `value || fallback` would lie about 0/false. + assert.equal(values.d_num, "0"); + assert.equal(values.e_off, "false"); +}); + +test("every row value is a string — objects must never reach React children", () => { + // React throws on an object child, taking down the whole edit dialog. + // A hand-edited managed-agents.json with nested config is exactly the + // record the create-time scalar gate never saw. + const summary = summarizeRunOn({ + type: "provider", + id: "p", + config: { + resources: { limits: { cpu: "2" } }, + flag: true, + count: 3, + name: "x", + missing: null, + }, + }); + for (const row of summary.rows) { + assert.equal(typeof row.value, "string", `${row.key} is not a string`); + } +}); + +test("arrays and objects are summarized, never serialized", () => { + const summary = summarizeRunOn({ + type: "provider", + id: "p", + config: { + tolerations: [{ key: "gpu" }, { key: "spot" }], + node_selector: { disktype: "ssd" }, + }, + }); + const values = Object.fromEntries(summary.rows.map((r) => [r.key, r.value])); + assert.equal(values.tolerations, "List (2 items)"); + assert.equal(values.node_selector, "Structured value"); + // The nested content must not leak through. + assert.ok(!JSON.stringify(values).includes("ssd")); +}); + +test("secret-shaped keys are redacted, fail-safe for unknown providers", () => { + const summary = summarizeRunOn({ + type: "provider", + id: "future-provider", + config: { + api_token: "abc123", + registry_password: "hunter2", + clientSecret: "s3cr3t", + authHeader: "Bearer xyz", + privateKey: "nsec1...", + namespace: "safe-to-show", + }, + }); + const byKey = Object.fromEntries(summary.rows.map((r) => [r.key, r])); + for (const key of [ + "api_token", + "registry_password", + "clientSecret", + "authHeader", + "privateKey", + ]) { + assert.equal(byKey[key].redacted, true, `${key} must be redacted`); + assert.equal(byKey[key].value, "••••••••"); + } + assert.equal(byKey.namespace.redacted, false); + assert.equal(byKey.namespace.value, "safe-to-show"); + // Raw secret bytes must be absent from the whole summary. + const dump = JSON.stringify(summary); + for (const secret of ["abc123", "hunter2", "s3cr3t", "Bearer xyz", "nsec1"]) { + assert.ok(!dump.includes(secret), `${secret} leaked`); + } +}); + +test("humanizeConfigKey handles snake_case, camelCase, and acronyms", () => { + assert.equal(humanizeConfigKey("cpu_request"), "CPU request"); + assert.equal(humanizeConfigKey("memory_limit"), "Memory limit"); + assert.equal(humanizeConfigKey("serviceAccount"), "Service account"); + assert.equal(humanizeConfigKey("image"), "Image"); + assert.equal(humanizeConfigKey("api_url"), "API URL"); + assert.equal(humanizeConfigKey(""), ""); +}); diff --git a/desktop/src/features/agents/ui/runOnSummary.ts b/desktop/src/features/agents/ui/runOnSummary.ts new file mode 100644 index 00000000000..f37155481ae --- /dev/null +++ b/desktop/src/features/agents/ui/runOnSummary.ts @@ -0,0 +1,156 @@ +import type { ManagedAgentBackend } from "@/shared/api/types"; + +/** + * A single saved provider-config row, ready to render. + * + * `value` is always a display string: scalars are stringified, `null` becomes + * an explicit "Not set", and anything non-scalar (array/object) is summarized + * rather than dumped — a generic "render every value" helper must not become + * a disclosure surface for config shapes we have never seen. + */ +export type RunOnConfigRow = { + key: string; + label: string; + value: string; + /** True when the key looks secret-shaped and the value was replaced. */ + redacted: boolean; +}; + +export type RunOnSummary = + | { location: "local" } + | { location: "provider"; providerId: string; rows: RunOnConfigRow[] }; + +/** + * Words that mark a key's value as unrenderable, checked against the same + * word-split the create-time gate uses (`validate_provider_config`, + * src-tauri managed_agents/backend.rs) so there is one definition of + * "looks like a secret", not two that drift. That gate already rejects + * secret-shaped keys on the only non-test write path to `agent.backend` — + * this display-side redaction is screenshot hygiene, not the missing + * credential fix: a value that is fine in `managed-agents.json` on the + * owner's own disk is not fine in a dialog that gets screenshotted into a + * channel or PR body, and hand-edited records never met the gate at all. + * The first five words mirror the Rust list; the rest are display-only + * extras (redacting more than create rejects fails safe). + */ +const SECRET_WORDS = new Set([ + "secret", + "password", + "token", + "key", + "credential", + "passphrase", + "auth", + "nsec", +]); + +const REDACTED_PLACEHOLDER = "••••••••"; + +/** Acronyms that read wrong in sentence case ("Cpu request"). */ +const LABEL_ACRONYMS = new Set(["cpu", "id", "url", "api"]); + +/** + * Split a config key into lowercase words on `_`, `-`, `.`, and camelCase + * boundaries, keeping acronym runs together ("APIKey" → ["api", "key"]). + * Mirrors `split_config_key` in managed_agents/backend.rs — one split feeds + * both labels and redaction, and word matching (not substring) is what + * keeps "keyboard" from being treated as a key. + */ +function splitConfigKey(key: string): string[] { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .split(/[_\s.-]+/) + .filter((word) => word.length > 0) + .map((word) => word.toLowerCase()); +} + +/** + * Humanize a stored config key: `cpu_request` → "CPU request". Labels come + * from the saved keys, not a live provider probe — probing during edit is + * executable work whose schema (titles, generated defaults) reflects the + * plugin *today*, not what this agent was deployed with. See PR #4411 for + * why dialogs must not re-probe as a side effect. + */ +export function humanizeConfigKey(key: string): string { + const words = splitConfigKey(key); + if (words.length === 0) return key; + return words + .map((word, index) => { + if (LABEL_ACRONYMS.has(word)) return word.toUpperCase(); + if (index === 0) return word.charAt(0).toUpperCase() + word.slice(1); + return word; + }) + .join(" "); +} + +function displayValue(value: unknown): string { + if (value === null || value === undefined) return "Not set"; + if (typeof value === "string") return value.length > 0 ? value : "Not set"; + if (typeof value === "number" || typeof value === "boolean") + return String(value); + // Arrays/objects: summarize, never serialize. A nested structure could + // carry values its own keys would have redacted. + return Array.isArray(value) + ? `List (${value.length} items)` + : "Structured value"; +} + +/** + * Preferred display order, traced from the Kubernetes provider schema + * (crates/buzz-backend-kubernetes/src/config.rs): locate the deployment + * first (context → namespace), then what runs (image), then the + * request/limit pairs kept adjacent, then lifecycle/identity. Keys not + * listed here (other providers, future fields) spill over alphabetically + * after the known ones, so ordering stays deterministic for every record. + */ +const PREFERRED_KEY_ORDER = [ + "context", + "namespace", + "image", + "cpu_request", + "memory_request", + "cpu_limit", + "memory_limit", + "inactivity_seconds", + "service_account", +]; + +function compareKeys(a: string, b: string): number { + const ia = PREFERRED_KEY_ORDER.indexOf(a); + const ib = PREFERRED_KEY_ORDER.indexOf(b); + if (ia !== -1 && ib !== -1) return ia - ib; + if (ia !== -1) return -1; + if (ib !== -1) return 1; + return a.localeCompare(b); +} + +/** + * Project a `ManagedAgentBackend` into renderable rows. These are the + * *saved* settings — the config recorded when the agent was created — not + * the provider's effective settings: optional fields a record omits are + * defaulted by the provider at deploy time, and synthesizing today's plugin + * defaults here could show values that drifted from what actually deployed. + * (`backendAgentId` is deliberately not shown: it is deploy-time runtime + * state written on start, not saved creation intent, and this section's + * contract is the latter.) Rows follow the preferred key order with + * alphabetical spillover, so rendering is deterministic regardless of the + * JSON key order a given record happened to persist. + */ +export function summarizeRunOn(backend: ManagedAgentBackend): RunOnSummary { + if (backend.type === "local") return { location: "local" }; + const rows = Object.entries(backend.config ?? {}) + .sort(([a], [b]) => compareKeys(a, b)) + .map(([key, value]): RunOnConfigRow => { + const redacted = splitConfigKey(key).some((word) => + SECRET_WORDS.has(word), + ); + return { + key, + label: humanizeConfigKey(key), + value: redacted ? REDACTED_PLACEHOLDER : displayValue(value), + redacted, + }; + }); + return { location: "provider", providerId: backend.id, rows }; +} diff --git a/desktop/tests/e2e/edit-agent-run-on.spec.ts b/desktop/tests/e2e/edit-agent-run-on.spec.ts new file mode 100644 index 00000000000..569c735eecb --- /dev/null +++ b/desktop/tests/e2e/edit-agent-run-on.spec.ts @@ -0,0 +1,212 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SHOTS = "test-results/edit-agent-run-on"; + +/** + * The exact persisted shape of a real kubernetes agent created through the + * app ("Loni" in managed-agents.json, traced by Sami in review): eight keys, + * `inactivity_seconds` a JSON number, everything else strings, and the + * optional `service_account` ABSENT — no schema default means the create + * flow never seeds it, so honest fixtures omit it too. + */ +const KUBERNETES_CONFIG = { + context: "docker-desktop", + cpu_limit: "1", + cpu_request: "1", + image: + "ghcr.io/block/buzz-sprig:sha-6530b58@sha256:17facfc7608d8ddb33bc056c9aaba1098f4ef6abe5655702fbfd7584d1f74d76", + inactivity_seconds: 7200, + memory_limit: "1Gi", + memory_request: "1Gi", + namespace: "buzz-agents-gfq7aq", +}; + +async function openEditDialog( + page: import("@playwright/test").Page, + agentName: string, +) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + await page + .getByRole("button", { name: `${agentName} agent profile` }) + .click(); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible(); +} + +test("editing a kubernetes agent shows its saved run-on settings", 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: "kubernetes", + config: KUBERNETES_CONFIG, + }, + }, + ], + }); + await openEditDialog(page, "Remote Helper"); + + const runOn = page.getByTestId("edit-agent-run-on"); + await expect(runOn).toBeVisible(); + await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText( + "kubernetes", + ); + + // Every saved field renders as a labeled row with its stored value. + await expect(runOn.getByTestId("edit-agent-run-on-namespace")).toContainText( + "buzz-agents-gfq7aq", + ); + await expect(runOn.getByTestId("edit-agent-run-on-context")).toContainText( + "docker-desktop", + ); + await expect(runOn.getByTestId("edit-agent-run-on-image")).toContainText( + "ghcr.io/block/buzz-sprig", + ); + await expect( + runOn.getByTestId("edit-agent-run-on-cpu_request"), + ).toContainText("CPU request"); + await expect( + runOn.getByTestId("edit-agent-run-on-inactivity_seconds"), + ).toContainText("7200"); + // Real records omit the optional service_account (no schema default): the + // section must show only what was saved, never synthesize a row for it. + await expect( + runOn.getByTestId("edit-agent-run-on-service_account"), + ).toHaveCount(0); + + // The section explains immutability instead of pretending to be a form. + await expect(runOn).toContainText("can't be changed afterwards"); + + await runOn.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await page + .getByTestId("edit-agent-dialog") + .screenshot({ path: `${SHOTS}/kubernetes-run-on.png` }); +}); + +test("editing a local agent names this computer, with no config rows", async ({ + page, +}) => { + const agent = TEST_IDENTITIES.tyler; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Local Helper", + status: "stopped", + channelNames: ["general"], + respondTo: "owner-only", + backend: { type: "local" }, + }, + ], + }); + await openEditDialog(page, "Local Helper"); + + const runOn = page.getByTestId("edit-agent-run-on"); + await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText( + "This computer", + ); + await expect(runOn.getByTestId("edit-agent-run-on-namespace")).toHaveCount(0); + + await runOn.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await page + .getByTestId("edit-agent-dialog") + .screenshot({ path: `${SHOTS}/local-run-on.png` }); +}); + +test("a blox agent's single saved field renders with a humanized label", async ({ + page, +}) => { + // The other real provider shape on developer machines: blox records + // persist exactly one key, exercising provider-generic humanization. + const agent = TEST_IDENTITIES.bob; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Blox Helper", + status: "running", + channelNames: ["general"], + respondTo: "owner-only", + backend: { + type: "provider", + id: "blox", + config: { workstation_name: "tlongwell-sprout-home" }, + }, + }, + ], + }); + await openEditDialog(page, "Blox Helper"); + + const runOn = page.getByTestId("edit-agent-run-on"); + await expect(runOn.getByTestId("edit-agent-run-on-location")).toHaveText( + "blox", + ); + const row = runOn.getByTestId("edit-agent-run-on-workstation_name"); + await expect(row).toContainText("Workstation name"); + await expect(row).toContainText("tlongwell-sprout-home"); + + await runOn.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await page + .getByTestId("edit-agent-dialog") + .screenshot({ path: `${SHOTS}/blox-run-on.png` }); +}); + +test("secret-shaped keys from an untrusted provider render redacted", async ({ + page, +}) => { + const agent = TEST_IDENTITIES.charlie; + await installMockBridge(page, { + managedAgents: [ + { + pubkey: agent.pubkey, + name: "Future Provider Agent", + status: "running", + channelNames: ["general"], + respondTo: "owner-only", + backend: { + type: "provider", + id: "some-future-provider", + config: { + endpoint: "https://provider.example", + api_token: "tok-do-not-show", + }, + }, + }, + ], + }); + await openEditDialog(page, "Future Provider Agent"); + + const runOn = page.getByTestId("edit-agent-run-on"); + await expect(runOn.getByTestId("edit-agent-run-on-endpoint")).toContainText( + "https://provider.example", + ); + const tokenRow = runOn.getByTestId("edit-agent-run-on-api_token"); + await expect(tokenRow).toContainText("••••••••"); + await expect(tokenRow).not.toContainText("tok-do-not-show"); + // The raw secret must not appear anywhere in the dialog. + await expect(page.getByTestId("edit-agent-dialog")).not.toContainText( + "tok-do-not-show", + ); + + await runOn.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await page + .getByTestId("edit-agent-dialog") + .screenshot({ path: `${SHOTS}/redacted-run-on.png` }); +}); From b0c6d6f744e63ac88a1738f0e995680c163e1d13 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 3 Aug 2026 17:26:06 +0100 Subject: [PATCH 017/163] Add channel activity hover menu (#3935) ## Summary - show relevant unread threads and active agents when hovering a channel - keep channel-level unread emphasis separate from thread activity dots - make activity rows navigate to the thread and remove demo-only data ## Test plan - `just ci` (all stages passed except the final duplicate native check, which ran out of disk after its earlier clippy pass) - `cd desktop && pnpm exec playwright test tests/e2e/channel-activity-popover.spec.ts --project=smoke` --------- Signed-off-by: kenny lopez --- desktop/playwright.config.ts | 1 + desktop/src/app/AppShell.helpers.test.mjs | 45 +- desktop/src/app/AppShell.helpers.ts | 35 + desktop/src/app/AppShell.tsx | 102 +-- desktop/src/app/AppShellContext.tsx | 41 +- .../app/useChannelActivityProjection.test.mjs | 30 + .../src/app/useChannelActivityProjection.ts | 145 ++++ .../channels/forcedUnreadStore.test.mjs | 55 ++ .../features/channels/forcedUnreadStore.ts | 137 ++- .../features/channels/ui/ChannelScreen.tsx | 22 +- .../ui/useChannelOpenReadState.test.mjs | 25 + .../channels/ui/useChannelOpenReadState.ts | 45 + .../channels/ui/useChannelUnreadState.ts | 27 +- .../features/channels/unreadChannelCounts.ts | 13 + .../channels/unreadReadMarker.test.mjs | 25 + .../channels/useChannelPaneHandlers.ts | 14 +- .../features/channels/useUnreadChannels.ts | 140 +-- .../communities/communityUnreadObserver.ts | 5 +- desktop/src/features/home/ui/HomeView.tsx | 12 + .../home/useHomeInboxReadState.test.mjs | 42 + .../features/home/useHomeInboxReadState.ts | 65 +- .../lib/useActiveWorkingChannelsById.ts | 14 +- .../sidebar/ui/ChannelActivityPopover.tsx | 448 ++++++++++ .../sidebar/ui/ChannelContextMenu.tsx | 32 +- .../features/sidebar/ui/SidebarSection.tsx | 54 +- .../src/shared/styles/globals/scrollbars.css | 21 + desktop/tests/e2e/badge.spec.ts | 85 +- .../e2e/channel-activity-popover.spec.ts | 795 ++++++++++++++++++ desktop/tests/e2e/channel-mute.spec.ts | 10 +- desktop/tests/e2e/thread-unread.spec.ts | 21 +- desktop/tests/e2e/unread-pill.spec.ts | 9 +- 31 files changed, 2304 insertions(+), 211 deletions(-) create mode 100644 desktop/src/app/useChannelActivityProjection.test.mjs create mode 100644 desktop/src/app/useChannelActivityProjection.ts create mode 100644 desktop/src/features/channels/forcedUnreadStore.test.mjs create mode 100644 desktop/src/features/channels/ui/useChannelOpenReadState.test.mjs create mode 100644 desktop/src/features/channels/ui/useChannelOpenReadState.ts create mode 100644 desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx create mode 100644 desktop/tests/e2e/channel-activity-popover.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 82147777809..796fdede1eb 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ "**/channel-mute.spec.ts", "**/channel-star.spec.ts", "**/channel-controls.spec.ts", + "**/channel-activity-popover.spec.ts", "**/active-turn-resilience.spec.ts", "**/profile-active-turn.spec.ts", "**/config-bridge-screenshots.spec.ts", diff --git a/desktop/src/app/AppShell.helpers.test.mjs b/desktop/src/app/AppShell.helpers.test.mjs index 73505d17984..fa062329231 100644 --- a/desktop/src/app/AppShell.helpers.test.mjs +++ b/desktop/src/app/AppShell.helpers.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { shouldBounceForChannelNotification } from "./AppShell.helpers.ts"; +import { + markAllReadSources, + shouldBounceForChannelNotification, +} from "./AppShell.helpers.ts"; test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => { assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true); @@ -27,3 +30,43 @@ test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => { true, ); }); + +test("markAllReadSources clears Inbox overrides and active thread activity", () => { + const calls = []; + + markAllReadSources({ + activeChannelId: "active-channel", + channelActivityItems: [ + { channelId: "another-channel", createdAt: 100 }, + { channelId: "active-channel", createdAt: 200 }, + { channelId: "active-channel", createdAt: 300 }, + ], + unreadFeedItemIds: new Set(["first-inbox-item", "second-inbox-item"]), + undoUnreadFeedItem: (itemId) => calls.push(`inbox:${itemId}`), + markAllChannelReadMarkers: () => calls.push("channels"), + markActiveChannelRead: (channelId, createdAt) => + calls.push(`active:${channelId}:${createdAt}`), + }); + + assert.deepEqual(calls, [ + "inbox:first-inbox-item", + "inbox:second-inbox-item", + "channels", + "active:active-channel:300", + ]); +}); + +test("markAllReadSources skips the active marker without projected activity", () => { + const calls = []; + + markAllReadSources({ + activeChannelId: "active-channel", + channelActivityItems: [], + unreadFeedItemIds: new Set(), + undoUnreadFeedItem: () => calls.push("inbox"), + markAllChannelReadMarkers: () => calls.push("channels"), + markActiveChannelRead: () => calls.push("active"), + }); + + assert.deepEqual(calls, ["channels"]); +}); diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index b0ce8949316..dd6b9195e82 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -86,6 +86,41 @@ export function shouldBounceForChannelNotification(tags: string[][]): boolean { return !isThreadReply(tags); } +export function markAllReadSources({ + activeChannelId, + channelActivityItems, + markAllChannelReadMarkers, + markActiveChannelRead, + undoUnreadFeedItem, + unreadFeedItemIds, +}: { + activeChannelId: string | null; + channelActivityItems: ReadonlyArray<{ + channelId: string | null; + createdAt: number; + }>; + markAllChannelReadMarkers: () => void; + markActiveChannelRead: (channelId: string, createdAt: number) => void; + undoUnreadFeedItem: (itemId: string) => void; + unreadFeedItemIds: ReadonlySet; +}) { + for (const itemId of unreadFeedItemIds) { + undoUnreadFeedItem(itemId); + } + markAllChannelReadMarkers(); + + if (!activeChannelId) return; + + let latestActivityAt: number | null = null; + for (const item of channelActivityItems) { + if (item.channelId !== activeChannelId) continue; + latestActivityAt = Math.max(latestActivityAt ?? 0, item.createdAt); + } + if (latestActivityAt !== null) { + markActiveChannelRead(activeChannelId, latestActivityAt); + } +} + export function toSearchHit( target: DesktopNotificationTarget, ): SearchHit | null { diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4eb0a42bbe9..46e06b2f9f4 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; -import { deriveShellRoute } from "@/app/AppShell.helpers"; +import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; import { AppShellProvider } from "@/app/AppShellContext"; import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; import { AppShellOverlays } from "@/app/AppShellOverlays"; @@ -15,7 +15,7 @@ import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; -import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; +import { useChannelActivityProjection } from "@/app/useChannelActivityProjection"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; import { @@ -26,7 +26,6 @@ import { useOpenDmMutation, } from "@/features/channels/hooks"; import { useUnreadChannels } from "@/features/channels/useUnreadChannels"; -import { msgContextKey } from "@/features/channels/readState/readStateFormat"; import { useMembershipNotifications } from "@/features/channels/useMembershipNotifications"; import { useFeedItemState } from "@/features/home/useFeedItemState"; import { useThreadFollows } from "@/features/messages/lib/useThreadFollows"; @@ -324,10 +323,12 @@ export function AppShell() { } = useThreadFollows(identityQuery.data?.pubkey); const { - markAllChannelsRead, + markAllChannelsRead: markAllChannelReadMarkers, markChannelRead, markChannelUnread, + clearChannelUnreadSource, unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelCounts, highPriorityUnreadChannelIds, unreadChannelNotificationCount, @@ -338,6 +339,7 @@ export function AppShell() { participatedRootIds, authoredRootIds, mentionedRootIds, + recordThreadInteraction, threadActivityItems, mutedRootIds, muteThread, @@ -356,55 +358,45 @@ export function AppShell() { followedRootIds, }); - const getThreadReadAt = React.useCallback( - (rootId: string, channelId?: string | null) => { - const threadReadAt = getOwnReadAt(`thread:${rootId}`); - if (!channelId) { - return threadReadAt; - } - - const channelReadAt = getChannelReadAt(channelId); - if (threadReadAt === null) { - return channelReadAt; - } - if (channelReadAt === null) { - return threadReadAt; - } - return Math.max(threadReadAt, channelReadAt); - }, - [getChannelReadAt, getOwnReadAt], - ); - - const markThreadRead = React.useCallback( - (rootId: string, timestamp: number) => { - markChannelRead( - `thread:${rootId}`, - new Date(timestamp * 1_000).toISOString(), - ); - }, - [markChannelRead], - ); - - // Per-message read frontier (LP4 v3): effective(msg:) folds through the - // channel, so a channel-read clears messages older than the top-level frontier. - const getMessageReadAt = React.useCallback( - (messageId: string) => getChannelReadAt(msgContextKey(messageId)), - [getChannelReadAt], - ); - const markMessageRead = React.useCallback( - (messageId: string, timestamp: number) => - markChannelRead( - msgContextKey(messageId), - new Date(timestamp * 1_000).toISOString(), - ), - [markChannelRead], - ); - const threadActivityFeedItems = useThreadActivityFeedItems( + const { + getThreadReadAt, + markThreadRead, + getMessageReadAt, + getChannelActivityItemReadAt, + markMessageRead, + threadActivityFeedItems, + locallyUnreadFeedItems, + unreadThreadFeedItems, + unreadThreadChannelIds, + } = useChannelActivityProjection({ + channels, + feed: homeFeedQuery.data?.feed, + unreadFeedItemIds: feedItemState.unreadSet, + getChannelReadAt, + getOwnReadAt, + markChannelRead, + readStateVersion, threadActivityItems, mutedRootIds, - channels, - ); - + }); + const markAllChannelsRead = React.useCallback(() => { + markAllReadSources({ + activeChannelId: activeChannel?.id ?? null, + channelActivityItems: unreadThreadFeedItems, + markAllChannelReadMarkers, + markActiveChannelRead: (channelId, createdAt) => + markChannelRead(channelId, new Date(createdAt * 1_000).toISOString()), + undoUnreadFeedItem: feedItemState.undoUnread, + unreadFeedItemIds: feedItemState.unreadSet, + }); + }, [ + activeChannel?.id, + feedItemState.undoUnread, + feedItemState.unreadSet, + markAllChannelReadMarkers, + markChannelRead, + unreadThreadFeedItems, + ]); // Badge count consumes the shared NIP-RS read-state from useUnreadChannels. const { homeBadgeCount, homeBadgeCountExcludingHighPriority } = useHomeFeedNotificationState( @@ -709,6 +701,7 @@ export function AppShell() { markAllChannelsRead, markChannelRead, markChannelUnread, + clearChannelUnreadSource, openBrowseChannels: handleOpenBrowseChannels, openCreateChannel: handleOpenCreateChannel, openChannelManagement: (channelId?: string) => { @@ -721,6 +714,7 @@ export function AppShell() { getThreadReadAt, markThreadRead, getMessageReadAt, + getChannelActivityItemReadAt, markMessageRead, readStateVersion, setContextParentResolver, @@ -728,9 +722,15 @@ export function AppShell() { unfollowThread: handleUnfollowThread, isFollowingThread, isNotifiedForThread, + recordThreadInteraction, isThreadMuted: (rootId) => mutedRootIds.has(rootId), threadActivityItems, threadActivityFeedItems, + locallyUnreadFeedItems, + unreadThreadFeedItems, + unreadThreadChannelIds, + topLevelUnreadChannelIds, + hasSidebarUnreadProjections: true, feedItemState, onOpenSettings: handleOpenSettings, }} diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 4a64de0cb27..b909436178b 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore"; import type { ContextParentResolver } from "@/features/channels/readState/readStateManager"; import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; import type { FeedItemState } from "@/features/home/useFeedItemState"; @@ -12,9 +13,16 @@ type AppShellContextValue = { markChannelRead: ( channelId: string, readAt: string | null | undefined, - options?: { topLevelOnly?: boolean }, + options?: { + preserveForcedUnread?: boolean; + topLevelOnly?: boolean; + }, + ) => void; + markChannelUnread: (channelId: string, source?: ForcedUnreadSource) => void; + clearChannelUnreadSource: ( + channelId: string, + source: ForcedUnreadSource, ) => void; - markChannelUnread: (channelId: string) => void; openBrowseChannels: () => void; openCreateChannel: () => void; openChannelManagement: (channelId?: string) => void; @@ -31,6 +39,11 @@ type AppShellContextValue = { // read. Uses `msg:` context keys folded through the active channel by the // parent resolver (LP4 v3 per-message badge model). getMessageReadAt: (messageId: string) => number | null; + // Read frontier for a channel-activity item, scoped to that item's own + // message and channel rather than the currently mounted channel resolver. + getChannelActivityItemReadAt: ( + item: Pick, + ) => number | null; // Advance a single message's read marker to the given unix-seconds timestamp. markMessageRead: (messageId: string, timestamp: number) => void; // Bump-counter that invalidates whenever the read marker changes. Include @@ -43,9 +56,25 @@ type AppShellContextValue = { unfollowThread: (rootId: string) => void; isFollowingThread: (rootId: string) => boolean; isNotifiedForThread: (rootId: string) => boolean; + recordThreadInteraction: (rootId: string) => void; isThreadMuted: (rootId: string) => boolean; threadActivityItems: ThreadActivityItem[]; threadActivityFeedItems: FeedItem[]; + // Home-feed items explicitly reopened from Inbox. Kept separate from live + // thread activity so older rows can be projected into channel hover cards + // without duplicating the Home feed itself. + locallyUnreadFeedItems: FeedItem[]; + // Thread rows that remain unread until their own message/thread marker is + // advanced. Unlike the broad channel unread set, this includes the active + // channel so simply landing in it does not hide the wayfinding signal. + unreadThreadFeedItems: FeedItem[]; + unreadThreadChannelIds: ReadonlySet; + // Ordinary unread channel-level activity. Sidebar rows use this for text + // emphasis only; thread activity owns the dot. + topLevelUnreadChannelIds: ReadonlySet; + // Lets isolated component tests retain the legacy hasUnread fallback while + // the mounted shell uses the split projections above. + hasSidebarUnreadProjections: boolean; feedItemState: FeedItemState; // Open the Settings panel at the given section. Available on all surfaces // that render under AppShell (channel, home, projects, pulse, agents). @@ -57,6 +86,7 @@ const AppShellContext = React.createContext({ markAllChannelsRead: () => {}, markChannelRead: () => {}, markChannelUnread: () => {}, + clearChannelUnreadSource: () => {}, openBrowseChannels: () => {}, openCreateChannel: () => {}, openChannelManagement: () => {}, @@ -64,6 +94,7 @@ const AppShellContext = React.createContext({ getThreadReadAt: () => null, markThreadRead: () => {}, getMessageReadAt: () => null, + getChannelActivityItemReadAt: () => null, markMessageRead: () => {}, readStateVersion: 0, setContextParentResolver: () => {}, @@ -71,9 +102,15 @@ const AppShellContext = React.createContext({ unfollowThread: () => {}, isFollowingThread: () => false, isNotifiedForThread: () => false, + recordThreadInteraction: () => {}, isThreadMuted: () => false, threadActivityItems: [], threadActivityFeedItems: [], + locallyUnreadFeedItems: [], + unreadThreadFeedItems: [], + unreadThreadChannelIds: EMPTY_SET, + topLevelUnreadChannelIds: EMPTY_SET, + hasSidebarUnreadProjections: false, feedItemState: { doneSet: EMPTY_SET, markDone: () => {}, diff --git a/desktop/src/app/useChannelActivityProjection.test.mjs b/desktop/src/app/useChannelActivityProjection.test.mjs new file mode 100644 index 00000000000..6b54f322463 --- /dev/null +++ b/desktop/src/app/useChannelActivityProjection.test.mjs @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveChannelActivityFeedItemReadAt } from "./useChannelActivityProjection.ts"; + +test("channel activity read state folds the item's own message and channel markers", () => { + const markers = new Map([ + ["msg:reply-general", 100], + ["general", 200], + ["random", 500], + ]); + + assert.equal( + resolveChannelActivityFeedItemReadAt( + { id: "reply-general", channelId: "general" }, + (contextId) => markers.get(contextId) ?? null, + ), + 200, + ); +}); + +test("channel activity read state honors a channel marker without a message marker", () => { + assert.equal( + resolveChannelActivityFeedItemReadAt( + { id: "reply-general", channelId: "general" }, + (contextId) => (contextId === "general" ? 300 : null), + ), + 300, + ); +}); diff --git a/desktop/src/app/useChannelActivityProjection.ts b/desktop/src/app/useChannelActivityProjection.ts new file mode 100644 index 00000000000..b1961318ce7 --- /dev/null +++ b/desktop/src/app/useChannelActivityProjection.ts @@ -0,0 +1,145 @@ +import * as React from "react"; + +import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; +import { + maxReadAt, + msgContextKey, +} from "@/features/channels/readState/readStateFormat"; +import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels"; +import { isThreadReply } from "@/features/messages/lib/threading"; +import type { Channel, FeedItem, HomeFeed } from "@/shared/api/types"; + +type ReadTimestamp = (contextKey: string) => number | null; +type MarkChannelRead = ( + contextKey: string, + readAt: string | null | undefined, + options?: { topLevelOnly?: boolean }, +) => void; + +type UseChannelActivityProjectionOptions = { + channels: Channel[]; + feed: HomeFeed | undefined; + unreadFeedItemIds: ReadonlySet; + getChannelReadAt: ReadTimestamp; + getOwnReadAt: ReadTimestamp; + markChannelRead: MarkChannelRead; + readStateVersion: number; + threadActivityItems: ThreadActivityItem[]; + mutedRootIds: ReadonlySet; +}; + +export function resolveChannelActivityFeedItemReadAt( + item: Pick, + getOwnReadAt: ReadTimestamp, +): number | null { + return maxReadAt( + getOwnReadAt(msgContextKey(item.id)), + item.channelId ? getOwnReadAt(item.channelId) : null, + ); +} + +export function useChannelActivityProjection({ + channels, + feed, + unreadFeedItemIds, + getChannelReadAt, + getOwnReadAt, + markChannelRead, + readStateVersion, + threadActivityItems, + mutedRootIds, +}: UseChannelActivityProjectionOptions) { + const getThreadReadAt = React.useCallback( + (rootId: string, channelId?: string | null) => { + const threadReadAt = getOwnReadAt(`thread:${rootId}`); + if (!channelId) return threadReadAt; + + const channelReadAt = getChannelReadAt(channelId); + if (threadReadAt === null) return channelReadAt; + if (channelReadAt === null) return threadReadAt; + return Math.max(threadReadAt, channelReadAt); + }, + [getChannelReadAt, getOwnReadAt], + ); + const markThreadRead = React.useCallback( + (rootId: string, timestamp: number) => + markChannelRead( + `thread:${rootId}`, + new Date(timestamp * 1_000).toISOString(), + ), + [markChannelRead], + ); + const getMessageReadAt = React.useCallback( + (messageId: string) => getChannelReadAt(msgContextKey(messageId)), + [getChannelReadAt], + ); + const getChannelActivityItemReadAt = React.useCallback( + (item: Pick) => + resolveChannelActivityFeedItemReadAt(item, getOwnReadAt), + [getOwnReadAt], + ); + const markMessageRead = React.useCallback( + (messageId: string, timestamp: number) => + markChannelRead( + msgContextKey(messageId), + new Date(timestamp * 1_000).toISOString(), + ), + [markChannelRead], + ); + const threadActivityFeedItems = useThreadActivityFeedItems( + threadActivityItems, + mutedRootIds, + channels, + ); + const locallyUnreadFeedItems = React.useMemo(() => { + if (!feed || unreadFeedItemIds.size === 0) return []; + return [ + ...feed.mentions, + ...feed.needsAction, + ...feed.activity, + ...feed.agentActivity, + ].filter((item) => unreadFeedItemIds.has(item.id)); + }, [feed, unreadFeedItemIds]); + const unreadThreadFeedItems = React.useMemo(() => { + void readStateVersion; + const candidatesById = new Map( + threadActivityFeedItems.map((item) => [item.id, item]), + ); + for (const item of locallyUnreadFeedItems) + candidatesById.set(item.id, item); + + return [...candidatesById.values()].filter( + (item) => + isThreadReply(item.tags) && + (unreadFeedItemIds.has(item.id) || + item.createdAt > (getChannelActivityItemReadAt(item) ?? 0)), + ); + }, [ + getChannelActivityItemReadAt, + locallyUnreadFeedItems, + readStateVersion, + threadActivityFeedItems, + unreadFeedItemIds, + ]); + const unreadThreadChannelIds = React.useMemo( + () => + new Set( + unreadThreadFeedItems.flatMap((item) => + item.channelId ? [item.channelId] : [], + ), + ) as ReadonlySet, + [unreadThreadFeedItems], + ); + + return { + getThreadReadAt, + markThreadRead, + getMessageReadAt, + getChannelActivityItemReadAt, + markMessageRead, + threadActivityFeedItems, + locallyUnreadFeedItems, + unreadThreadFeedItems, + unreadThreadChannelIds, + }; +} diff --git a/desktop/src/features/channels/forcedUnreadStore.test.mjs b/desktop/src/features/channels/forcedUnreadStore.test.mjs new file mode 100644 index 00000000000..cea1c3babe9 --- /dev/null +++ b/desktop/src/features/channels/forcedUnreadStore.test.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + addForcedUnreadSource, + forcedUnreadMarker, + removeForcedUnreadSource, +} from "./forcedUnreadStore.ts"; + +test("adding an Inbox owner preserves an existing manual force", () => { + const entry = addForcedUnreadSource(null, 120, "inbox"); + + assert.deepEqual(entry, { + markerAtWhenForced: null, + sources: ["manual", "inbox"], + }); +}); + +test("clearing Inbox ownership leaves a manual force intact", () => { + const entry = { + markerAtWhenForced: 120, + sources: ["manual", "inbox"], + }; + + assert.deepEqual(removeForcedUnreadSource(entry, "inbox"), { + markerAtWhenForced: 120, + sources: ["manual"], + }); +}); + +test("clearing manual ownership leaves an Inbox force intact", () => { + const entry = { + markerAtWhenForced: 120, + sources: ["manual", "inbox"], + }; + + assert.deepEqual(removeForcedUnreadSource(entry, "manual"), { + markerAtWhenForced: 120, + sources: ["inbox"], + }); +}); + +test("clearing the only force owner removes the entry", () => { + const entry = { + markerAtWhenForced: 120, + sources: ["inbox"], + }; + + assert.equal(removeForcedUnreadSource(entry, "inbox"), undefined); +}); + +test("legacy persisted entries retain their read-marker baseline", () => { + assert.equal(forcedUnreadMarker(120), 120); + assert.equal(forcedUnreadMarker(null), null); +}); diff --git a/desktop/src/features/channels/forcedUnreadStore.ts b/desktop/src/features/channels/forcedUnreadStore.ts index c6a6fd07585..c4c25dcecb9 100644 --- a/desktop/src/features/channels/forcedUnreadStore.ts +++ b/desktop/src/features/channels/forcedUnreadStore.ts @@ -1,14 +1,17 @@ +import * as React from "react"; + /** - * Per-pubkey localStorage record store for channels manually marked unread via - * right-click → "mark unread". Keyed by channelId (not thread-root). Persisted - * so the sidebar badge survives reload and the rail observer can read it for - * inactive communities. + * Per-pubkey localStorage record store for channels forced unread by manual or + * Inbox actions. Keyed by channelId (not thread-root). Persisted so the sidebar + * badge survives reload and the rail observer can read it for inactive + * communities. * * Each entry stores the channel's own NIP-RS read marker (unix seconds) at the - * moment mark-unread was invoked, or null if no marker existed yet. The rail - * observer gates the forced-unread OR on this baseline: if the observed synced - * read marker has since advanced past markerAtWhenForced, the cross-device read - * wins and the dot is not lit. + * moment the first force was added, or null if no marker existed yet, plus the + * sources that currently own the force. The rail observer gates the + * forced-unread OR on this baseline: if the observed synced read marker has + * since advanced past markerAtWhenForced, the cross-device read wins and the + * dot is not lit. * * On identity change, the in-memory map is swapped to the current pubkey's * persisted data. Old pubkey data is NOT wiped from localStorage. @@ -17,8 +20,58 @@ * a retrograde "unread" state. localStorage is best-effort (per-device). */ -/** channelId → NIP-RS read marker (unix seconds) at force-time, or null */ -export type ForcedUnreadMap = Record; +export type ForcedUnreadSource = "inbox" | "manual"; +export type ForcedUnreadEntry = + | number + | null + | { + markerAtWhenForced: number | null; + sources: ForcedUnreadSource[]; + }; +/** channelId → forced-unread entry. Numbers/null are the legacy v1 shape. */ +export type ForcedUnreadMap = Record; + +function entrySources(entry: ForcedUnreadEntry): ForcedUnreadSource[] { + return typeof entry === "object" && entry !== null + ? entry.sources + : ["manual"]; +} + +export function forcedUnreadMarker(entry: ForcedUnreadEntry): number | null { + return typeof entry === "object" && entry !== null + ? entry.markerAtWhenForced + : entry; +} + +export function addForcedUnreadSource( + entry: ForcedUnreadEntry | undefined, + markerAtWhenForced: number | null, + source: ForcedUnreadSource, +): ForcedUnreadEntry { + if (entry !== undefined && entrySources(entry).includes(source)) return entry; + return { + markerAtWhenForced: + entry === undefined ? markerAtWhenForced : forcedUnreadMarker(entry), + sources: [ + ...new Set([...(entry === undefined ? [] : entrySources(entry)), source]), + ], + }; +} + +export function removeForcedUnreadSource( + entry: ForcedUnreadEntry, + source: ForcedUnreadSource, +): ForcedUnreadEntry | undefined { + const sources = entrySources(entry); + if (!sources.includes(source)) return entry; + const remainingSources = sources.filter((candidate) => candidate !== source); + return remainingSources.length > 0 + ? { + markerAtWhenForced: forcedUnreadMarker(entry), + sources: remainingSources, + } + : undefined; +} const STORAGE_PREFIX = "buzz-forced-unread.v1"; const storageKey = (pubkey: string) => `${STORAGE_PREFIX}:${pubkey}`; @@ -37,8 +90,27 @@ export const forcedUnreadStore = { return {}; const result: ForcedUnreadMap = {}; for (const [k, v] of Object.entries(parsed)) { - if (typeof k === "string" && (v === null || typeof v === "number")) { - result[k] = v as number | null; + if (typeof k !== "string") continue; + if (v === null || typeof v === "number") { + result[k] = v; + continue; + } + if ( + typeof v === "object" && + v !== null && + "markerAtWhenForced" in v && + (v.markerAtWhenForced === null || + typeof v.markerAtWhenForced === "number") && + "sources" in v && + Array.isArray(v.sources) + ) { + const sources = v.sources.filter( + (source: unknown): source is ForcedUnreadSource => + source === "inbox" || source === "manual", + ); + if (sources.length > 0) { + result[k] = { markerAtWhenForced: v.markerAtWhenForced, sources }; + } } } return result; @@ -54,3 +126,44 @@ export const forcedUnreadStore = { } }, }; + +export function useForcedUnreadActions( + forcedUnreadRef: React.MutableRefObject, + getOwnTimestamp: (channelId: string) => number | null, + pubkey: string | undefined, + onChange: () => void, +) { + const persist = React.useCallback(() => { + if (pubkey) forcedUnreadStore.write(pubkey, forcedUnreadRef.current); + onChange(); + }, [forcedUnreadRef, onChange, pubkey]); + const markChannelUnread = React.useCallback( + (channelId: string, source: ForcedUnreadSource = "manual") => { + const current = Object.hasOwn(forcedUnreadRef.current, channelId) + ? forcedUnreadRef.current[channelId] + : undefined; + const next = addForcedUnreadSource( + current, + getOwnTimestamp(channelId), + source, + ); + if (next === current) return; + forcedUnreadRef.current[channelId] = next; + persist(); + }, + [forcedUnreadRef, getOwnTimestamp, persist], + ); + const clearChannelUnreadSource = React.useCallback( + (channelId: string, source: ForcedUnreadSource) => { + if (!Object.hasOwn(forcedUnreadRef.current, channelId)) return; + const current = forcedUnreadRef.current[channelId]; + const next = removeForcedUnreadSource(current, source); + if (next === current) return; + if (next === undefined) delete forcedUnreadRef.current[channelId]; + else forcedUnreadRef.current[channelId] = next; + persist(); + }, + [forcedUnreadRef, persist], + ); + return { clearChannelUnreadSource, markChannelUnread }; +} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index b4d33b47763..3e3b6c9c8fe 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -80,6 +80,7 @@ import { useMessageProfiles } from "./useMessageProfiles"; import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; +import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, @@ -99,7 +100,7 @@ export function ChannelScreen({ const { goHome } = useAppNavigation(); const { activeCommunity } = useCommunities(); const { - markChannelRead, + clearChannelUnreadSource, markChannelUnread, getChannelReadAt, getMessageReadAt, @@ -112,6 +113,7 @@ export function ChannelScreen({ unfollowThread, isFollowingThread, isNotifiedForThread, + recordThreadInteraction, isThreadMuted, readStateVersion, } = useAppShell(); @@ -207,12 +209,11 @@ export function ChannelScreen({ const activeReadAt = latestActiveMessage ? new Date(latestActiveMessage.created_at * 1_000).toISOString() : null; - React.useEffect(() => { - if (!activeChannelId || activeChannel?.isMember === false) { - return; - } - markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true }); - }, [activeChannel?.isMember, activeChannelId, activeReadAt, markChannelRead]); + useChannelOpenReadState( + activeChannelId, + activeChannel?.isMember, + activeReadAt, + ); React.useEffect(() => { if (!activeChannelId) { setContextParentResolver(null); @@ -262,7 +263,6 @@ export function ChannelScreen({ resolvedMessages, threadReplyEvents, ); - const messageEventProfilePubkeys = useMessageEventProfilePubkeys( resolvedMessages, threadReplyEvents, @@ -472,6 +472,7 @@ export function ChannelScreen({ threadReplyTargetId, expandedThreadReplyIds, openThreadMessages: threadPanelData.visibleReplies, + clearChannelUnreadSource, getChannelReadAt, getMessageReadAt, markChannelUnread, @@ -484,8 +485,7 @@ export function ChannelScreen({ timelineMessages.find((message) => message.id === editTargetId) ?? null, [editTargetId, timelineMessages], ); - // Event id awaiting the empty-edit "Delete message?" confirmation (non-null - // while the dialog is open); see handleEditSave. + // Event id awaiting the empty-edit deletion confirmation. const [emptyDeleteId, setEmptyDeleteId] = React.useState(null); const { handleCancelEdit, @@ -508,6 +508,7 @@ export function ChannelScreen({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + recordThreadInteraction, openThreadHeadId: effectiveOpenThreadHeadId, onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId, onRequestEmptyEditDelete: setEmptyDeleteId, @@ -688,7 +689,6 @@ export function ChannelScreen({ threadReplyTargetId, threadReplyTargetMessage, }); - const hasAuxiliaryPanel = Boolean( effectiveOpenThreadHeadId || openAgentSessionPubkey || diff --git a/desktop/src/features/channels/ui/useChannelOpenReadState.test.mjs b/desktop/src/features/channels/ui/useChannelOpenReadState.test.mjs new file mode 100644 index 00000000000..4f54d8b3098 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelOpenReadState.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getTopLevelInboxUnreadOverrideIds } from "./useChannelOpenReadState.ts"; + +test("opening a channel clears only its top-level Inbox overrides", () => { + assert.deepEqual( + getTopLevelInboxUnreadOverrideIds( + [ + { id: "top-level", channelId: "general", tags: [] }, + { + id: "thread-reply", + channelId: "general", + tags: [ + ["e", "root", "", "root"], + ["e", "parent", "", "reply"], + ], + }, + { id: "other-channel", channelId: "random", tags: [] }, + ], + "general", + ), + ["top-level"], + ); +}); diff --git a/desktop/src/features/channels/ui/useChannelOpenReadState.ts b/desktop/src/features/channels/ui/useChannelOpenReadState.ts new file mode 100644 index 00000000000..ff927c00bb0 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelOpenReadState.ts @@ -0,0 +1,45 @@ +import * as React from "react"; + +import { useAppShell } from "@/app/AppShellContext"; +import { isThreadReply } from "@/features/messages/lib/threading"; +import type { FeedItem } from "@/shared/api/types"; + +/** + * Inbox overrides for top-level rows are consumed by opening the channel. + * Thread-reply overrides intentionally remain until their thread is read. + */ +export function getTopLevelInboxUnreadOverrideIds( + items: FeedItem[], + channelId: string, +): string[] { + return items.flatMap((item) => + item.channelId === channelId && !isThreadReply(item.tags) ? [item.id] : [], + ); +} + +export function useChannelOpenReadState( + activeChannelId: string | null, + isChannelMember: boolean | undefined, + activeReadAt: string | null, +) { + const { feedItemState, locallyUnreadFeedItems, markChannelRead } = + useAppShell(); + + React.useEffect(() => { + if (!activeChannelId || isChannelMember === false) return; + for (const itemId of getTopLevelInboxUnreadOverrideIds( + locallyUnreadFeedItems, + activeChannelId, + )) { + feedItemState.undoUnread(itemId); + } + markChannelRead(activeChannelId, activeReadAt, { topLevelOnly: true }); + }, [ + activeChannelId, + activeReadAt, + feedItemState.undoUnread, + isChannelMember, + locallyUnreadFeedItems, + markChannelRead, + ]); +} diff --git a/desktop/src/features/channels/ui/useChannelUnreadState.ts b/desktop/src/features/channels/ui/useChannelUnreadState.ts index dc240235559..884aa02d8e3 100644 --- a/desktop/src/features/channels/ui/useChannelUnreadState.ts +++ b/desktop/src/features/channels/ui/useChannelUnreadState.ts @@ -1,5 +1,6 @@ import * as React from "react"; +import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore"; import { buildCreatedAtByMessageId, buildDirectReplyIdsByParentId, @@ -36,6 +37,10 @@ type UseChannelUnreadStateOptions = { openThreadMessages?: MainTimelineEntry[]; getChannelReadAt: (channelId: string) => number | null; getMessageReadAt: (messageId: string) => number | null; + clearChannelUnreadSource: ( + channelId: string, + source: ForcedUnreadSource, + ) => void; markChannelUnread: (channelId: string) => void; markMessageRead: (messageId: string, timestamp: number) => void; isThreadMuted: (rootId: string) => boolean; @@ -64,6 +69,7 @@ export function useChannelUnreadState({ openThreadMessages, getChannelReadAt, getMessageReadAt, + clearChannelUnreadSource, markChannelUnread, markMessageRead, isThreadMuted, @@ -452,16 +458,26 @@ export function useChannelUnreadState({ const createdAt = createdAtByMessageId.get(id); if (createdAt !== undefined) markMessageRead(id, createdAt); } + if (activeChannelId && forcedUnreadMsgRef.current.size === 0) { + clearChannelUnreadSource(activeChannelId, "manual"); + } forceUnreadRender(); }, - [createdAtByMessageId, getReplyDescendantIdsForMessage, markMessageRead], + [ + activeChannelId, + clearChannelUnreadSource, + createdAtByMessageId, + getReplyDescendantIdsForMessage, + markMessageRead, + ], ); // Mark a message and its whole subtree UNREAD (LP4 v3 menu action). Markers // are monotonic and cannot move backward, so this writes NO marker: it adds // the ids to the session-local forced-unread overlay the badge predicates OR - // in. Cleared on channel-leave; does not survive reload (symmetric with the - // shipped channel mark-unread). + // in. It also forces the channel-level unread projection so leaving the + // channel restores its bold sidebar emphasis. The per-message overlay is + // cleared on channel-leave; the channel-level force survives until reopen. const handleMarkMessageUnread = React.useCallback( (messageId: string) => { for (const id of [ @@ -470,9 +486,12 @@ export function useChannelUnreadState({ ]) { forcedUnreadMsgRef.current.add(id); } + if (activeChannelId) { + markChannelUnread(activeChannelId); + } forceUnreadRender(); }, - [getReplyDescendantIdsForMessage], + [activeChannelId, getReplyDescendantIdsForMessage, markChannelUnread], ); return { diff --git a/desktop/src/features/channels/unreadChannelCounts.ts b/desktop/src/features/channels/unreadChannelCounts.ts index cb33c629758..aae9c5096e0 100644 --- a/desktop/src/features/channels/unreadChannelCounts.ts +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -78,6 +78,19 @@ export function countUnreadObservedEvents( return count; } +export function hasUnreadTopLevelObservedEvent( + eventsById: ReadonlyMap | undefined, + getReadAt: (event: ObservedUnreadEvent) => number | null, +): boolean { + if (!eventsById) return false; + for (const event of eventsById.values()) { + if (event.rootId !== null) continue; + const readAt = getReadAt(event); + if (readAt === null || event.createdAt > readAt) return true; + } + return false; +} + export function countUnreadBadgeObservedEvents( eventsById: ReadonlyMap | undefined, getReadAt: (event: ObservedUnreadEvent) => number | null, diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index 6ea09164136..660d4f5c61b 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -7,6 +7,7 @@ import { countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, + hasUnreadTopLevelObservedEvent, observedUnreadEventReadAt, recordObservedUnreadEvent, } from "./unreadChannelCounts.ts"; @@ -368,6 +369,30 @@ test("countUnreadObservedEvents_topLevelUsesChannelMarker", () => { assert.equal(countUnreadObservedEvents(events, readAtFor(300, new Map())), 1); }); +test("hasUnreadTopLevelObservedEvent_ignoresUnreadThreadReplies", () => { + const events = new Map([ + ["top-old", observed("top-old", 250)], + ["thread-new", observed("thread-new", 500, "root-1")], + ]); + + assert.equal( + hasUnreadTopLevelObservedEvent(events, readAtFor(300, new Map())), + false, + ); +}); + +test("hasUnreadTopLevelObservedEvent_detectsUnreadTopLevelMessage", () => { + const events = new Map([ + ["top-new", observed("top-new", 350)], + ["thread-new", observed("thread-new", 500, "root-1")], + ]); + + assert.equal( + hasUnreadTopLevelObservedEvent(events, readAtFor(300, new Map())), + true, + ); +}); + test("countUnreadBadgeObservedEvents_skipsBoldOnlyGeneralChannelItems", () => { const events = new Map([ ["plain", observed("plain", 500, null, false, false)], diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 53170257a47..a619f595f90 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -7,6 +7,7 @@ import type { useToggleReactionMutation, } from "@/features/messages/hooks"; import { resolveThreadReplyTarget } from "@/features/messages/hooks"; +import type { TimelineMessage } from "@/features/messages/types"; /** * Stable callback references for ChannelPane so that keystroke-driven @@ -24,6 +25,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + recordThreadInteraction, onOptimisticOpenThreadHeadIdChange, onRequestEmptyEditDelete, openThreadHeadId, @@ -43,6 +45,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; markRevealedRepliesRead: (messageId: string) => void; + recordThreadInteraction: (rootId: string) => void; onOptimisticOpenThreadHeadIdChange: React.Dispatch< React.SetStateAction >; @@ -344,14 +347,21 @@ export function useChannelPaneHandlers({ ); const handleToggleReaction = React.useCallback( - async (message: { id: string }, emoji: string, remove: boolean) => { + async ( + message: Pick, + emoji: string, + remove: boolean, + ) => { await toggleMutateRef.current({ emoji, eventId: message.id, remove, }); + if (!remove) { + recordThreadInteraction(message.rootId ?? message.id); + } }, - [], + [recordThreadInteraction], ); return { diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index a2376f9b7bb..256077d64ca 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -9,8 +9,8 @@ import { countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, + hasUnreadTopLevelObservedEvent, makeObservedUnreadEvent, - mapsEqual, observedUnreadEventReadAt, recordObservedUnreadEvent, type ObservedUnreadEvent, @@ -20,6 +20,7 @@ import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { forcedUnreadStore, type ForcedUnreadMap, + useForcedUnreadActions, } from "@/features/channels/forcedUnreadStore"; import { getThreadReference, @@ -33,6 +34,7 @@ import { import type { RelayClient } from "@/shared/api/relayClientSession"; import type { Channel, RelayEvent } from "@/shared/api/types"; import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; +import { useStableMap, useStableSet } from "@/shared/hooks/useStableReference"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind"; import { @@ -125,14 +127,6 @@ export function resolveObservedUnreadRootId(tags: string[][]): string | null { return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId; } -function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { - if (a.size !== b.size) return false; - for (const item of a) { - if (!b.has(item)) return false; - } - return true; -} - export function useUnreadChannels( channels: Channel[], activeChannel: Channel | null, @@ -310,9 +304,18 @@ export function useUnreadChannels( ( channelId: string, readAt: string | null | undefined, - { topLevelOnly = false }: { topLevelOnly?: boolean } = {}, + { + preserveForcedUnread = false, + topLevelOnly = false, + }: { + preserveForcedUnread?: boolean; + topLevelOnly?: boolean; + } = {}, ) => { - if (Object.hasOwn(forcedUnreadRef.current, channelId)) { + if ( + !preserveForcedUnread && + Object.hasOwn(forcedUnreadRef.current, channelId) + ) { delete forcedUnreadRef.current[channelId]; if (pubkey) { forcedUnreadStore.write(pubkey, forcedUnreadRef.current); @@ -342,22 +345,13 @@ export function useUnreadChannels( [markContextRead, pubkey], ); - // Manually mark a channel unread (e.g., right-click → "mark unread"). Persists - // the current NIP-RS read marker as baseline to localStorage so the rail - // observer can detect when a cross-device read has since covered the force. - // NIP-RS markers are monotonic, so we do not publish a lower timestamp. - const markChannelUnread = React.useCallback( - (channelId: string) => { - if (!Object.hasOwn(forcedUnreadRef.current, channelId)) { - forcedUnreadRef.current[channelId] = getOwnTimestamp(channelId); - if (pubkey) { - forcedUnreadStore.write(pubkey, forcedUnreadRef.current); - } - bumpLatestVersion(); - } - }, - [getOwnTimestamp, pubkey], - ); + const { clearChannelUnreadSource, markChannelUnread } = + useForcedUnreadActions( + forcedUnreadRef, + getOwnTimestamp, + pubkey, + bumpLatestVersion, + ); // Record the thread root of an EXTERNAL message that @-mentioned the user. // Keyed on the thread root so the badge gate trips for a mention recipient @@ -477,6 +471,22 @@ export function useUnreadChannels( [normalizedPubkey], ); + const recordThreadInteraction = React.useCallback( + (rootId: string) => { + const normalizedRootId = rootId.trim(); + if (!normalizedRootId) return; + const target = participatedRootIdsRef.current; + const sizeBefore = target.size; + target.add(normalizedRootId); + if (target.size === sizeBefore) return; + if (normalizedPubkey !== null) { + participationStore.write(normalizedPubkey, target); + } + bumpMembershipVersion(); + }, + [normalizedPubkey], + ); + const handleThreadReplyNotification = React.useCallback( (channelId: string, event: RelayEvent) => { // Guard: don't merge into a ref whose scope has drifted from the current @@ -830,6 +840,7 @@ export function useUnreadChannels( if (!isReadStateReady) { return { unreadChannelIds: new Set(), + topLevelUnreadChannelIds: new Set(), highPriorityUnreadChannelIds: new Set(), unreadChannelCounts: new Map(), unreadChannelNotificationCount: 0, @@ -837,6 +848,7 @@ export function useUnreadChannels( } const unread = new Set(); + const topLevelUnread = new Set(); const highPriority = new Set(); const counts = new Map(); let unreadChannelNotificationCount = 0; @@ -844,15 +856,10 @@ export function useUnreadChannels( for (const channel of channels) { if (channel.id === activeChannelId) continue; - if (Object.hasOwn(forcedUnreadRef.current, channel.id)) { - // Forced-unread is dot tier only — not high-priority. - unread.add(channel.id); - counts.set(channel.id, 1); - unreadChannelNotificationCount += 1; - continue; - } - - if (latestByChannelRef.current.get(channel.id) === undefined) continue; + const isForcedUnread = Object.hasOwn( + forcedUnreadRef.current, + channel.id, + ); const observedEvents = observedUnreadEventsByChannelRef.current.get( channel.id, @@ -866,13 +873,25 @@ export function useUnreadChannels( (messageId) => getOwnTimestamp(`msg:${messageId}`), ); - const unreadCount = countUnreadObservedEvents( - observedEvents, - readAtForObservedEvent, - ); - if (unreadCount === 0) continue; + const unreadCount = + latestByChannelRef.current.get(channel.id) === undefined + ? 0 + : countUnreadObservedEvents(observedEvents, readAtForObservedEvent); + if (unreadCount === 0) { + if (!isForcedUnread) continue; + unread.add(channel.id); + topLevelUnread.add(channel.id); + counts.set(channel.id, 1); + unreadChannelNotificationCount += 1; + continue; + } unread.add(channel.id); + if ( + hasUnreadTopLevelObservedEvent(observedEvents, readAtForObservedEvent) + ) { + topLevelUnread.add(channel.id); + } const badgeCount = countUnreadBadgeObservedEvents( observedEvents, readAtForObservedEvent, @@ -900,6 +919,7 @@ export function useUnreadChannels( return { unreadChannelIds: unread, + topLevelUnreadChannelIds: topLevelUnread, highPriorityUnreadChannelIds: highPriority, unreadChannelCounts: counts, unreadChannelNotificationCount, @@ -914,37 +934,14 @@ export function useUnreadChannels( readStateVersion, ]); - // Stabilize Set references: only replace when contents actually change, - // so downstream memos don't re-run on every render when sets are equal. - const prevUnreadRef = React.useRef>(new Set()); - const prevHighPriorityRef = React.useRef>(new Set()); - const prevUnreadCountsRef = React.useRef>( - new Map(), + const unreadChannelIds = useStableSet(rawUnread.unreadChannelIds); + const topLevelUnreadChannelIds = useStableSet( + rawUnread.topLevelUnreadChannelIds, ); - - const unreadChannelIds = setsEqual( - rawUnread.unreadChannelIds, - prevUnreadRef.current, - ) - ? prevUnreadRef.current - : rawUnread.unreadChannelIds; - prevUnreadRef.current = unreadChannelIds; - - const highPriorityUnreadChannelIds = setsEqual( + const highPriorityUnreadChannelIds = useStableSet( rawUnread.highPriorityUnreadChannelIds, - prevHighPriorityRef.current, - ) - ? prevHighPriorityRef.current - : rawUnread.highPriorityUnreadChannelIds; - prevHighPriorityRef.current = highPriorityUnreadChannelIds; - - const unreadChannelCounts = mapsEqual( - rawUnread.unreadChannelCounts, - prevUnreadCountsRef.current, - ) - ? prevUnreadCountsRef.current - : rawUnread.unreadChannelCounts; - prevUnreadCountsRef.current = unreadChannelCounts; + ); + const unreadChannelCounts = useStableMap(rawUnread.unreadChannelCounts); const unreadChannelNotificationCount = rawUnread.unreadChannelNotificationCount; @@ -992,12 +989,14 @@ export function useUnreadChannels( return { unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelCounts, highPriorityUnreadChannelIds, unreadChannelNotificationCount, markAllChannelsRead, markChannelRead, markChannelUnread, + clearChannelUnreadSource, // Exposed so other surfaces (e.g. Home) can project per-item read state // off the same NIP-RS read marker without instantiating a second // ReadStateManager. readStateVersion is the invalidation signal callers @@ -1009,6 +1008,7 @@ export function useUnreadChannels( participatedRootIds, authoredRootIds, mentionedRootIds, + recordThreadInteraction, threadActivityItems: projectActivityForScope( threadActivityScopeRef.current, currentActivityScope, diff --git a/desktop/src/features/communities/communityUnreadObserver.ts b/desktop/src/features/communities/communityUnreadObserver.ts index 5bc32984132..e729831eac6 100644 --- a/desktop/src/features/communities/communityUnreadObserver.ts +++ b/desktop/src/features/communities/communityUnreadObserver.ts @@ -1,5 +1,6 @@ import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { + forcedUnreadMarker, forcedUnreadStore, type ForcedUnreadMap, } from "@/features/channels/forcedUnreadStore"; @@ -238,7 +239,9 @@ export async function fetchCommunityUnread(args: { // runs while the community is active, so the store may not be pruned for // inactive communities). if (!hasUnread && Object.hasOwn(forcedUnreadMap, channel.id)) { - const markerAtWhenForced = forcedUnreadMap[channel.id]; + const markerAtWhenForced = forcedUnreadMarker( + forcedUnreadMap[channel.id], + ); if ( readAt === null || (markerAtWhenForced !== null && readAt <= markerAtWhenForced) diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0f7c851643c..e415f8d3a86 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -234,13 +234,16 @@ export function HomeView({ inboxListWidthPx, } = useResizableInboxListWidth(); const { + clearChannelUnreadSource, getChannelReadAt, getThreadReadAt, getMessageReadAt, feedItemState, markChannelRead, + markChannelUnread, markMessageRead, markThreadRead, + recordThreadInteraction, readStateVersion, } = useAppShell(); const { doneSet, markDone, markUnread, undoDone, undoUnread, unreadSet } = @@ -383,7 +386,9 @@ export function HomeView({ readStateVersion, localDoneSet: doneSet, localUnreadSet: unreadSet, + clearChannelUnreadSource, markChannelRead, + markChannelUnread, markMessageRead, markThreadRead, markDoneLocal: markDone, @@ -861,6 +866,13 @@ export function HomeView({ eventId: message.id, remove, }); + if (!remove) { + recordThreadInteraction( + selectedItem?.conversationId ?? + message.rootId ?? + message.id, + ); + } await threadContext.refreshReactions(); await channelMessagesQuery.refetch(); onRefresh(); diff --git a/desktop/src/features/home/useHomeInboxReadState.test.mjs b/desktop/src/features/home/useHomeInboxReadState.test.mjs index 4af181379a8..77caecad0f3 100644 --- a/desktop/src/features/home/useHomeInboxReadState.test.mjs +++ b/desktop/src/features/home/useHomeInboxReadState.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { getGroupedChannelReadTimestamp, getGroupedInboxItemIds, + hasRemainingChannelUnreadOverride, hasGroupedUnreadOverride, resolveInboxItemReadAt, } from "./useHomeInboxReadState.ts"; @@ -125,6 +126,47 @@ test("grouped unread override matches any item represented by the row", () => { ); }); +test("remaining channel unread override ignores the row being cleared", () => { + const firstReply = feedItem({ + id: "first-reply", + createdAt: 200, + tags: [ + ["h", CHANNEL_ID], + ["e", "first-root", "", "root"], + ["e", "first-parent", "", "reply"], + ], + }); + const secondReply = feedItem({ + id: "second-reply", + createdAt: 300, + tags: [ + ["h", CHANNEL_ID], + ["e", "second-root", "", "root"], + ["e", "second-parent", "", "reply"], + ], + }); + const items = [inboxItem([firstReply]), inboxItem([secondReply])]; + + assert.equal( + hasRemainingChannelUnreadOverride( + items, + new Set(["first-reply", "second-reply"]), + CHANNEL_ID, + new Set(["first-reply"]), + ), + true, + ); + assert.equal( + hasRemainingChannelUnreadOverride( + items, + new Set(["first-reply"]), + CHANNEL_ID, + new Set(["first-reply"]), + ), + false, + ); +}); + test("thread inbox row without a marker ignores local done fallback", () => { const replyItem = feedItem({ id: "reply-event", diff --git a/desktop/src/features/home/useHomeInboxReadState.ts b/desktop/src/features/home/useHomeInboxReadState.ts index 624e72a0f5e..24d536e7301 100644 --- a/desktop/src/features/home/useHomeInboxReadState.ts +++ b/desktop/src/features/home/useHomeInboxReadState.ts @@ -1,5 +1,6 @@ import * as React from "react"; +import type { ForcedUnreadSource } from "@/features/channels/forcedUnreadStore"; import type { InboxItem } from "@/features/home/lib/inbox"; import { getThreadReference, @@ -25,6 +26,17 @@ type UseHomeInboxReadStateOptions = { markChannelRead: ( channelId: string, readAt: string | null | undefined, + options?: { + preserveForcedUnread?: boolean; + topLevelOnly?: boolean; + }, + ) => void; + /** Force a channel's unread indicator without rolling back its NIP-RS marker. */ + markChannelUnread: (channelId: string, source?: ForcedUnreadSource) => void; + /** Remove only the named owner of a channel's forced-unread indicator. */ + clearChannelUnreadSource: ( + channelId: string, + source: ForcedUnreadSource, ) => void; /** Advance the thread read marker to the given unix-seconds timestamp. */ markThreadRead: (rootId: string, timestamp: number) => void; @@ -32,7 +44,7 @@ type UseHomeInboxReadStateOptions = { markMessageRead: (messageId: string, timestamp: number) => void; /** Local fallback: mark a non-channel item done. */ markDoneLocal: (id: string) => void; - /** Local inbox row override: mark an item unread without touching the channel. */ + /** Local inbox row override: mark an item unread alongside its channel emphasis. */ markUnreadLocal: (id: string) => void; /** Local fallback: undo a non-channel item done. */ undoDoneLocal: (id: string) => void; @@ -83,6 +95,21 @@ export function hasGroupedUnreadOverride( return getGroupedInboxItemIds(item).some((id) => localUnreadSet.has(id)); } +export function hasRemainingChannelUnreadOverride( + items: InboxItem[], + localUnreadSet: ReadonlySet, + channelId: string, + clearedItemIds: ReadonlySet, +): boolean { + return items.some( + (candidate) => + candidate.item.channelId === channelId && + getGroupedInboxItemIds(candidate).some( + (id) => localUnreadSet.has(id) && !clearedItemIds.has(id), + ), + ); +} + export function resolveInboxItemReadAt( item: InboxItem, options: { @@ -114,8 +141,8 @@ export function resolveInboxItemReadAt( * "Mark as read" on channel-backed items is routed through `markChannelRead`; * thread rows advance the same per-message markers as the channel thread * panel, plus the aggregate `thread:` marker for compatibility. "Mark - * unread" is item-local: it only reopens the specific inbox row and must not - * light up the channel. + * unread" keeps its per-item local override and also restores the source + * channel's forced-unread indicator so channel surfaces agree with Inbox. */ export function useHomeInboxReadState({ items, @@ -126,6 +153,8 @@ export function useHomeInboxReadState({ localDoneSet, localUnreadSet = EMPTY_ITEM_SET, markChannelRead, + markChannelUnread, + clearChannelUnreadSource, markThreadRead, markMessageRead, markDoneLocal, @@ -182,9 +211,22 @@ export function useHomeInboxReadState({ (itemId: string) => { const item = itemById.get(itemId); const localUnreadIds = item ? getGroupedInboxItemIds(item) : [itemId]; + const clearedItemIds = new Set(localUnreadIds); for (const id of localUnreadIds) { undoUnreadLocal(id); } + const channelId = item?.item.channelId ?? null; + if ( + channelId && + !hasRemainingChannelUnreadOverride( + items, + localUnreadSet, + channelId, + clearedItemIds, + ) + ) { + clearChannelUnreadSource(channelId, "inbox"); + } const threadRootId = item ? getInboxThreadRootId(item) : null; if (item && threadRootId) { const markedReplyIds = new Set(); @@ -201,16 +243,17 @@ export function useHomeInboxReadState({ markChannelRead( groupedChannelRead.channelId, new Date(groupedChannelRead.timestamp * 1_000).toISOString(), + { preserveForcedUnread: true, topLevelOnly: true }, ); } return; } - const channelId = item?.item.channelId ?? null; if (item && channelId) { markChannelRead( channelId, new Date(item.latestActivityAt * 1_000).toISOString(), + { preserveForcedUnread: true }, ); return; } @@ -218,6 +261,9 @@ export function useHomeInboxReadState({ }, [ itemById, + items, + clearChannelUnreadSource, + localUnreadSet, markChannelRead, markDoneLocal, markMessageRead, @@ -230,8 +276,17 @@ export function useHomeInboxReadState({ (itemId: string) => { undoDoneLocal(itemId); markUnreadLocal(itemId); + const item = itemById.get(itemId); + const channelId = item?.item.channelId ?? null; + // The Inbox override owns the durable thread dot, while the channel force + // restores timeline-level emphasis after the user leaves the channel. + // Opening the channel clears the bolding but leaves the thread dot until + // the thread itself is opened or marked read. + if (channelId && item) { + markChannelUnread(channelId, "inbox"); + } }, - [markUnreadLocal, undoDoneLocal], + [itemById, markChannelUnread, markUnreadLocal, undoDoneLocal], ); return { effectiveDoneSet, markItemRead, markItemUnread }; diff --git a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts index 37a3c866569..9b4c3c7804f 100644 --- a/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts +++ b/desktop/src/features/sidebar/lib/useActiveWorkingChannelsById.ts @@ -2,7 +2,10 @@ import * as React from "react"; import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { useWorkingChannels } from "@/features/agents/agentWorkingSignal"; -import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; import { normalizePubkey } from "@/shared/lib/pubkey"; export function resolveActiveWorkingChannelNames( @@ -27,10 +30,15 @@ export function useActiveWorkingChannelsById(): ReadonlyMap< ActiveChannelTurnSummary > { const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); const managedAgents = React.useMemo( () => managedAgentsQuery.data ?? [], [managedAgentsQuery.data], ); + const namedAgents = React.useMemo( + () => [...(relayAgentsQuery.data ?? []), ...managedAgents], + [managedAgents, relayAgentsQuery.data], + ); // Unified working signal: observer-derived turns primary, bot typing as // fallback — so the sidebar badge appears even for agents whose observer @@ -42,11 +50,11 @@ export function useActiveWorkingChannelsById(): ReadonlyMap< activeWorkingChannels.map((summary) => { const resolvedSummary = resolveActiveWorkingChannelNames( summary, - managedAgents, + namedAgents, ); return [resolvedSummary.channelId, resolvedSummary]; }), ), - [activeWorkingChannels, managedAgents], + [activeWorkingChannels, namedAgents], ); } diff --git a/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx new file mode 100644 index 00000000000..1e50ef72ee8 --- /dev/null +++ b/desktop/src/features/sidebar/ui/ChannelActivityPopover.tsx @@ -0,0 +1,448 @@ +import * as React from "react"; +import { Clock, Loader2, MailOpen } from "lucide-react"; + +import { useAppShell } from "@/app/AppShellContext"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity"; +import { buildInboxItems, type InboxItem } from "@/features/home/lib/inbox"; +import { getGroupedInboxItemIds } from "@/features/home/useHomeInboxReadState"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { Channel, FeedItem, HomeFeedResponse } from "@/shared/api/types"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; +import { useNow } from "@/shared/lib/useNow"; +import { Markdown } from "@/shared/ui/markdown"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +const HOVER_OPEN_DELAY_MS = 250; +const HOVER_CLOSE_DELAY_MS = 180; + +function buildChannelActivityFeed(items: FeedItem[]): HomeFeedResponse { + return { + feed: { + mentions: items.filter((item) => item.category === "mention"), + needsAction: items.filter((item) => item.category === "needs_action"), + activity: items.filter( + (item) => + item.category !== "mention" && + item.category !== "needs_action" && + item.category !== "agent_activity", + ), + agentActivity: items.filter((item) => item.category === "agent_activity"), + }, + meta: { + since: 0, + total: items.length, + generatedAt: 0, + }, + }; +} + +function RowActionButton({ + children, + label, + onClick, +}: { + children: React.ReactNode; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function ThreadPreviewRow({ + item, + onMarkRead, + onOpen, + onRemindLater, +}: { + item: InboxItem; + onMarkRead: () => void; + onOpen: () => void; + onRemindLater: () => void; +}) { + return ( +
+
+ ); +} + +function WorkingAgentRow({ + avatarUrl, + elapsed, + name, + onOpen, + pubkey, +}: { + avatarUrl: string | null; + elapsed: string; + name: string; + onOpen: () => void; + pubkey: string; +}) { + return ( + + ); +} + +function WorkingAgentRows({ + activeWorking, + channelId, + onOpen, + profiles, +}: { + activeWorking: ActiveChannelTurnSummary; + channelId: string; + onOpen: (pubkey: string, channelId: string) => void; + profiles?: UserProfileLookup; +}) { + const now = useNow(1000); + const elapsed = formatElapsed(now - activeWorking.anchorAt); + const alignedAgentNames = + activeWorking.agentNames?.length === activeWorking.agentPubkeys.length + ? activeWorking.agentNames + : null; + + return activeWorking.agentPubkeys.map((pubkey, index) => { + const profile = profiles?.[normalizePubkey(pubkey)]; + const name = + profile?.displayName?.trim() || + alignedAgentNames?.[index] || + `Agent ${truncatePubkey(pubkey)}`; + return ( + onOpen(pubkey, channelId)} + pubkey={pubkey} + /> + ); + }); +} + +export function ChannelActivityPopover({ + activeWorking, + channel, + children, +}: { + activeWorking?: ActiveChannelTurnSummary; + channel: Channel; + children: React.ReactNode; +}) { + const [open, setOpen] = React.useState(false); + const hoverTimerRef = React.useRef | null>( + null, + ); + const { + clearChannelUnreadSource, + getChannelActivityItemReadAt, + locallyUnreadFeedItems, + markMessageRead, + markThreadRead, + unreadThreadFeedItems, + feedItemState, + } = useAppShell(); + const { undoUnread } = feedItemState; + const identityQuery = useIdentityQuery(); + const { goChannel } = useAppNavigation(); + const { openAgentActivity } = useOpenAgentActivity(); + const { openReminder } = useRemindLater(); + + const unreadChannelFeedItems = React.useMemo(() => { + return unreadThreadFeedItems.filter( + (item) => item.channelId === channel.id, + ); + }, [channel.id, unreadThreadFeedItems]); + const profilePubkeys = React.useMemo( + () => [ + ...new Set([ + ...unreadChannelFeedItems.map((item) => item.pubkey), + ...(activeWorking?.agentPubkeys ?? []), + ]), + ], + [activeWorking?.agentPubkeys, unreadChannelFeedItems], + ); + const profilesQuery = useUsersBatchQuery(open ? profilePubkeys : [], { + enabled: open, + }); + const profiles = profilesQuery.data?.profiles; + const activityReadAtByMessageId = React.useMemo( + () => + new Map( + unreadChannelFeedItems.map((item) => [ + item.id, + getChannelActivityItemReadAt(item), + ]), + ), + [getChannelActivityItemReadAt, unreadChannelFeedItems], + ); + const activityItems = React.useMemo(() => { + if (!open) return []; + return buildInboxItems({ + channels: [channel], + currentPubkey: identityQuery.data?.pubkey, + feed: buildChannelActivityFeed(unreadChannelFeedItems), + getMessageReadAt: (messageId) => + activityReadAtByMessageId.get(messageId) ?? null, + profiles, + }); + }, [ + channel, + activityReadAtByMessageId, + identityQuery.data?.pubkey, + open, + profiles, + unreadChannelFeedItems, + ]); + const hasContent = + unreadChannelFeedItems.length > 0 || + (activeWorking?.agentPubkeys.length ?? 0) > 0; + + const clearHoverTimer = React.useCallback(() => { + if (hoverTimerRef.current !== null) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + const openWithDelay = React.useCallback(() => { + if (!hasContent) return; + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(true); + }, HOVER_OPEN_DELAY_MS); + }, [clearHoverTimer, hasContent]); + const openImmediately = React.useCallback(() => { + if (!hasContent) return; + clearHoverTimer(); + setOpen(true); + }, [clearHoverTimer, hasContent]); + const closeWithDelay = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimer]); + const keepOpen = React.useCallback(() => { + clearHoverTimer(); + }, [clearHoverTimer]); + + React.useEffect(() => () => clearHoverTimer(), [clearHoverTimer]); + React.useEffect(() => { + if (!hasContent) { + setOpen(false); + } + }, [hasContent]); + + const clearUnreadOverride = React.useCallback( + (item: InboxItem) => { + const clearedItemIds = new Set(getGroupedInboxItemIds(item)); + for (const itemId of clearedItemIds) { + undoUnread(itemId); + } + const channelId = item.item.channelId ?? null; + const hasAnotherChannelOverride = locallyUnreadFeedItems.some( + (feedItem) => + feedItem.channelId === channelId && !clearedItemIds.has(feedItem.id), + ); + if (channelId && !hasAnotherChannelOverride) { + clearChannelUnreadSource(channelId, "inbox"); + } + }, + [clearChannelUnreadSource, locallyUnreadFeedItems, undoUnread], + ); + + const handleMarkRead = React.useCallback( + (item: InboxItem) => { + clearUnreadOverride(item); + for (const reply of item.groupItems) { + markMessageRead(reply.id, reply.createdAt); + } + markThreadRead(item.conversationId, item.latestActivityAt); + }, + [clearUnreadOverride, markMessageRead, markThreadRead], + ); + + if (!hasContent) { + return children; + } + + return ( + + + {/* biome-ignore lint/a11y/noStaticElementInteractions: hover/focus events bubble from the nested channel button while this wrapper supplies the popover anchor box. */} +
setOpen(false)} + onFocus={openImmediately} + onMouseEnter={openWithDelay} + onMouseLeave={closeWithDelay} + > + {children} +
+
+ event.preventDefault()} + side="right" + sideOffset={8} + > +
+

+ Channel activity +

+
+ {activeWorking?.agentPubkeys.length ? ( + { + setOpen(false); + openAgentActivity(pubkey, { channelId }); + }} + profiles={profiles} + /> + ) : null} + {activityItems.length > 0 + ? activityItems.map((item) => ( + handleMarkRead(item)} + onOpen={() => { + clearUnreadOverride(item); + setOpen(false); + void goChannel(channel.id, { + messageId: item.id, + threadRootId: item.conversationId, + }); + }} + onRemindLater={() => { + setOpen(false); + openReminder({ + authorPubkey: item.item.pubkey, + channelId: channel.id, + eventId: item.id, + preview: item.preview.slice(0, 100), + }); + }} + /> + )) + : null} +
+
+
+
+ ); +} diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 76cae3ed38c..78f3929f19c 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -15,6 +15,7 @@ import { TriangleAlert, } from "lucide-react"; +import { useAppShell } from "@/app/AppShellContext"; import { useArchiveChannelMutation, useChannelMembersQuery, @@ -178,6 +179,22 @@ export function ChannelContextMenuItems({ onDeleteChannel?: (channel: Channel) => void; onLeaveChannel?: (channel: Channel) => void; }) { + const { + feedItemState, + hasSidebarUnreadProjections, + locallyUnreadFeedItems, + unreadThreadChannelIds, + } = useAppShell(); + const channelUnreadOverrideIds = locallyUnreadFeedItems.flatMap((item) => + item.channelId === channel.id && feedItemState.unreadSet.has(item.id) + ? [item.id] + : [], + ); + const hasProjectedUnread = + hasUnread || + (channel.channelType !== "dm" && + hasSidebarUnreadProjections && + unreadThreadChannelIds.has(channel.id)); const canLoadOwnerActions = channel.channelType !== "dm" && Boolean(onDeleteChannel); const membersQuery = useChannelMembersQuery(channel.id, canLoadOwnerActions); @@ -204,7 +221,7 @@ export function ChannelContextMenuItems({ canDeleteChannel, ); const showStar = Boolean(onStarChannel && onUnstarChannel); - const showReadToggle = hasUnread + const showReadToggle = hasProjectedUnread ? Boolean(onMarkChannelRead) : Boolean(onMarkChannelUnread); const showMuteToggle = Boolean(onMuteChannel && onUnmuteChannel); @@ -230,12 +247,15 @@ export function ChannelContextMenuItems({ /> ) : null} {showReadToggle ? : null} - {hasUnread && onMarkChannelRead ? ( + {hasProjectedUnread && onMarkChannelRead ? ( - deferMenuAction(() => - onMarkChannelRead(channel.id, channel.lastMessageAt), - ) + deferMenuAction(() => { + for (const itemId of channelUnreadOverrideIds) { + feedItemState.undoUnread(itemId); + } + onMarkChannelRead(channel.id, channel.lastMessageAt); + }) } > @@ -243,7 +263,7 @@ export function ChannelContextMenuItems({ Mark as read - ) : !hasUnread && onMarkChannelUnread ? ( + ) : !hasProjectedUnread && onMarkChannelUnread ? ( deferMenuAction(() => onMarkChannelUnread(channel.id)) diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 3e39c60a6d1..797e907ba17 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -36,6 +36,8 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/shared/ui/sidebar"; +import { ChannelActivityPopover } from "@/features/sidebar/ui/ChannelActivityPopover"; +import { useAppShell } from "@/app/AppShellContext"; const SECTION_LABEL_BUTTON_CLASS = "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground"; @@ -138,7 +140,8 @@ function ChannelWorkingBadge({ return ( ) : null} - {hasUnread && !isActive && channel.channelType !== "dm" ? ( - unreadCount > 0 ? ( - - ) : ( - - ) + {hasThreadUnread ? ( + ) : null} ); + + if (!activeWorking && !hasThreadUnread) { + return button; + } + + return ( + + {button} + + ); } export function SidebarSection({ diff --git a/desktop/src/shared/styles/globals/scrollbars.css b/desktop/src/shared/styles/globals/scrollbars.css index 4614967745f..3d6fa154022 100644 --- a/desktop/src/shared/styles/globals/scrollbars.css +++ b/desktop/src/shared/styles/globals/scrollbars.css @@ -33,6 +33,27 @@ ); } +/* Channel activity keeps its chrome outside the scrolling surface. The slim, + * low-contrast thumb belongs only to the activity list beneath the header. */ +.buzz-channel-activity-scrollbar { + scrollbar-gutter: stable; +} + +.buzz-channel-activity-scrollbar::-webkit-scrollbar { + width: 8px; +} + +.buzz-channel-activity-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.buzz-channel-activity-scrollbar::-webkit-scrollbar-thumb { + background-clip: content-box; + background-color: hsl(var(--foreground) / 0.15); + border: 2px solid transparent; + border-radius: 999px; +} + /* * Main-content scroll areas that have sticky blurred chrome inside them. * Native overlay scrollbars paint UNDER composited descendants diff --git a/desktop/tests/e2e/badge.spec.ts b/desktop/tests/e2e/badge.spec.ts index 0dfdfb549a6..ae40eda61af 100644 --- a/desktop/tests/e2e/badge.spec.ts +++ b/desktop/tests/e2e/badge.spec.ts @@ -105,11 +105,11 @@ test("regular message bolds inactive channel without numeric badge", async ({ "600", ); await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); - await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible(); + await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0); await waitForBadgeState(page, withDotOnlyBadge(baselineBadge)); }); -test("numeric badge increments for @mention in inactive channel", async ({ +test("top-level @mention bolds the channel without a row badge", async ({ page, }) => { await page.goto("/"); @@ -134,7 +134,11 @@ test("numeric badge increments for @mention in inactive channel", async ({ }, ); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0); await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); @@ -158,7 +162,7 @@ test("numeric badge increments for DM message", async ({ page }) => { await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); -test("numeric badge increments for interested thread reply in inactive channel", async ({ +test("interested thread reply shows the channel thread dot", async ({ page, }) => { await page.goto("/"); @@ -190,11 +194,12 @@ test("numeric badge increments for interested thread reply in inactive channel", { parentEventId: rootEventId, pubkey: TEST_IDENTITIES.alice.pubkey }, ); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + await expect(page.getByTestId("channel-unread-dot-random")).toBeVisible(); await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); -test("numeric badge increments for broadcast reply in inactive channel", async ({ +test("broadcast reply bolds the channel without a thread dot", async ({ page, }) => { await page.goto("/"); @@ -219,7 +224,12 @@ test("numeric badge increments for broadcast reply in inactive channel", async ( { pubkey: TEST_IDENTITIES.alice.pubkey }, ); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0); await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); @@ -265,9 +275,7 @@ test("mark-as-read via context menu clears channel unread indicator", async ({ await waitForBadgeState(page, baselineBadge); }); -test("mark-as-unread via context menu increments numeric badge", async ({ - page, -}) => { +test("mark-as-unread via context menu bolds the channel", async ({ page }) => { await page.goto("/"); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); @@ -278,10 +286,55 @@ test("mark-as-unread via context menu increments numeric badge", async ({ await page.getByTestId("channel-random").click({ button: "right" }); await page.getByText("Mark unread").click(); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); + await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0); await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); }); +test("marking a message unread bolds its channel after leaving", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await waitForMockLiveSubscription(page, "random"); + + const message = await page.evaluate( + ({ pubkey }) => + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "random", + content: "Keep this channel message unread", + kind: 40002, + pubkey, + }), + { pubkey: TEST_IDENTITIES.alice.pubkey }, + ); + if (!message) { + throw new Error("Mock message emitter is unavailable"); + } + + const messageRow = page + .getByTestId("message-row") + .filter({ hasText: "Keep this channel message unread" }); + await expect(messageRow).toBeVisible(); + await messageRow.hover(); + await page.getByTestId(`more-actions-${message.id}`).click(); + const toggle = page.getByTestId(`mark-read-toggle-${message.id}`); + await expect(toggle).toHaveText("Mark unread"); + await toggle.click(); + + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-dot-random")).toHaveCount(0); +}); + test("remote read-state rollback is ignored while local mark-unread still increments badge", async ({ page, }) => { @@ -379,11 +432,15 @@ test("remote read-state rollback is ignored while local mark-unread still increm await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); - // Local mark-unread remains an in-session affordance and should still show - // the dot immediately without publishing a lower read timestamp. + // Local mark-unread remains an in-session affordance and should still bold + // the channel immediately without publishing a lower read timestamp. await page.getByTestId("channel-random").click({ button: "right" }); await page.getByText("Mark unread").click(); - await expect(page.getByTestId("channel-unread-random")).toBeVisible(); + await expect(page.getByTestId("channel-random")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-random")).toHaveCount(0); await waitForBadgeState(page, withAdditionalBadgeCount(baselineBadge, 1)); // Step 3: remote advance clears the local forced-unread dot. diff --git a/desktop/tests/e2e/channel-activity-popover.spec.ts b/desktop/tests/e2e/channel-activity-popover.spec.ts new file mode 100644 index 00000000000..f91f1b41de2 --- /dev/null +++ b/desktop/tests/e2e/channel-activity-popover.spec.ts @@ -0,0 +1,795 @@ +import { expect, test, type Page } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const SELF_PUBKEY = "deadbeef".repeat(8); +const CHANNEL_GENERAL = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const AGENT_PUBKEY = TEST_IDENTITIES.charlie.pubkey; + +type MockMessageEvent = { + id: string; + created_at: number; + pubkey: string; +}; + +type MockInboxFeedItem = { + content: string; + id: string; + tags: string[][]; +}; + +async function waitForMockLiveSubscription(page: Page, channelName: string) { + await expect + .poll(() => + page.evaluate( + (name) => + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: name, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + +async function emitMockMessage( + page: Page, + content: string, + options: { + parentEventId?: string; + pubkey: string; + createdAt: number; + mentionPubkeys?: string[]; + }, +): Promise { + const event = await page.evaluate( + ({ body, parentEventId, pubkey, createdAt, mentionPubkeys }) => + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + parentEventId?: string; + pubkey: string; + createdAt: number; + mentionPubkeys?: string[]; + }) => MockMessageEvent; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: body, + parentEventId, + pubkey, + createdAt, + mentionPubkeys, + }), + { + body: content, + parentEventId: options.parentEventId, + pubkey: options.pubkey, + createdAt: options.createdAt, + mentionPubkeys: options.mentionPubkeys, + }, + ); + if (!event) { + throw new Error("Mock message emitter is unavailable"); + } + return event; +} + +async function pushMockInboxFeedItems(page: Page, items: MockInboxFeedItem[]) { + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: unknown }) + .__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__ === "function", + ); + await page.evaluate( + ({ channelId, feedItems, senderPubkey }) => { + const pushFeedItem = ( + window as Window & { + __BUZZ_E2E_PUSH_MOCK_FEED_ITEM__?: (item: { + category: "mention"; + channel_id: string; + channel_name: string; + channel_type: "stream"; + content: string; + created_at: number; + id: string; + kind: number; + pubkey: string; + tags: string[][]; + }) => void; + } + ).__BUZZ_E2E_PUSH_MOCK_FEED_ITEM__; + if (!pushFeedItem) { + throw new Error("Mock feed injection helper is unavailable"); + } + const createdAt = Math.floor(Date.now() / 1_000) - 300; + for (const item of feedItems) { + pushFeedItem({ + category: "mention", + channel_id: channelId, + channel_name: "general", + channel_type: "stream", + content: item.content, + created_at: createdAt, + id: item.id, + kind: 9, + pubkey: senderPubkey, + tags: item.tags, + }); + } + }, + { + channelId: CHANNEL_GENERAL, + feedItems: items, + senderPubkey: TEST_IDENTITIES.alice.pubkey, + }, + ); +} + +async function getForcedUnreadSources(page: Page): Promise { + return page.evaluate( + ({ channelId, pubkey }) => { + const raw = window.localStorage.getItem( + `buzz-forced-unread.v1:${pubkey}`, + ); + if (!raw) return []; + const entry = JSON.parse(raw)?.[channelId]; + if (entry === undefined) return []; + return typeof entry === "object" && entry !== null + ? (entry.sources ?? []) + : ["manual"]; + }, + { channelId: CHANNEL_GENERAL, pubkey: SELF_PUBKEY }, + ); +} + +async function seedChannelActivity( + page: Page, + { + extraThreadCount = 0, + includeAgent = true, + }: { extraThreadCount?: number; includeAgent?: boolean } = {}, +) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + const ownRoot = await emitMockMessage( + page, + "How should the handoff work for longer agent tasks?", + { + pubkey: SELF_PUBKEY, + createdAt: Math.floor(Date.now() / 1000) - 20, + }, + ); + const extraRoots = await Promise.all( + Array.from({ length: extraThreadCount }, (_, index) => + emitMockMessage(page, `Overflow preview thread ${index + 1}`, { + pubkey: SELF_PUBKEY, + createdAt: Math.floor(Date.now() / 1000) - 19 + index, + }), + ), + ); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + + const unreadAt = Math.floor(Date.now() / 1000) + 60; + await emitMockMessage( + page, + "I tightened the empty state and added the direct thread link.", + { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: unreadAt, + mentionPubkeys: [SELF_PUBKEY], + }, + ); + await emitMockMessage( + page, + "The updated interaction keeps context visible without opening the channel first.", + { + parentEventId: ownRoot.id, + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: unreadAt + 1, + }, + ); + await Promise.all( + extraRoots.map((root, index) => + emitMockMessage( + page, + `A new reply in overflow preview thread ${index + 1}`, + { + parentEventId: root.id, + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: unreadAt + 2 + index, + }, + ), + ), + ); + + if (includeAgent) { + await page.waitForFunction( + () => + typeof (window as Window & { __BUZZ_E2E_SEED_ACTIVE_TURNS__?: unknown }) + .__BUZZ_E2E_SEED_ACTIVE_TURNS__ === "function", + ); + await page.evaluate( + ({ agentPubkey, channelId }) => { + ( + window as Window & { + __BUZZ_E2E_SEED_ACTIVE_TURNS__?: (input: { + agentPubkey: string; + channelId: string; + turnId: string; + }) => void; + } + ).__BUZZ_E2E_SEED_ACTIVE_TURNS__?.({ + agentPubkey, + channelId, + turnId: "channel-hover-preview", + }); + }, + { agentPubkey: AGENT_PUBKEY, channelId: CHANNEL_GENERAL }, + ); + } + + await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible(); + if (includeAgent) { + await expect(page.getByTestId("channel-working-general")).toBeVisible(); + } +} + +async function openActivityPopover(page: Page) { + await page.getByTestId("channel-general").hover(); + const popover = page.getByTestId("channel-activity-popover-general"); + await expect(popover).toBeVisible(); + return popover; +} + +test.describe("channel activity hover preview", () => { + test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + managedAgents: [ + { + pubkey: AGENT_PUBKEY, + name: "Charlie", + status: "running", + channelNames: ["general"], + }, + ], + }); + }); + + test("shows unread channel activity and working agents, then opens the selected thread", async ({ + page, + }) => { + await seedChannelActivity(page, { extraThreadCount: 5 }); + const popover = await openActivityPopover(page); + + const heading = popover.getByRole("heading", { + name: "Channel activity", + }); + await expect(heading).toBeVisible(); + await expect(heading).toHaveCSS("backdrop-filter", /blur/); + const activityScroll = popover.getByTestId("channel-activity-scroll"); + await expect(activityScroll).toHaveCSS("overflow-y", "auto"); + await expect(activityScroll.getByRole("heading")).toHaveCount(0); + await expect + .poll(() => + activityScroll.evaluate( + (element) => element.scrollHeight > element.clientHeight, + ), + ) + .toBe(true); + await waitForAnimations(page); + const headingY = (await heading.boundingBox())?.y; + const scrollY = (await activityScroll.boundingBox())?.y; + const headingBottom = await heading.evaluate( + (element) => element.getBoundingClientRect().bottom, + ); + expect(scrollY).toBeGreaterThanOrEqual(headingBottom - 1); + await activityScroll.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); + await expect + .poll(() => activityScroll.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + const scrolledHeadingY = (await heading.boundingBox())?.y; + expect(headingY).toBeDefined(); + expect(scrolledHeadingY).toBeDefined(); + expect(Math.abs((scrolledHeadingY ?? 0) - (headingY ?? 0))).toBeLessThan( + 0.5, + ); + await activityScroll.evaluate((element) => { + element.scrollTop = 0; + }); + await expect + .poll(() => + activityScroll.evaluate( + (element) => + getComputedStyle(element, "::-webkit-scrollbar-thumb") + .backgroundColor, + ), + ) + .toMatch(/0\.15\)/); + await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(7); + await expect(popover).toContainText( + "I tightened the empty state and added the direct thread link.", + ); + await expect(popover.getByRole("heading")).toHaveCount(1); + await expect( + popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), + ).toContainText("Charlie"); + await expect( + popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`), + ).toContainText("Working"); + const orderedRows = popover.locator( + '[data-testid^="channel-activity-agent-"], [data-testid^="channel-activity-item-"]', + ); + await expect(orderedRows.first()).toHaveAttribute( + "data-testid", + `channel-activity-agent-${AGENT_PUBKEY}`, + ); + + await waitForAnimations(page); + await page.screenshot({ + clip: { x: 0, y: 80, width: 720, height: 640 }, + path: "test-results/channel-activity-hover/channel-activity-popover.png", + }); + + const targetRow = popover + .getByTestId(/^channel-activity-item-/) + .filter({ hasText: "direct thread link" }); + await targetRow.getByRole("button", { name: /Open thread/ }).click(); + + await expect(page.getByTestId("chat-title")).toHaveText("general"); + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + await expect(threadPanel).toContainText("direct thread link"); + }); + + test("removes the dot and preview after the final activity is read", async ({ + page, + }) => { + await seedChannelActivity(page, { includeAgent: false }); + const popover = await openActivityPopover(page); + await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(2); + + for (let remaining = 1; remaining >= 0; remaining -= 1) { + const row = popover.getByTestId(/^channel-activity-item-/).first(); + await row.hover(); + await row.getByRole("button", { name: "Mark as read" }).click(); + await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount( + remaining, + ); + } + + await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0); + await expect( + page.getByTestId("channel-activity-popover-general"), + ).toHaveCount(0); + }); + + test("groups multiple unread replies against the previewed channel", async ({ + page, + }) => { + await seedChannelActivity(page, { includeAgent: false }); + await emitMockMessage( + page, + "A second unread reply should stay grouped with this thread.", + { + parentEventId: "mock-general-welcome", + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: Math.floor(Date.now() / 1_000) + 120, + mentionPubkeys: [SELF_PUBKEY], + }, + ); + + const popover = await openActivityPopover(page); + const groupedRow = popover + .getByTestId(/^channel-activity-item-/) + .filter({ hasText: "direct thread link" }); + await expect(groupedRow).toContainText("2 unread"); + }); + + test("marks projected thread activity read from the active channel menu", async ({ + page, + }) => { + await seedChannelActivity(page, { includeAgent: false }); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible(); + + await page.getByTestId("channel-general").click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Mark as read" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Mark unread" }), + ).toHaveCount(0); + await page.getByRole("menuitem", { name: "Mark as read" }).click(); + + await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0); + }); + + test("marks projected thread activity read in the active channel with Shift+Escape", async ({ + page, + }) => { + await seedChannelActivity(page, { includeAgent: false }); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible(); + + const shortcutHandled = await page.evaluate(() => { + const event = new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + shiftKey: true, + }); + window.dispatchEvent(event); + return event.defaultPrevented; + }); + expect(shortcutHandled).toBe(true); + + await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0); + await page.getByTestId("channel-general").hover(); + await expect( + page.getByTestId("channel-activity-popover-general"), + ).toHaveCount(0); + }); + + test("supports row actions and opens an agent's scoped activity", async ({ + page, + }) => { + await seedChannelActivity(page); + let popover = await openActivityPopover(page); + const initialRows = popover.getByTestId(/^channel-activity-item-/); + await expect(initialRows).toHaveCount(2); + + const firstRow = initialRows.first(); + await firstRow.hover(); + await firstRow.getByRole("button", { name: "Mark as read" }).click(); + await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(1); + + const remainingRow = popover.getByTestId(/^channel-activity-item-/).first(); + await remainingRow.hover(); + await remainingRow.getByRole("button", { name: "Remind me later" }).click(); + await expect( + page.getByRole("dialog").getByText("Remind me later"), + ).toBeVisible(); + await page.getByRole("button", { name: "Cancel" }).click(); + + popover = await openActivityPopover(page); + await popover.getByTestId(`channel-activity-agent-${AGENT_PUBKEY}`).click(); + + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("agent-session-agent-name")).toContainText( + "Charlie", + ); + await expect(page.getByTestId("agent-session-scope-label")).toContainText( + "#general", + ); + }); + + test("keeps multiple Inbox threads visible after opening their channel", async ({ + page, + }) => { + const inboxItemIds = [ + "older-inbox-thread-for-hover", + "second-inbox-thread-for-hover", + ]; + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByRole("button", { name: "Inbox", exact: true }).click(); + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + await pushMockInboxFeedItems( + page, + inboxItemIds.map((id, index) => ({ + content: + index === 0 + ? "Older Inbox thread reopened for hover testing." + : "A second Inbox thread reopened for hover testing.", + id, + tags: [ + ["h", CHANNEL_GENERAL], + [ + "e", + index === 0 + ? "older-inbox-thread-root" + : "second-inbox-thread-root", + "", + "root", + ], + [ + "e", + index === 0 + ? "older-inbox-thread-parent" + : "second-inbox-thread-parent", + "", + "reply", + ], + ["p", TEST_IDENTITIES.tyler.pubkey], + ], + })), + ); + + for (const itemId of inboxItemIds) { + const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`); + await expect(inboxRow).toBeVisible(); + await inboxRow.hover(); + await inboxRow.getByRole("button", { name: "Mark as read" }).click(); + await inboxRow.hover(); + await inboxRow.getByRole("button", { name: "Mark unread" }).click(); + } + + await expect(page.getByTestId("channel-general")).toHaveCSS( + "font-weight", + "600", + ); + for (const [index, itemId] of inboxItemIds.entries()) { + const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`); + await inboxRow.hover(); + await inboxRow.getByRole("button", { name: "Mark as read" }).click(); + if (index === 0) { + await expect(page.getByTestId("channel-general")).toHaveCSS( + "font-weight", + "600", + ); + } + } + await expect(page.getByTestId("channel-general")).not.toHaveCSS( + "font-weight", + "600", + ); + for (const itemId of inboxItemIds) { + const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`); + await inboxRow.hover(); + await inboxRow.getByRole("button", { name: "Mark unread" }).click(); + } + + await expect(page.getByTestId("channel-general")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible(); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible(); + await page.mouse.move(900, 680); + let popover = await openActivityPopover(page); + await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(2); + await expect(popover).toContainText("Older Inbox thread reopened"); + await expect(popover).toContainText("A second Inbox thread reopened"); + await expect(popover.getByText("Thread", { exact: true })).toHaveCount(2); + + await popover + .getByTestId(/^channel-activity-item-/) + .filter({ hasText: "Older Inbox thread reopened" }) + .getByRole("button", { name: /Open thread/ }) + .click(); + await expect(popover).toBeHidden(); + await page.mouse.move(900, 680); + popover = await openActivityPopover(page); + await expect(popover.getByTestId(/^channel-activity-item-/)).toHaveCount(1); + await expect(popover).not.toContainText("Older Inbox thread reopened"); + await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible(); + await expect(page.getByTestId("channel-general")).toHaveCSS( + "font-weight", + "600", + ); + + await page.mouse.move(900, 680); + await expect(popover).toBeHidden(); + await page.getByRole("button", { name: "Inbox", exact: true }).click(); + const topLevelItemId = "top-level-inbox-item-for-channel-read"; + await pushMockInboxFeedItems(page, [ + { + content: "Top-level Inbox item reopened for channel read testing.", + id: topLevelItemId, + tags: [ + ["h", CHANNEL_GENERAL], + ["p", TEST_IDENTITIES.tyler.pubkey], + ], + }, + ]); + const topLevelInboxRow = page.getByTestId( + `home-inbox-item-${topLevelItemId}`, + ); + await expect(topLevelInboxRow).toBeVisible(); + await topLevelInboxRow.hover(); + await topLevelInboxRow.getByRole("button", { name: "Mark unread" }).click(); + await page.getByTestId("channel-general").click({ button: "right" }); + await page.getByRole("menuitem", { name: "Mark as read" }).click(); + await topLevelInboxRow.hover(); + await expect( + topLevelInboxRow.getByRole("button", { name: "Mark unread" }), + ).toBeVisible(); + await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0); + await page.getByTestId("channel-general").hover(); + await expect( + page.getByTestId("channel-activity-popover-general"), + ).toHaveCount(0); + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await expect(page.getByTestId("channel-general")).not.toHaveCSS( + "font-weight", + "600", + ); + }); + + test("reading a grouped Inbox thread preserves an unrelated manual unread", async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + const manualUnreadMessage = await emitMockMessage( + page, + "Keep this separate timeline message unread.", + { + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: Math.floor(Date.now() / 1_000), + }, + ); + const manualUnreadRow = page + .getByTestId("message-row") + .filter({ hasText: "Keep this separate timeline message unread." }); + await manualUnreadRow.hover(); + await page.getByTestId(`more-actions-${manualUnreadMessage.id}`).click(); + await page + .getByTestId(`mark-read-toggle-${manualUnreadMessage.id}`) + .click(); + + await page.getByRole("button", { name: "Inbox", exact: true }).click(); + const groupedRootId = "grouped-inbox-root-preserve-manual"; + const groupedReplyId = "grouped-inbox-reply-preserve-manual"; + await pushMockInboxFeedItems(page, [ + { + content: "Grouped Inbox root for manual unread preservation.", + id: groupedRootId, + tags: [ + ["h", CHANNEL_GENERAL], + ["p", TEST_IDENTITIES.tyler.pubkey], + ], + }, + { + content: "Grouped Inbox reply marked read independently.", + id: groupedReplyId, + tags: [ + ["h", CHANNEL_GENERAL], + ["e", groupedRootId, "", "root"], + ["e", groupedRootId, "", "reply"], + ["p", TEST_IDENTITIES.tyler.pubkey], + ], + }, + ]); + const groupedInboxRow = page.getByTestId( + `home-inbox-item-${groupedReplyId}`, + ); + await expect(groupedInboxRow).toBeVisible(); + await groupedInboxRow.hover(); + await groupedInboxRow.getByRole("button", { name: "Mark as read" }).click(); + + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("channel-general")).toHaveCSS( + "font-weight", + "600", + ); + }); + + test("preserves Inbox ownership while another top-level row remains unread", async ({ + page, + }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.getByRole("button", { name: "Inbox", exact: true }).click(); + const topLevelItemIds = [ + "first-top-level-inbox-owner", + "second-top-level-inbox-owner", + ]; + await pushMockInboxFeedItems( + page, + topLevelItemIds.map((id, index) => ({ + content: `Top-level Inbox owner ${index + 1}.`, + id, + tags: [ + ["h", CHANNEL_GENERAL], + ["p", TEST_IDENTITIES.tyler.pubkey], + ], + })), + ); + for (const itemId of topLevelItemIds) { + const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`); + await expect(inboxRow).toBeVisible(); + await inboxRow.hover(); + await inboxRow.getByRole("button", { name: "Mark unread" }).click(); + } + await expect(page.getByTestId("channel-general")).toHaveCSS( + "font-weight", + "600", + ); + await expect.poll(() => getForcedUnreadSources(page)).toEqual(["inbox"]); + + await page.getByRole("button", { name: "Inbox", exact: true }).click(); + for (const [index, itemId] of topLevelItemIds.entries()) { + const inboxRow = page.getByTestId(`home-inbox-item-${itemId}`); + await inboxRow.hover(); + await inboxRow.getByRole("button", { name: "Mark as read" }).click(); + if (index === 0) { + await expect(page.getByTestId("channel-general")).toHaveCSS( + "font-weight", + "600", + ); + await expect + .poll(() => getForcedUnreadSources(page)) + .toEqual(["inbox"]); + } + } + await expect.poll(() => getForcedUnreadSources(page)).toEqual([]); + }); + + test("surfaces future replies after the user reacts to a thread root", async ({ + 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, + "Reacting here means I care about future replies.", + { + pubkey: TEST_IDENTITIES.alice.pubkey, + createdAt: Math.floor(Date.now() / 1_000) - 20, + }, + ); + const rootRow = page + .getByTestId("message-row") + .filter({ hasText: "Reacting here means I care" }); + await expect(rootRow).toBeVisible(); + await rootRow.hover(); + const actionBar = page.getByTestId(`message-action-bar-${root.id}`); + await expect(actionBar).toBeVisible(); + await actionBar + .getByRole("button", { name: /^React with / }) + .first() + .click(); + await expect( + rootRow.getByRole("button", { name: /^Toggle .* reaction$/ }), + ).toBeVisible(); + + await page.getByTestId("channel-random").click(); + await emitMockMessage( + page, + "This follow-up appears because the root was reacted to.", + { + parentEventId: root.id, + pubkey: TEST_IDENTITIES.bob.pubkey, + createdAt: Math.floor(Date.now() / 1_000) + 60, + }, + ); + + await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible(); + const popover = await openActivityPopover(page); + await expect(popover).toContainText( + "This follow-up appears because the root was reacted to.", + ); + }); +}); diff --git a/desktop/tests/e2e/channel-mute.spec.ts b/desktop/tests/e2e/channel-mute.spec.ts index a710ca245f7..23602f486af 100644 --- a/desktop/tests/e2e/channel-mute.spec.ts +++ b/desktop/tests/e2e/channel-mute.spec.ts @@ -85,7 +85,7 @@ test.describe("channel muting", () => { await expect(engRow.locator("svg.lucide-bell-off")).toHaveCount(1); }); - test("03 — muted channel with @mention shows unread dot", async ({ + test("03 — muted channel with a top-level @mention is emphasized", async ({ page, }) => { await seedMuteState(page, ENGINEERING_CHANNEL_ID); @@ -123,7 +123,13 @@ test.describe("channel muting", () => { }, ); - await expect(page.getByTestId("channel-unread-engineering")).toBeVisible(); + await expect(page.getByTestId("channel-engineering")).toHaveCSS( + "font-weight", + "600", + ); + await expect( + page.getByTestId("channel-unread-dot-engineering"), + ).toHaveCount(0); }); test("04 — context menu shows Unmute channel when muted", async ({ diff --git a/desktop/tests/e2e/thread-unread.spec.ts b/desktop/tests/e2e/thread-unread.spec.ts index 586ca3dd123..f4f45d3319a 100644 --- a/desktop/tests/e2e/thread-unread.spec.ts +++ b/desktop/tests/e2e/thread-unread.spec.ts @@ -711,7 +711,7 @@ test.describe("thread unread indicator", () => { await expect(badge).toContainText("3"); }); - // Thread-only replies now also light the channel sidebar badge. Viewing the + // Thread-only replies now also light the channel sidebar dot. Viewing the // channel should leave unopened thread replies unread until the thread is read. test("11-thread-reply-lights-sidebar-badge-after-channel-view", async ({ page, @@ -748,15 +748,15 @@ test.describe("thread unread indicator", () => { await expect(page.getByTestId("chat-title")).toHaveText("general"); // The crux: leave general. The unopened thread reply should still keep a - // numeric channel sidebar badge until the thread itself is read. + // channel sidebar dot until the thread itself is read. await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); - await expect(page.getByTestId("channel-unread-general")).toBeVisible(); + await expect(page.getByTestId("channel-unread-dot-general")).toBeVisible(); }); // Regression guard for the all-replies window: when the loaded window holds // ONLY thread replies (the top-level root has scrolled past the history - // limit), thread-only activity should still light the channel sidebar badge. + // limit), thread-only activity should still light the channel sidebar dot. // // The `all-replies` fixture carries a far-future `lastMessageAt` (standing in // for the backend's reply-inclusive MAX) with no top-level message in its @@ -768,8 +768,7 @@ test.describe("thread unread indicator", () => { // Emit ONE reply whose parent root is NOT in the window (orphan parent id), // so the loaded window is all-replies: no top-level message exists for // `latestActiveMessage` to find. The reply mentions the current user so it - // clears the notify gate and creates Inbox activity without lighting the - // channel sidebar dot. + // clears the notify gate and creates Inbox thread activity. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); await waitForMockLiveSubscription(page, "all-replies"); @@ -779,17 +778,21 @@ test.describe("thread unread indicator", () => { mentionPubkeys: [SELF_PUBKEY], createdAt: unreadTimestamp(), }); - await expect(page.getByTestId("channel-unread-all-replies")).toHaveCount(0); + await expect( + page.getByTestId("channel-unread-dot-all-replies"), + ).toBeVisible(); // View all-replies while the reply is unread. await page.getByTestId("channel-all-replies").click(); await expect(page.getByTestId("chat-title")).toHaveText("all-replies"); // The crux: leave the channel. Its unopened thread reply should still keep - // a numeric channel sidebar badge until the thread itself is read. + // a channel sidebar dot until the thread itself is read. await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); - await expect(page.getByTestId("channel-unread-all-replies")).toBeVisible(); + await expect( + page.getByTestId("channel-unread-dot-all-replies"), + ).toBeVisible(); }); // Regression guard for BUG-2 (clear-on-read): opening an unread thread marks diff --git a/desktop/tests/e2e/unread-pill.spec.ts b/desktop/tests/e2e/unread-pill.spec.ts index 63f42d12ac5..88169d96365 100644 --- a/desktop/tests/e2e/unread-pill.spec.ts +++ b/desktop/tests/e2e/unread-pill.spec.ts @@ -198,9 +198,12 @@ test.describe("unread pill & divider", () => { await page.getByTestId("channel-random").click(); await expect(page.getByTestId("chat-title")).toHaveText("random"); - // The unread indicator only renders on inactive channels, so it appears - // once general is no longer the active channel. - await expect(page.getByTestId("channel-unread-general")).toBeVisible(); + // Forced channel unread uses channel-name emphasis, not the thread dot. + await expect(page.getByTestId("channel-general")).toHaveCSS( + "font-weight", + "600", + ); + await expect(page.getByTestId("channel-unread-dot-general")).toHaveCount(0); await page.getByTestId("channel-general").click(); await expect(page.getByTestId("chat-title")).toHaveText("general"); From 01c80aa9b3eaa569361966877994438ad84a280a Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 3 Aug 2026 09:29:40 -0700 Subject: [PATCH 018/163] fix(desktop): save key backups to authorized path (#4022) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** fix **User Impact:** Users can save password-protected identity backups directly to protected macOS folders such as Downloads. **Problem:** Signed macOS builds could not save a portable `.ncryptsec` backup to Downloads because the atomic writer created an unauthorized sibling temporary file. This surfaced as an “Operation not permitted” error after the user completed backup creation. **Solution:** Portable exports now write only to the exact path authorized by the native Save panel, sync and verify the saved bytes, and refuse to truncate an existing backup. Buzz’s app-managed backup retains its atomic writer and durability guarantees.
File changes **desktop/src-tauri/src/commands/export_util.rs** Clarifies that secret exports use a dedicated writer compatible with native Save-panel authorization. **desktop/src-tauri/src/commands/identity.rs** Routes portable NIP-49 exports through the Save-panel-compatible writer while preserving canonical app state. **desktop/src-tauri/src/key_backup.rs** Adds an exclusive-create portable writer with owner-only permissions, disk sync, byte verification, and cleanup on failure. Keeps the existing atomic writer for app-managed backups. **desktop/src-tauri/src/key_backup_tests.rs** Covers portable export permissions, absence of sibling files, and preservation of existing backups.
## Reproduction steps 1. Install a signed macOS build containing this change. 2. Open **Settings → Profile → Private key → Create backup** and complete backup creation. 3. Save a fresh `identity.ncryptsec` file into `~/Downloads` and confirm Buzz reports success. 4. Open and verify the saved backup with its password. 5. Repeat the save using an existing filename and confirm Buzz preserves the existing file and asks for a new filename. ## Verification - Full desktop Tauri suite: 2,049 passed, 14 ignored - Diagnostic suite: 3 passed - Focused backup coverage: 30 passed - Tauri clippy (`--all-targets -D warnings`), Rust formatting, and `git diff --check`: passed - Push hooks: org safety, branch skew, and desktop Tauri checks passed Signed-production Downloads smoke remains required after merge because the signing workflow is restricted to `main`. Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/export_util.rs | 4 +- desktop/src-tauri/src/commands/identity.rs | 9 +-- desktop/src-tauri/src/key_backup.rs | 59 ++++++++++++++++++- desktop/src-tauri/src/key_backup_tests.rs | 39 ++++++++++++ 4 files changed, 103 insertions(+), 8 deletions(-) diff --git a/desktop/src-tauri/src/commands/export_util.rs b/desktop/src-tauri/src/commands/export_util.rs index ded14679c10..e12cbd19e13 100644 --- a/desktop/src-tauri/src/commands/export_util.rs +++ b/desktop/src-tauri/src/commands/export_util.rs @@ -35,8 +35,8 @@ pub async fn pick_save_path( /// 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`. +/// 0o600). Secret exports go through `pick_save_path` and a dedicated +/// secret-file writer such as `key_backup::write_portable_backup_file`. pub async fn save_bytes_with_dialog( app: &AppHandle, suggested_filename: &str, diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 33ecf3cfca3..bddf2e725a7 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -297,9 +297,10 @@ pub async fn verify_ncryptsec_backup( /// 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. +/// selection-only; the write uses the exact save-panel-authorized path with +/// owner-only permissions, sync, and reread verification. Existing files are +/// preserved rather than truncated. 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, @@ -324,7 +325,7 @@ pub async fn save_ncryptsec_copy( let dest_for_write = dest.clone(); tokio::task::spawn_blocking(move || { - crate::key_backup::write_backup_file(&dest_for_write, &normalized) + crate::key_backup::write_portable_backup_file(&dest_for_write, &normalized) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))??; diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index f97bf95a676..e8fcc8abe42 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -133,9 +133,14 @@ 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 +/// Atomically write the app-managed `ncryptsec` backup with owner-only +/// permissions, then reread and byte-compare. Same crash-safety pattern as /// `app_state::save_key_file`. +/// +/// Portable exports selected through a native save panel must use +/// [`write_portable_backup_file`] instead: sandboxed macOS grants access to the +/// selected path, but not to the sibling temporary file this writer needs. +#[allow(dead_code)] // Retained for durable app-managed backups; portable exports must not use it. pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { use atomic_write_file::AtomicWriteFile; use std::io::Write; @@ -155,6 +160,56 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), file.commit() .map_err(|e| format!("commit backup file: {e}"))?; + verify_backup_file(path, ncryptsec) +} + +/// Write a user-selected portable backup without creating a sibling file. +/// +/// Native macOS save panels authorize the exact selected path in protected +/// folders such as Downloads, not an atomic writer's hidden sibling. Opening +/// with `create_new` uses only that authorized path and also guarantees an +/// existing backup is never truncated: users must choose a new filename when +/// the destination already exists. After writing, the file is synced and its +/// persisted bytes are reread before success is reported. +pub fn write_portable_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { + use std::io::Write; + + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + + let mut file = options.open(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::AlreadyExists { + "backup file already exists; choose a new filename so the existing backup stays safe" + .to_string() + } else { + format!("create portable backup file: {error}") + } + })?; + + let write_result = file + .write_all(ncryptsec.as_bytes()) + .map_err(|e| format!("write portable backup file: {e}")) + .and_then(|()| { + file.sync_all() + .map_err(|e| format!("sync portable backup file: {e}")) + }); + drop(file); + + let result = write_result.and_then(|()| verify_backup_file(path, ncryptsec)); + if result.is_err() { + // This function created the destination exclusively, so cleanup cannot + // clobber a backup that existed before the save attempt. + let _ = std::fs::remove_file(path); + } + result +} + +fn verify_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), String> { // 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}"))?; diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index b9713201e17..35b486f78dd 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -160,6 +160,45 @@ fn write_backup_file_overwrites_atomically() { assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); } +#[test] +fn write_portable_backup_file_persists_0600_without_a_sibling() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("portable.ncryptsec"); + write_portable_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); + let entries: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!( + entries, + vec![std::ffi::OsString::from("portable.ncryptsec")] + ); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "portable backup must be owner-only"); + } +} + +#[test] +fn write_portable_backup_file_preserves_an_existing_backup() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("portable.ncryptsec"); + std::fs::write(&path, "ncryptsec1existing").unwrap(); + + let error = write_portable_backup_file(&path, SPEC_NCRYPTSEC).unwrap_err(); + + assert!(error.contains("already exists"), "{error}"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "ncryptsec1existing" + ); +} + #[test] fn delete_backup_file_is_idempotent() { let dir = tempfile::tempdir().unwrap(); From c1b88af8d71d1cf6aaca517e92ce9e918cd0e8bd Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 3 Aug 2026 10:30:15 -0600 Subject: [PATCH 019/163] feat(desktop): improve channel template discovery (#4549) ## Summary - move **Channel templates** from Communities to Personal settings - always expose the template picker in New Channel, using **None** as the no-template value - create a channel template directly from the picker and select it on return - preview the selected template's current visibility, canvas, agents, and teams - order the channel-creation controls as **Type / Visibility / Template** and mark Template **Optional** - cover populated and empty libraries, inline creation, selection, visibility overrides, mixed agent/team inventory, field order, optional labeling, and settings navigation in Playwright ## Validation Validated at desktop-only tip `76442270c88aa1d533ddca5de9f87cd615183919` with a clean worktree: - focused channel-template Playwright: 2/2 passed - Type / Visibility / Template ordering and muted Optional treatment visually inspected in the replacement screenshot - `git diff --check origin/main...HEAD` passed - PR diff contains exactly nine Desktop files and no Mobile files The pre-push hook was bypassed only for the corrected history push because the inherited Mobile test `keeps follow mode off while a tall newest message stays visible` passes in Linux CI but fails on macOS because its offscreen-child mounting assertion is platform-sensitive. No Mobile code or tests are changed by this PR. ## Screenshot ![New Channel with Type, Visibility, and optional Template](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/4549/create-channel-type-visibility-template.png) Originating Buzz channel: `efba7343-e147-48b7-a2aa-15a5f04abc57` --------- Signed-off-by: Wes Co-authored-by: Carl --- .../channels/ui/ChannelTypeSettings.tsx | 6 +- .../ui/ChannelTemplatesSettingsCard.tsx | 7 +- .../features/settings/ui/SettingsPanels.tsx | 2 +- .../src/features/settings/ui/SettingsView.tsx | 3 +- .../sidebar/lib/useCreateChannelForm.ts | 16 +- .../sidebar/ui/CreateChannelFormFields.tsx | 158 +++++++++++------- desktop/src/testing/e2eBridge.ts | 44 +++++ desktop/tests/e2e/channels.spec.ts | 81 ++++++++- desktop/tests/e2e/profile.spec.ts | 7 + 9 files changed, 250 insertions(+), 74 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx index 1ff11d2104e..6883f4cad1a 100644 --- a/desktop/src/features/channels/ui/ChannelTypeSettings.tsx +++ b/desktop/src/features/channels/ui/ChannelTypeSettings.tsx @@ -34,6 +34,7 @@ const CHANNEL_TYPE_RESIZE_TRANSITION = { export function ChannelTypeSettings({ disabled, + label = "Channel type", onOpenChange, onTemporaryChange, onTtlSecondsChange, @@ -43,6 +44,7 @@ export function ChannelTypeSettings({ ttlSeconds, }: { disabled?: boolean; + label?: string; onOpenChange?: (open: boolean) => void; onTemporaryChange: (temporary: boolean) => void; onTtlSecondsChange: (ttlSeconds: number) => void; @@ -77,9 +79,7 @@ export function ChannelTypeSettings({ className="flex items-center justify-between gap-3 px-3 py-3" data-testid={`${testIdPrefix}-channel-type-row`} > - - Channel type - + {label} void; + onCreated?: (template: ChannelTemplate) => void; }) { const isEditing = template !== null; const createMutation = useCreateChannelTemplateMutation(); @@ -388,8 +390,9 @@ function TemplateFormDialog({ }; createMutation.mutate(input, { - onSuccess: () => { + onSuccess: (created) => { toast.success(`Created "${trimmedName}"`); + onCreated?.(created); onOpenChange(false); }, onError: (error) => { diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 5c997efbc42..162150e7c64 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -189,7 +189,7 @@ export const settingsSections: SettingsSectionDescriptor[] = [ }, { value: "channel-templates", - label: "Templates", + label: "Channel templates", icon: LayoutTemplate, featureGate: "channel-templates", }, diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 8613880571c..991029dca7d 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -62,11 +62,12 @@ const settingsNavGroups: Array<{ "shortcuts", "custom-emoji", "local-archive", + "channel-templates", ], }, { label: "Communities", - sections: ["hosted-communities", "channel-templates", "community-members"], + sections: ["hosted-communities", "community-members"], }, { label: "App", diff --git a/desktop/src/features/sidebar/lib/useCreateChannelForm.ts b/desktop/src/features/sidebar/lib/useCreateChannelForm.ts index b227c0ae326..af03b9e4dd7 100644 --- a/desktop/src/features/sidebar/lib/useCreateChannelForm.ts +++ b/desktop/src/features/sidebar/lib/useCreateChannelForm.ts @@ -46,6 +46,7 @@ export type CreateChannelFormState = { errorMessage: string | null; selectedTemplateId: string | null; handleTemplateChange: (templateId: string) => void; + handleTemplateCreated: (template: ChannelTemplate) => void; templates: ChannelTemplate[]; nameInputRef: React.RefObject; isCreating: boolean; @@ -120,6 +121,13 @@ export function useCreateChannelForm({ return () => globalThis.clearTimeout(timerId); }, [active, autoFocusName, initialName]); + const applyTemplate = React.useCallback((template: ChannelTemplate) => { + setSelectedTemplateId(template.id); + setDescription(template.description ?? ""); + if (!visibilityTouchedRef.current) setVisibility(template.visibility); + setErrorMessage(null); + }, []); + const handleTemplateChange = React.useCallback( (templateId: string) => { if (!templateId) { @@ -135,12 +143,9 @@ export function useCreateChannelForm({ ); if (!template) return; - setSelectedTemplateId(templateId); - setDescription(template.description ?? ""); - if (!visibilityTouchedRef.current) setVisibility(template.visibility); - setErrorMessage(null); + applyTemplate(template); }, - [templates], + [applyTemplate, templates], ); const handleSubmit = React.useCallback( @@ -211,6 +216,7 @@ export function useCreateChannelForm({ errorMessage, selectedTemplateId, handleTemplateChange, + handleTemplateCreated: applyTemplate, templates, nameInputRef, isCreating, diff --git a/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx b/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx index 5fadf2a10af..01f5af478e5 100644 --- a/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx +++ b/desktop/src/features/sidebar/ui/CreateChannelFormFields.tsx @@ -1,13 +1,16 @@ -import { ChevronDown } from "lucide-react"; +import { ChevronDown, Plus } from "lucide-react"; +import * as React from "react"; -import type { ChannelTemplate } from "@/shared/api/types"; +import { TemplateFormDialog } from "@/features/settings/ui/ChannelTemplatesSettingsCard"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, DropdownMenuContent, + DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; @@ -39,9 +42,27 @@ export function CreateChannelFormFields({ form: CreateChannelFormState; }) { const { channelKind, kindLabel, isCreating } = form; + const [isCreateTemplateOpen, setIsCreateTemplateOpen] = React.useState(false); const selectedTemplate = form.templates.find( (template) => template.id === form.selectedTemplateId, ); + const selectedTemplatePersonaCount = + selectedTemplate?.agents.personas.length ?? 0; + const selectedTemplateTeamCount = selectedTemplate?.agents.teams.length ?? 0; + const selectedTemplateSummary = selectedTemplate + ? [ + form.visibility === "private" ? "Private" : "Open", + selectedTemplate.canvasTemplate ? "Canvas included" : null, + selectedTemplatePersonaCount > 0 + ? `${selectedTemplatePersonaCount} ${selectedTemplatePersonaCount === 1 ? "agent" : "agents"}` + : null, + selectedTemplateTeamCount > 0 + ? `${selectedTemplateTeamCount} ${selectedTemplateTeamCount === 1 ? "team" : "teams"}` + : null, + ] + .filter(Boolean) + .join(" · ") + : null; return (
@@ -107,6 +128,7 @@ export function CreateChannelFormFields({ - {form.templates.length > 0 ? ( -
- - Template - Optional - - - - - - event.preventDefault()} - style={{ - minWidth: "var(--radix-dropdown-menu-trigger-width)", - }} - > - - form.handleTemplateChange( - templateId === NO_TEMPLATE_VALUE ? "" : templateId, - ) - } - value={form.selectedTemplateId ?? NO_TEMPLATE_VALUE} - > - - No template - - {form.templates.map((template: ChannelTemplate) => ( - - {template.name} - - ))} - - - -
- ) : null} - +
+ + Template + Optional + + + + + + event.preventDefault()} + style={{ + minWidth: "var(--radix-dropdown-menu-trigger-width)", + }} + > + + form.handleTemplateChange( + templateId === NO_TEMPLATE_VALUE ? "" : templateId, + ) + } + value={form.selectedTemplateId ?? NO_TEMPLATE_VALUE} + > + + None + + {form.templates.map((template) => ( + + {template.name} + + ))} + + + setIsCreateTemplateOpen(true)}> + + Create new channel template… + + + + +
+ {selectedTemplateSummary ? ( +

+ {selectedTemplateSummary} +

+ ) : null} + {form.errorMessage ? (

{form.errorMessage}

) : null} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 037274176ec..03d37ed72ed 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11201,6 +11201,50 @@ export function maybeInstallE2eTauriMocks() { created_at: template.createdAt, updated_at: template.updatedAt, })); + case "create_channel_template": { + const { input } = payload as { + input: { + name: string; + description?: string; + channelType?: "stream" | "forum"; + visibility?: "open" | "private"; + canvasTemplate?: string; + agents?: ChannelTemplate["agents"]; + }; + }; + const timestamp = new Date().toISOString(); + const created: ChannelTemplate = { + id: `template-${Date.now()}`, + name: input.name, + description: input.description ?? null, + channelType: input.channelType ?? "stream", + visibility: input.visibility ?? "open", + canvasTemplate: input.canvasTemplate ?? null, + agents: input.agents ?? { personas: [], teams: [] }, + isBuiltin: false, + createdAt: timestamp, + updatedAt: timestamp, + }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.channelTemplates = [ + ...(activeConfig.mock.channelTemplates ?? []), + created, + ]; + } + return { + id: created.id, + name: created.name, + description: created.description, + channel_type: created.channelType, + visibility: created.visibility, + canvas_template: created.canvasTemplate, + agents: created.agents, + is_builtin: created.isBuiltin, + created_at: created.createdAt, + updated_at: created.updatedAt, + }; + } case "create_team": return handleCreateTeam( payload as Parameters[0], diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 9da40224205..e9c5b42638b 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1336,8 +1336,26 @@ test("create channel template selector matches the lifecycle controls", async ({ description: "Coordinate a new project from planning through launch.", channelType: "stream", visibility: "private", - canvasTemplate: null, - agents: { personas: [], teams: [] }, + canvasTemplate: "# {channel.name}\n\nKickoff notes", + agents: { + personas: [ + { + personaId: "planner", + runtime: null, + model: null, + role: null, + backend: null, + }, + ], + teams: [ + { + teamId: "research-team", + runtime: null, + model: null, + backend: null, + }, + ], + }, isBuiltin: false, createdAt: "2026-07-23T00:00:00Z", updatedAt: "2026-07-23T00:00:00Z", @@ -1350,17 +1368,74 @@ test("create channel template selector matches the lifecycle controls", async ({ const templateControl = page.getByTestId("create-channel-template"); await expect(templateControl).toHaveRole("button"); - await expect(templateControl).toHaveText("No template"); + await expect(templateControl).toHaveText("None"); await templateControl.click(); + await expect( + page.getByRole("menuitem", { name: "Create new channel template…" }), + ).toBeVisible(); await page.getByRole("menuitemradio", { name: "Project kickoff" }).click(); await expect(templateControl).toHaveText("Project kickoff"); + await expect(page.getByTestId("create-channel-template-summary")).toHaveText( + "Private · Canvas included · 1 agent · 1 team", + ); await expect(page.getByTestId("create-channel-description")).toHaveValue( "Coordinate a new project from planning through launch.", ); await expect(page.getByTestId("create-channel-permissions")).toContainText( "Private", ); + await page.getByTestId("create-channel-permissions").click(); + await page.getByTestId("create-channel-permissions-option-open").click(); + await expect(page.getByTestId("create-channel-template-summary")).toHaveText( + "Open · Canvas included · 1 agent · 1 team", + ); +}); + +test("create channel exposes templates when the library is empty", async ({ + page, +}) => { + await installMockBridge(page, { channelTemplates: [] }); + await page.goto("/"); + await openCreateChannelDialog(page); + + const typeContainer = page.getByTestId( + "create-channel-channel-type-container", + ); + const visibilityContainer = page.getByTestId( + "create-channel-permissions-container", + ); + const templateContainer = page.getByTestId( + "create-channel-template-container", + ); + await expect(templateContainer).toContainText("TemplateOptional"); + const typeBox = await typeContainer.boundingBox(); + const visibilityBox = await visibilityContainer.boundingBox(); + const templateBox = await templateContainer.boundingBox(); + expect(typeBox).not.toBeNull(); + expect(visibilityBox).not.toBeNull(); + expect(templateBox).not.toBeNull(); + expect(typeBox?.y ?? 0).toBeLessThan(visibilityBox?.y ?? 0); + expect(visibilityBox?.y ?? 0).toBeLessThan(templateBox?.y ?? 0); + + const templateControl = page.getByTestId("create-channel-template"); + await expect(templateControl).toHaveText("None"); + await templateControl.click(); + await page + .getByRole("menuitem", { name: "Create new channel template…" }) + .click(); + + await expect( + page.getByText("Create template", { exact: true }), + ).toBeVisible(); + await page.locator("#template-name").fill("Weekly planning"); + await page.locator("#template-description").fill("Plan the next week."); + await page.getByRole("button", { name: "Create", exact: true }).click(); + + await expect(templateControl).toHaveText("Weekly planning"); + await expect(page.getByTestId("create-channel-description")).toHaveValue( + "Plan the next week.", + ); }); test("create ephemeral stream shows sidebar and header affordances", async ({ diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index 3b71ff9dd80..eefdef1fdd2 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1139,6 +1139,13 @@ test("renders settings in the app shell with a back button", async ({ await expect(page.getByTestId("settings-back-to-app")).toBeVisible(); await expect(page.getByPlaceholder("Search everything")).toHaveCount(0); await expect(page.getByText("Personal", { exact: true })).toBeVisible(); + const personalGroup = page + .getByTestId("settings-nav-channel-templates") + .locator("xpath=ancestor::*[@data-sidebar='group']"); + await expect(personalGroup).toContainText("Personal"); + await expect( + page.getByTestId("settings-nav-channel-templates"), + ).toContainText("Channel templates"); await expect(page.getByTestId("settings-nav-profile")).toHaveAttribute( "aria-pressed", "true", From 80315ac1a68024c40b61f3a062c9cb6bf7d4efb5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 3 Aug 2026 12:37:44 -0400 Subject: [PATCH 020/163] fix(desktop): harden Windows installs against Defender block and orphaned Node (#4382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR fixes two Windows-specific install failures: Windows Defender blocking the bare `irm|iex` PowerShell install command, and managed Node shims pointing at a version-bumped (now-absent) Node directory. The Defender block (Trojan:Win32/Commando.A!ml) fires before PowerShell runs and is not clearable via Allow. The Node orphaning means shims in the managed npm prefix resolve but fail at runtime with 'node not recognized' because they reference the deleted old Node path. - Replace all three Windows CLI install commands (Goose, Claude, Codex) with a two-step shape — `Invoke-RestMethod` to a named temp file, then execute — to eliminate the dropper signature; a new `windows_install_command!` macro in `discovery/windows_install.rs` generates all three strings at compile time so the shape cannot drift between runtimes - `$ErrorActionPreference='Stop'` aborts on download failure instead of falling through to a missing-file exit-0; `exit $LASTEXITCODE` propagates the vendor script's own exit code - Add `probe_node(executable, expected_version, timeout)` as a bounded seam: stdout goes to a temp file (not a pipe) so no exit path can block on an inherited handle; the child runs in its own process group on Unix so an unconditional group SIGKILL on every exit path terminates all descendants; on Windows `taskkill /T /F` provides the same tree-wide cleanup; `managed_node_runtime_ready()` is a thin wrapper that resolves the managed Node path and calls the seam - Add `resolve_adapter_path()` in `managed_node.rs`: resolves the candidate first, then calls `should_invalidate_adapter()` — a pure predicate that returns `true` only when the resolved path is under `buzz_managed_npm_bin_dir()` AND the managed Node runtime is orphaned; external adapters outside the managed prefix are always preserved Note: CI cannot reproduce the Defender block (no live Defender ML classifier). Proof of fix is structural — the command shape no longer matches the dropper signature. Canary validation on a real Windows machine with Defender enabled is the definitive check. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .../src-tauri/src/commands/agent_discovery.rs | 25 +- .../commands/agent_discovery/managed_node.rs | 358 +++++-------- .../agent_discovery/managed_node_tests.rs | 481 ++++++++++++++++++ .../src-tauri/src/managed_agents/discovery.rs | 10 +- .../discovery/windows_install.rs | 225 ++++++++ 5 files changed, 863 insertions(+), 236 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/windows_install.rs diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce3511..4abf53ee918 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -333,10 +333,7 @@ fn install_acp_runtime_blocking( // For the codex runtime, "found" is not enough — the resolved binary must also // pass the 1.x version gate. An outdated 0.16.x adapter must be overwritten by // the new npm install so the CODEX_CONFIG spawn contract works correctly. - let adapter_path = runtime - .commands - .iter() - .find_map(|cmd| crate::managed_agents::resolve_command(cmd)); + let adapter_path = resolve_adapter_path(runtime.commands, runtime.adapter_install_commands); let adapter_probe_path = crate::managed_agents::readiness::cli_probe::augmented_path(); if let Some(cmds) = plan_adapter_install( runtime_id, @@ -1020,7 +1017,7 @@ use install_report::InstallReporter; mod managed_node; use managed_node::{ ensure_managed_node_runtime_blocking, managed_node_runtime_supported, managed_npm_command, - npm_eacces_hint, + npm_eacces_hint, resolve_adapter_path, }; #[tauri::command] @@ -1741,7 +1738,7 @@ mod tests { #[test] fn test_powershell_command_argv_exact() { // Catalog format: body wrapped in one outer double-quote pair (Bash-layer serialization). - let body = "irm https://chatgpt.com/codex/install.ps1 | iex"; + let body = "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-codex.ps1'; Invoke-RestMethod https://chatgpt.com/codex/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE"; let cmd = super::install_powershell_command(&format!( r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "{body}""# )); @@ -1771,12 +1768,12 @@ mod tests { ); } - /// Claude Code catalog command (discovery.rs:107) must dequote to the bare pipeline. + /// Claude Code catalog command must dequote to the two-step download-then-execute body. #[cfg(windows)] #[test] fn test_powershell_command_claude_catalog_dequoted() { let cmd = super::install_powershell_command( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex""#, + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, ); assert_eq!( cmd.get_args() @@ -1787,22 +1784,22 @@ mod tests { "-ExecutionPolicy", "Bypass", "-Command", - "irm https://claude.ai/install.ps1 | iex", + "$ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-claude.ps1'; Invoke-RestMethod https://claude.ai/install.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", ], "Claude catalog command must be dequoted correctly" ); } - /// Goose Windows catalog command (discovery.rs:78) must dequote to a bare pipeline - /// with a literal `$env:` prefix — no backslash before the dollar sign. - /// This proves the `\$` → `$` escape fix: post-#2750 the spawn is native and + /// Goose Windows catalog command must dequote to the two-step download-then-execute body + /// with the `$env:CONFIGURE` prefix intact — no backslash before the dollar sign. + /// This proves the `\$` → `$` contract: post-#2750 the spawn is native and /// PowerShell receives the body verbatim, so a residual `\` would produce /// `\$env:CONFIGURE='false'` which is a malformed statement. #[cfg(windows)] #[test] fn test_powershell_command_goose_catalog_dequoted() { let cmd = super::install_powershell_command( - r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex""#, + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE""#, ); assert_eq!( cmd.get_args() @@ -1813,7 +1810,7 @@ mod tests { "-ExecutionPolicy", "Bypass", "-Command", - "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex", + "$env:CONFIGURE='false'; $ErrorActionPreference='Stop'; $installer=Join-Path $env:TEMP 'buzz-install-goose.ps1'; Invoke-RestMethod https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 -OutFile $installer; & $installer; exit $LASTEXITCODE", ], "Goose catalog command must dequote with bare $env: (no backslash before $)" ); diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index 72108f02910..fbfb068c0e5 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -102,25 +102,155 @@ fn managed_node_failed_step(stderr: String) -> InstallStepResult { } } -fn managed_node_runtime_ready() -> bool { +pub(super) fn managed_node_runtime_ready() -> bool { let Some(node) = crate::managed_agents::buzz_managed_node_bin_path() else { return false; }; if !node.is_file() { return false; } - let mut cmd = std::process::Command::new(&node); + probe_node(&node, MANAGED_NODE_VERSION, Duration::from_secs(3)) +} + +/// Run `executable --version` with a bounded deadline and return `true` only +/// when it exits 0 and its trimmed stdout equals `expected_version`. +/// +/// Transport: stdout is redirected to a temp file so no exit path can block on +/// an inherited handle (a descendant retaining a pipe write-end would otherwise +/// prevent EOF indefinitely). +/// +/// Cleanup: the child runs in its own process group on Unix (`process_group(0)`) +/// so an unconditional group SIGKILL on every exit path terminates all +/// descendants. On Windows, `terminate_process` issues `taskkill /T /F` for +/// tree-wide cleanup. SIGKILL to an already-dead group returns ESRCH (no-op). +pub(super) fn probe_node( + executable: &std::path::Path, + expected_version: &str, + timeout: Duration, +) -> bool { + let tmp = match tempfile::NamedTempFile::new() { + Ok(f) => f, + Err(_) => return false, + }; + let out_file = match tmp.reopen() { + Ok(f) => f, + Err(_) => return false, + }; + + let mut cmd = std::process::Command::new(executable); cmd.arg("--version") .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) + .stdout(std::process::Stdio::from(out_file)) .stderr(std::process::Stdio::null()); crate::util::configure_no_window(&mut cmd); - let output = cmd.output(); - output - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim() == MANAGED_NODE_VERSION) - .unwrap_or(false) + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + let Ok(mut child) = cmd.spawn() else { + return false; + }; + + let deadline = std::time::Instant::now() + timeout; + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if std::time::Instant::now() >= deadline { + kill_probe_group(child.id()); + let _ = child.wait(); + return false; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + kill_probe_group(child.id()); + let _ = child.wait(); + return false; + } + } + }; + + // Group-kill unconditionally: SIGKILL to a dead group is ESRCH (no-op). + kill_probe_group(child.id()); + + if !exit_status.success() { + return false; + } + + let mut output = String::new(); + if std::io::Read::read_to_string(&mut tmp.as_file(), &mut output).is_err() { + return false; + } + output.trim() == expected_version +} + +/// Kill the probe's process group/tree unconditionally (no TERM grace — this +/// is a probe, not an agent session). ESRCH on a dead group is fine. +fn kill_probe_group(pid: u32) { + #[cfg(unix)] + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } + #[cfg(windows)] + { + let _ = crate::managed_agents::terminate_process(pid); + } + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + } +} + +/// Returns `true` when the managed Node runtime is absent or no longer executes — +/// meaning any existing npm adapter shims are broken and must be reinstalled. +/// +/// This fires when the pinned Node version changes (e.g. v24.11.0 → v24.18.0): +/// the old dir stays on disk, shims appear installed, but they fail at run time +/// because the Node binary they reference is gone. Treating the adapter as +/// missing forces `ensure_managed_node_runtime_blocking` to re-download Node and +/// npm to reinstall the shims. +pub(super) fn managed_node_orphaned() -> bool { + managed_node_runtime_supported() && !managed_node_runtime_ready() +} + +/// Returns `true` when an adapter at `resolved` should be invalidated. +/// +/// Only a Buzz-managed shim (path under `managed_prefix`) with an orphaned +/// runtime is invalidated; external adapters are always preserved. +pub(super) fn should_invalidate_adapter( + resolved: &std::path::Path, + managed_prefix: &std::path::Path, + orphaned: bool, +) -> bool { + orphaned && resolved.starts_with(managed_prefix) +} + +/// Resolve the adapter binary path, accounting for the Node-orphan case. +/// Resolves first; invalidates only managed-prefix shims when Node is orphaned. +pub(super) fn resolve_adapter_path( + commands: &[&str], + adapter_install_commands: &[&str], +) -> Option { + let resolved = commands + .iter() + .find_map(|cmd| crate::managed_agents::resolve_command(cmd)); + + let needs_managed_npm = adapter_install_commands + .iter() + .any(|cmd| is_npm_global_install(cmd)); + if needs_managed_npm { + if let (Some(ref path), Some(ref managed_bin)) = + (&resolved, crate::managed_agents::buzz_managed_npm_bin_dir()) + { + if should_invalidate_adapter(path, managed_bin, managed_node_orphaned()) { + return None; + } + } + } + + resolved } fn managed_node_install_lock() -> &'static Mutex<()> { @@ -538,211 +668,5 @@ pub(super) fn npm_eacces_hint(stderr: &str, _command: &str) -> Option { // ── end managed npm adapter installs ────────────────────────────────────────── #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { - let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap(); - assert!( - hint.contains("Buzz's private Node tools directory"), - "hint: {hint}" - ); - } - - #[test] - fn test_rewrite_npm_install_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install( - "npm install -g @agentclientprotocol/codex-acp", - "'/tmp/Buzz Node'" - ), - "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp" - ); - } - - #[test] - fn test_rewrite_npm_i_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install("npm i -g some-package", "'/tmp/buzz'"), - "npm i --global --prefix '/tmp/buzz' some-package" - ); - } - - #[test] - fn test_rewrite_npm_uninstall_uses_private_prefix() { - assert_eq!( - rewrite_npm_global_install("npm uninstall -g @zed-industries/codex-acp", "'/tmp/buzz'"), - "npm uninstall --global --prefix '/tmp/buzz' @zed-industries/codex-acp" - ); - } - - #[test] - fn test_rewrite_ignores_non_global_command() { - assert_eq!( - rewrite_npm_global_install("npm install foo", "'/tmp/buzz'"), - "npm install foo" - ); - } - - #[test] - fn test_shell_quote_escapes_single_quotes() { - assert_eq!( - shell_quote(std::path::Path::new("/tmp/Buzz's Node")), - "'/tmp/Buzz'\\''s Node'" - ); - } - - // ── zip validation tests ────────────────────────────────────────────────── - - /// Build an in-memory zip archive with the supplied entry names and return - /// a temporary file containing it (zip::ZipArchive requires Seek). - fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile { - let mut buf: Vec = Vec::new(); - { - let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); - let opts = zip::write::SimpleFileOptions::default(); - for name in entry_names { - writer.start_file(*name, opts).unwrap(); - } - writer.finish().unwrap(); - } - let mut tmp = tempfile::NamedTempFile::new().unwrap(); - std::io::Write::write_all(&mut tmp, &buf).unwrap(); - tmp - } - - #[test] - fn test_validate_zip_accepts_normal_entries() { - let tmp = make_zip_with_entries(&[ - "node-v24.18.0-win-x64/node.exe", - "node-v24.18.0-win-x64/npm.cmd", - "node-v24.18.0-win-x64/npm", - ]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - assert!(validate_managed_node_zip_entries(&archive).is_ok()); - } - - #[test] - fn test_validate_zip_rejects_absolute_path() { - let tmp = make_zip_with_entries(&["/etc/passwd"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_path_traversal() { - let tmp = make_zip_with_entries(&["../../../etc/passwd"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("path traversal"), - "expected 'path traversal' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_backslash_rooted() { - // Windows-style absolute path using backslash — must reject on every host. - let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_drive_prefix() { - // Windows drive-letter absolute path — must reject on every host. - let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("absolute path"), - "expected 'absolute path' in: {err}" - ); - } - - #[test] - fn test_validate_zip_rejects_backslash_traversal() { - // Path traversal using Windows separator — must reject on every host. - let tmp = make_zip_with_entries(&["node-v24.18.0-win-x64\\..\\..\\evil"]); - let file = std::fs::File::open(tmp.path()).unwrap(); - let archive = zip::ZipArchive::new(file).unwrap(); - let err = validate_managed_node_zip_entries(&archive).unwrap_err(); - assert!( - err.contains("path traversal"), - "expected 'path traversal' in: {err}" - ); - } - - // ── verify_node_tree layout tests ───────────────────────────────────────── - - #[test] - fn test_verify_node_tree_unix_layout_passes() { - let tmp = tempfile::TempDir::new().unwrap(); - let bin = tmp.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - std::fs::write(bin.join("node"), b"").unwrap(); - std::fs::write(bin.join("npm"), b"").unwrap(); - // On non-Windows the unix branch is active — this must pass. - #[cfg(not(windows))] - assert!(verify_node_tree(tmp.path()).is_ok()); - // On Windows the windows branch is active — unix layout must fail. - #[cfg(windows)] - assert!(verify_node_tree(tmp.path()).is_err()); - } - - #[test] - fn test_verify_node_tree_unix_layout_missing_npm_fails() { - let tmp = tempfile::TempDir::new().unwrap(); - let bin = tmp.path().join("bin"); - std::fs::create_dir_all(&bin).unwrap(); - std::fs::write(bin.join("node"), b"").unwrap(); - // npm intentionally absent - #[cfg(not(windows))] - { - let err = verify_node_tree(tmp.path()).unwrap_err(); - assert!(err.contains("bin/npm"), "err: {err}"); - } - } - - #[test] - fn test_verify_node_tree_windows_layout_passes() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); - std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); - std::fs::write(tmp.path().join("npm"), b"").unwrap(); - // On Windows the windows branch is active — this must pass. - #[cfg(windows)] - assert!(verify_node_tree(tmp.path()).is_ok()); - // On non-Windows the unix branch is active — windows-layout root files - // don't satisfy bin/node + bin/npm, so this must fail. - #[cfg(not(windows))] - assert!(verify_node_tree(tmp.path()).is_err()); - } - - #[test] - fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() { - let tmp = tempfile::TempDir::new().unwrap(); - std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); - std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); - // npm POSIX shim intentionally absent - #[cfg(windows)] - { - let err = verify_node_tree(tmp.path()).unwrap_err(); - assert!(err.contains("npm"), "err: {err}"); - } - } -} +#[path = "managed_node_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs new file mode 100644 index 00000000000..a8e1d7f4c81 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node_tests.rs @@ -0,0 +1,481 @@ +use super::*; + +#[test] +fn test_npm_eacces_hint_guidance_mentions_buzz_private_dir() { + let hint = npm_eacces_hint("EACCES: permission denied", "npm install -g foo").unwrap(); + assert!( + hint.contains("Buzz's private Node tools directory"), + "hint: {hint}" + ); +} + +#[test] +fn test_rewrite_npm_install_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install( + "npm install -g @agentclientprotocol/codex-acp", + "'/tmp/Buzz Node'" + ), + "npm install --global --prefix '/tmp/Buzz Node' @agentclientprotocol/codex-acp" + ); +} + +#[test] +fn test_rewrite_npm_i_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install("npm i -g some-package", "'/tmp/buzz'"), + "npm i --global --prefix '/tmp/buzz' some-package" + ); +} + +#[test] +fn test_rewrite_npm_uninstall_uses_private_prefix() { + assert_eq!( + rewrite_npm_global_install("npm uninstall -g @zed-industries/codex-acp", "'/tmp/buzz'"), + "npm uninstall --global --prefix '/tmp/buzz' @zed-industries/codex-acp" + ); +} + +#[test] +fn test_rewrite_ignores_non_global_command() { + assert_eq!( + rewrite_npm_global_install("npm install foo", "'/tmp/buzz'"), + "npm install foo" + ); +} + +#[test] +fn test_shell_quote_escapes_single_quotes() { + assert_eq!( + shell_quote(std::path::Path::new("/tmp/Buzz's Node")), + "'/tmp/Buzz'\\''s Node'" + ); +} + +// ── zip validation tests ────────────────────────────────────────────────────── + +/// Build an in-memory zip archive with the supplied entry names and return +/// a temporary file containing it (zip::ZipArchive requires Seek). +fn make_zip_with_entries(entry_names: &[&str]) -> tempfile::NamedTempFile { + let mut buf: Vec = Vec::new(); + { + let mut writer = zip::ZipWriter::new(std::io::Cursor::new(&mut buf)); + let opts = zip::write::SimpleFileOptions::default(); + for name in entry_names { + writer.start_file(*name, opts).unwrap(); + } + writer.finish().unwrap(); + } + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut tmp, &buf).unwrap(); + tmp +} + +#[test] +fn test_validate_zip_accepts_normal_entries() { + let tmp = make_zip_with_entries(&[ + "node-v24.18.0-win-x64/node.exe", + "node-v24.18.0-win-x64/npm.cmd", + "node-v24.18.0-win-x64/npm", + ]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + assert!(validate_managed_node_zip_entries(&archive).is_ok()); +} + +#[test] +fn test_validate_zip_rejects_absolute_path() { + let tmp = make_zip_with_entries(&["/etc/passwd"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_path_traversal() { + let tmp = make_zip_with_entries(&["../../../etc/passwd"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("path traversal"), + "expected 'path traversal' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_backslash_rooted() { + // Windows-style absolute path using backslash — must reject on every host. + let tmp = make_zip_with_entries(&["\\Windows\\system32\\evil.dll"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_drive_prefix() { + // Windows drive-letter absolute path — must reject on every host. + let tmp = make_zip_with_entries(&["C:\\evil\\payload.exe"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("absolute path"), + "expected 'absolute path' in: {err}" + ); +} + +#[test] +fn test_validate_zip_rejects_backslash_traversal() { + // Path traversal using Windows separator — must reject on every host. + let tmp = make_zip_with_entries(&["node-v24.18.0-win-x64\\..\\..\\evil"]); + let file = std::fs::File::open(tmp.path()).unwrap(); + let archive = zip::ZipArchive::new(file).unwrap(); + let err = validate_managed_node_zip_entries(&archive).unwrap_err(); + assert!( + err.contains("path traversal"), + "expected 'path traversal' in: {err}" + ); +} + +// ── verify_node_tree layout tests ───────────────────────────────────────────── + +#[test] +fn test_verify_node_tree_unix_layout_passes() { + let tmp = tempfile::TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), b"").unwrap(); + std::fs::write(bin.join("npm"), b"").unwrap(); + // On non-Windows the unix branch is active — this must pass. + #[cfg(not(windows))] + assert!(verify_node_tree(tmp.path()).is_ok()); + // On Windows the windows branch is active — unix layout must fail. + #[cfg(windows)] + assert!(verify_node_tree(tmp.path()).is_err()); +} + +#[test] +fn test_verify_node_tree_unix_layout_missing_npm_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + let bin = tmp.path().join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("node"), b"").unwrap(); + // npm intentionally absent + #[cfg(not(windows))] + { + let err = verify_node_tree(tmp.path()).unwrap_err(); + assert!(err.contains("bin/npm"), "err: {err}"); + } +} + +#[test] +fn test_verify_node_tree_windows_layout_passes() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); + std::fs::write(tmp.path().join("npm"), b"").unwrap(); + // On Windows the windows branch is active — this must pass. + #[cfg(windows)] + assert!(verify_node_tree(tmp.path()).is_ok()); + // On non-Windows the unix branch is active — windows-layout root files + // don't satisfy bin/node + bin/npm, so this must fail. + #[cfg(not(windows))] + assert!(verify_node_tree(tmp.path()).is_err()); +} + +#[test] +fn test_verify_node_tree_windows_layout_missing_npm_shim_fails() { + let tmp = tempfile::TempDir::new().unwrap(); + std::fs::write(tmp.path().join("node.exe"), b"").unwrap(); + std::fs::write(tmp.path().join("npm.cmd"), b"").unwrap(); + // npm POSIX shim intentionally absent + #[cfg(windows)] + { + let err = verify_node_tree(tmp.path()).unwrap_err(); + assert!(err.contains("npm"), "err: {err}"); + } +} + +// ── should_invalidate_adapter / orphan policy pure unit tests ───────────────── + +#[test] +fn test_should_invalidate_adapter_invalidates_managed_shim_when_orphaned() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let shim = prefix.join("codex-acp"); + assert!( + should_invalidate_adapter(&shim, prefix, true), + "managed shim + orphaned runtime must be invalidated" + ); +} + +#[test] +fn test_should_invalidate_adapter_keeps_external_adapter_when_orphaned() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let external = std::path::Path::new("/usr/local/bin/codex-acp"); + assert!( + !should_invalidate_adapter(external, prefix, true), + "external adapter must not be invalidated even when Node is orphaned" + ); +} + +#[test] +fn test_should_invalidate_adapter_keeps_managed_shim_when_node_healthy() { + let prefix = std::path::Path::new("/managed/npm/bin"); + let shim = prefix.join("codex-acp"); + assert!( + !should_invalidate_adapter(&shim, prefix, false), + "managed shim must not be invalidated when Node is healthy" + ); +} + +#[test] +fn test_resolve_adapter_path_returns_none_when_binary_absent() { + let commands: &[&str] = &["nonexistent-buzz-test-binary-xyz"]; + let adapter_install_commands: &[&str] = &["curl -fsSL https://example.com | bash"]; + assert!( + resolve_adapter_path(commands, adapter_install_commands).is_none(), + "must return None when the command is not on PATH" + ); +} + +// ── probe_node seam regressions ─────────────────────────────────────────────── +// +// All four scenarios drive probe_node() directly — the same +// tempfile/deadline/cleanup/status/version path used by managed_node_runtime_ready. +// Each test CAN fail if production: +// - drops process_group(0) → descendant-holds-stdout assertion (d) fails +// (tempfile transport still returns promptly; +// the sleep survives and kill($!,0) returns 0) +// - skips group-kill on a path → hung-binary test exceeds margin +// - ignores exit_status.success() → nonzero-exit test returns true +// - skips version comparison → wrong-version test returns true +// +// Script files are written into a TempDir (no open write fd at spawn time) +// to avoid ETXTBSY on Linux. + +/// Scenario 1 — descendant holds stdout write-end, direct child exits immediately. +/// +/// The script backgrounds a 60-second sleep (inheriting stdout), records both the +/// script's own PID (`$$`) and the sleep's PID (`$!`) to sidecar files, then exits +/// with the expected version string. Four assertions: +/// (a) bounded return — would hang ~60 s if tempfile transport regressed to pipe; +/// (b) correct result; +/// (c) process group dead after return — catches skipped-cleanup-on-success: if +/// the group-kill is absent but `process_group(0)` is still present, a live +/// member in the group is detectable; +/// (d) descendant PID dead after return — catches dropped `process_group(0)`: if +/// the call is removed the sleep stays in the runner's group (not the probe's), +/// `kill(-pgid,0)` is vacuously ESRCH, but `kill(desc_pid,0)` returns 0 and +/// this assertion fails. This is the canonical mutation for (c). +#[cfg(unix)] +#[test] +fn test_probe_node_descendant_holds_stdout_returns_promptly_and_kills_group() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("probe.sh"); + let pgid_file = tmp_dir.path().join("pgid"); + let desc_pid_file = tmp_dir.path().join("desc_pid"); + let pgid_file_path = pgid_file.to_str().unwrap().to_owned(); + let desc_pid_file_path = desc_pid_file.to_str().unwrap().to_owned(); + // Line 1 of script: record the script's own PID (= PGID after process_group(0)). + // Line 2: background the sleep and record its PID. + // Line 3: emit the expected version and exit so the direct child exits promptly. + let script_content = format!( + "#!/bin/sh\necho $$ > {pgid_file_path}\n/bin/sleep 60 &\necho $! > {desc_pid_file_path}\necho v24.18.0\nexit 0\n" + ); + std::fs::write(&script, script_content.as_bytes()).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let probe_timeout = std::time::Duration::from_secs(3); + let t = std::time::Instant::now(); + let result = probe_node(&script, "v24.18.0", probe_timeout); + let elapsed = t.elapsed(); + + // Give the group-kill a moment to propagate before checking liveness. + std::thread::sleep(std::time::Duration::from_millis(200)); + + // (a) bounded return — tempfile transport must not hang on the descendant's + // retained pipe write-end. + assert!( + elapsed < probe_timeout + std::time::Duration::from_secs(2), + "probe_node hung — likely descendant retained pipe write-end: elapsed {elapsed:?}" + ); + // (b) correct result + assert!(result, "probe_node must return true for matching version"); + + // (c) process group dead — catches skipped-cleanup: if the group-kill on the + // success path is removed while process_group(0) is still present, the + // sleep remains in the probe's group and kill(-pgid,0) returns 0. + let pgid_str = std::fs::read_to_string(&pgid_file) + .expect("script must have written its PID to the pgid sidecar"); + let pgid: i32 = pgid_str + .trim() + .parse() + .expect("pgid sidecar must contain a numeric PID"); + let group_alive = unsafe { libc::kill(-pgid, 0) } == 0; + assert!( + !group_alive, + "process group {pgid} must be dead after probe_node" + ); + + // (d) descendant PID dead — catches dropped process_group(0): without that + // call the sleep is never in the probe's group, so kill(-pgid,0) is + // vacuously ESRCH while the sleep survives. Asserting the descendant's + // own PID is dead proves the sleep was actually killed. + let desc_pid_str = std::fs::read_to_string(&desc_pid_file) + .expect("script must have written the sleep PID to the desc_pid sidecar"); + let desc_pid: libc::pid_t = desc_pid_str + .trim() + .parse() + .expect("desc_pid sidecar must contain a numeric PID"); + // Pre-assert cleanup: if the descendant is somehow still alive, kill it so + // a failing test does not leave a 60-second sleep in the runner's process group. + let desc_alive = unsafe { libc::kill(desc_pid, 0) } == 0; + if desc_alive { + unsafe { libc::kill(desc_pid, libc::SIGKILL) }; + } + assert!( + !desc_alive, + "descendant PID {desc_pid} must be dead after probe_node — \ + if process_group(0) is dropped, the sleep escapes into the runner's group \ + and is never killed by the group-kill" + ); +} + +/// Scenario 2 — direct hang: probe_node must traverse the real try_wait +/// deadline and return false. Does NOT call kill_probe_group directly. +/// +/// This test FAILS if the deadline loop in probe_node is broken or if the +/// timeout/kill path is not exercised (e.g., missing group-kill exits the +/// loop early via a different mechanism). +#[cfg(unix)] +#[test] +fn test_probe_node_times_out_on_hung_binary() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("hung.sh"); + std::fs::write(&script, b"#!/bin/sh\n/bin/sleep 30\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let probe_timeout = std::time::Duration::from_secs(3); + let t = std::time::Instant::now(); + let result = probe_node(&script, "v24.18.0", probe_timeout); + let elapsed = t.elapsed(); + + assert!(!result, "probe_node must return false for a hung binary"); + // Must have traversed the deadline (not returned early via a bug). + assert!( + elapsed >= probe_timeout, + "probe_node returned before deadline: {elapsed:?} < {probe_timeout:?}" + ); + // Must not hang past the deadline by more than the poll interval + margin. + assert!( + elapsed < probe_timeout + std::time::Duration::from_secs(3), + "probe_node exceeded deadline by too much: {elapsed:?}" + ); +} + +/// Scenario 3 — non-zero exit: probe_node must return false even when stdout +/// contains the expected version string. +/// +/// This test FAILS if probe_node skips or inverts the exit_status.success() check. +#[cfg(unix)] +#[test] +fn test_probe_node_returns_false_on_nonzero_exit() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("fail.sh"); + // Prints the expected version string but exits non-zero. + std::fs::write(&script, b"#!/bin/sh\necho v24.18.0\nexit 1\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let result = probe_node(&script, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when the process exits non-zero" + ); +} + +/// Scenario 4 — wrong version output: probe_node must return false when stdout +/// does not match expected_version. +/// +/// This test FAILS if probe_node skips or incorrectly performs the version comparison. +#[cfg(unix)] +#[test] +fn test_probe_node_returns_false_on_wrong_version_output() { + use std::os::unix::fs::PermissionsExt; + let tmp_dir = tempfile::TempDir::new().unwrap(); + let script = tmp_dir.path().join("wrongver.sh"); + std::fs::write(&script, b"#!/bin/sh\necho v99.0.0\nexit 0\n").unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let result = probe_node(&script, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when stdout version does not match expected" + ); +} + +/// Windows-shaped seam: non-zero exit via .bat file. +/// +/// Drives the same probe_node path on Windows (terminate_process / taskkill /T /F). +/// This test FAILS if probe_node ignores exit_status.success() on Windows. +#[cfg(windows)] +#[test] +fn test_probe_node_windows_returns_false_on_nonzero_exit() { + let tmp_dir = tempfile::TempDir::new().unwrap(); + let bat = tmp_dir.path().join("fail.bat"); + // Prints the expected version but exits non-zero — must still fail. + std::fs::write(&bat, b"@echo off\r\necho v24.18.0\r\nexit /b 1\r\n").unwrap(); + + let result = probe_node(&bat, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when the .bat exits non-zero (Windows)" + ); +} + +/// Windows-shaped seam: wrong version output via .bat file. +/// +/// This test FAILS if probe_node skips the version comparison on Windows. +#[cfg(windows)] +#[test] +fn test_probe_node_windows_returns_false_on_wrong_version_output() { + let tmp_dir = tempfile::TempDir::new().unwrap(); + let bat = tmp_dir.path().join("wrongver.bat"); + std::fs::write(&bat, b"@echo off\r\necho v99.0.0\r\nexit /b 0\r\n").unwrap(); + + let result = probe_node(&bat, "v24.18.0", std::time::Duration::from_secs(3)); + assert!( + !result, + "probe_node must return false when stdout version does not match (Windows)" + ); +} + +/// Returns false when the node binary path does not exist (fast path, no spawn). +#[test] +fn test_managed_node_runtime_ready_returns_false_when_binary_absent() { + let Some(node) = crate::managed_agents::buzz_managed_node_bin_path() else { + assert!( + !managed_node_runtime_ready(), + "managed_node_runtime_ready must return false when no path resolves" + ); + return; + }; + if node.is_file() { + return; + } + assert!( + !managed_node_runtime_ready(), + "managed_node_runtime_ready must return false when the binary file does not exist" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8d1b8a5013a..248625ce931 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -9,10 +9,10 @@ use crate::managed_agents::{ AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, HarnessSource, }; - mod presets; mod runtime_metadata; - +#[macro_use] +mod windows_install; use presets::{preset_catalog_entry, PRESET_HARNESSES}; pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; pub(crate) use runtime_metadata::KnownAcpRuntime; @@ -85,7 +85,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], // Goose's stable release currently publishes only the Unix installer; // its official Windows instructions intentionally point at this main-branch script. - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("goose", "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", "$env:CONFIGURE='false'; ")], adapter_install_commands: &[], cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", adapter_install_instructions_url: "", @@ -117,7 +117,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("claude"), cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("claude", "https://claude.ai/install.ps1")], adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", @@ -149,7 +149,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("codex"), cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], - cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], + cli_install_commands_windows: &[windows_install_command!("codex", "https://chatgpt.com/codex/install.ps1")], adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], cli_install_instructions_url: "https://developers.openai.com/codex/cli/", adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", diff --git a/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs b/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs new file mode 100644 index 00000000000..09e27a62bea --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/windows_install.rs @@ -0,0 +1,225 @@ +//! Defender-safe construction of the Windows PowerShell CLI install commands. +//! +//! # Why the shape matters +//! +//! Windows Defender's ML classifier flags the bare `irm | iex` command +//! line as `Trojan:Win32/Commando.A!ml` — piping a downloaded string straight +//! into `Invoke-Expression` is a textbook dropper signature, so the *command +//! line itself* is scored, independent of what the URL actually serves. The +//! spawn is denied before PowerShell runs, surfacing as +//! `failed to spawn shell: Access is denied. (os error 5)`, and the block is +//! sticky: Defender's "Allow" button does not clear it. +//! +//! [`windows_install_command!`] emits the two-step form instead — download the +//! vendor script to a file, then execute the file — which does not match that +//! signature. All three runtimes use it, not only the one observed failing: +//! Goose and Claude escaped by scoring under the classifier threshold, which is +//! luck rather than design, and the threshold is not ours to depend on. +//! +//! # Why one macro instead of three literals +//! +//! The catalog needs `&'static str`, so the commands must be built at compile +//! time from literals. Emitting them from a single macro means the security +//! shape is defined once and cannot drift between runtimes as URLs change — +//! a per-runtime literal would let one entry silently regress to `iex`. +//! +//! # Exit-code fidelity +//! +//! [#2892](https://github.com/block/buzz/pull/2892) established that an install +//! step must not report success when the download failed. Two pieces preserve +//! that here, and both are load-bearing: +//! +//! - `$ErrorActionPreference='Stop'` makes a failed `Invoke-RestMethod` +//! terminate the whole command. Without it a failed download falls through to +//! `& $installer` on a path that does not exist, and PowerShell exits **0** — +//! the exact masking #2892 removed, in a new dress. `Stop` also prevents +//! executing a *stale* installer left in `$env:TEMP` by an earlier run. +//! - `exit $LASTEXITCODE` propagates the vendor script's own exit code. Without +//! it PowerShell reports its own status and a vendor failure of `3` flattens +//! to `1`, losing the distinction the retry logic reads. +//! +//! Verified against `pwsh` over a local HTTP server: vendor exit 3 surfaces as +//! 3, vendor exit 0 as 0, a 404 and an unresolvable host as non-zero, and a +//! planted stale installer is never executed. The old `irm | iex` shape +//! produces identical codes for all four, so this is not a behavior change. +//! +//! # Quoting contract +//! +//! The emitted body is wrapped in one double-quote pair, which +//! `install_powershell_command` strips before handing the body to PowerShell. +//! The body therefore uses **only single quotes** internally; a double quote +//! would terminate that pair early and truncate the command. + +/// Build the Windows CLI install command for one runtime. +/// +/// `slug` names the downloaded script (`buzz-install-.ps1`) so concurrent +/// installs of different runtimes cannot overwrite each other's file. The +/// optional third argument carries a runtime's env prefix (Goose's +/// `$env:CONFIGURE='false'; `) and must end with `; `. +/// +/// See the module docs for why each fragment is present. +macro_rules! windows_install_command { + ($slug:literal, $url:literal) => { + windows_install_command!($slug, $url, "") + }; + ($slug:literal, $url:literal, $env_prefix:literal) => { + concat!( + "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"", + $env_prefix, + "$ErrorActionPreference='Stop'; ", + "$installer=Join-Path $env:TEMP 'buzz-install-", + $slug, + ".ps1'; ", + "Invoke-RestMethod ", + $url, + " -OutFile $installer; ", + "& $installer; ", + "exit $LASTEXITCODE\"", + ) + }; +} + +#[cfg(test)] +mod tests { + use crate::managed_agents::known_acp_runtime_exact; + + /// Every runtime that ships a Windows install command. `cli_install_commands_windows` + /// is read directly rather than through `cli_install_commands_for_os()` so these + /// assertions cover the Windows strings while running on the Linux CI host. + fn windows_install_commands() -> Vec<(&'static str, &'static str)> { + ["goose", "claude", "codex"] + .into_iter() + .flat_map(|id| { + known_acp_runtime_exact(id) + .expect("runtime must exist in the catalog") + .cli_install_commands_windows + .iter() + .map(move |command| (id, *command)) + }) + .collect() + } + + /// The whole point of the change: no runtime may carry the flagged + /// download-and-execute-in-one-line signature. + #[test] + fn test_no_windows_install_command_pipes_a_download_into_iex() { + for (id, command) in windows_install_commands() { + assert!( + !command.contains("| iex"), + "{id}: `irm | iex` is the shape Defender flags as Trojan:Win32/Commando.A!ml; \ + download to a file and execute the file instead. Got: {command}" + ); + assert!( + !command.contains("Invoke-Expression"), + "{id}: Invoke-Expression on downloaded content carries the same signature. \ + Got: {command}" + ); + } + } + + /// All three runtimes must be hardened, not just the one observed failing. + /// Goose and Claude escaped only by scoring under the classifier threshold. + #[test] + fn test_every_windows_install_command_downloads_to_a_file_then_executes_it() { + let commands = windows_install_commands(); + assert_eq!( + commands.len(), + 3, + "expected exactly one Windows install command for each of goose, claude, codex" + ); + for (id, command) in commands { + assert!( + command.contains("-OutFile $installer"), + "{id}: must download the vendor script to a file. Got: {command}" + ); + assert!( + command.contains("& $installer"), + "{id}: must execute the downloaded file. Got: {command}" + ); + assert!( + command.contains(&format!("buzz-install-{id}.ps1")), + "{id}: script name must be runtime-specific so concurrent installs of \ + different runtimes cannot overwrite each other. Got: {command}" + ); + } + } + + /// Guards the #2892 regression: without `Stop`, a failed download falls + /// through to a missing file and PowerShell exits 0, reporting a failed + /// install as a success. Without `exit $LASTEXITCODE`, the vendor's own + /// exit code is replaced by PowerShell's. + #[test] + fn test_every_windows_install_command_preserves_failure_exit_codes() { + for (id, command) in windows_install_commands() { + assert!( + command.contains("$ErrorActionPreference='Stop'"), + "{id}: a failed download must abort instead of running a missing or stale \ + installer and exiting 0 (see #2892). Got: {command}" + ); + assert!( + command.contains("exit $LASTEXITCODE"), + "{id}: the vendor script's exit code must propagate. Got: {command}" + ); + } + } + + /// `install_powershell_command` strips exactly one outer double-quote pair. + /// An inner double quote would close that pair early and truncate the body. + #[test] + fn test_every_windows_install_command_quotes_the_body_exactly_once() { + for (id, command) in windows_install_commands() { + let body = command + .split_once(" -Command ") + .map(|(_, body)| body) + .unwrap_or_else(|| panic!("{id}: command must pass a -Command body: {command}")); + assert!( + body.starts_with('"') && body.ends_with('"'), + "{id}: body must be wrapped in one double-quote pair. Got: {body}" + ); + assert_eq!( + body.matches('"').count(), + 2, + "{id}: body must contain no inner double quotes — one would terminate the \ + outer pair early and truncate the command. Got: {body}" + ); + } + } + + /// Goose's installer reads `CONFIGURE` to stay non-interactive; losing the + /// prefix hangs the install waiting on input that never comes. + #[test] + fn test_goose_windows_install_command_keeps_its_env_prefix() { + let goose = known_acp_runtime_exact("goose").unwrap(); + let command = goose.cli_install_commands_windows[0]; + assert!( + command.contains("$env:CONFIGURE='false'"), + "goose must stay non-interactive. Got: {command}" + ); + assert!( + command.find("$env:CONFIGURE='false'").unwrap() + < command.find("Invoke-RestMethod").unwrap(), + "the env prefix must be set before the installer runs. Got: {command}" + ); + } + + /// The vendor URLs are the payload; pin them so a refactor of the shared + /// shape cannot silently retarget a download. + #[test] + fn test_windows_install_commands_target_the_official_vendor_urls() { + for (id, expected) in [ + ( + "goose", + "https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1", + ), + ("claude", "https://claude.ai/install.ps1"), + ("codex", "https://chatgpt.com/codex/install.ps1"), + ] { + let runtime = known_acp_runtime_exact(id).unwrap(); + let command = runtime.cli_install_commands_windows[0]; + assert!( + command.contains(&format!("Invoke-RestMethod {expected} -OutFile")), + "{id}: must download from {expected}. Got: {command}" + ); + } + } +} From 09c86c56e52651c017743268fc8ce708bb83b265 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Mon, 3 Aug 2026 11:42:42 -0500 Subject: [PATCH 021/163] fix: report agent usage per provider round, not once per turn (#4545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## The bug buzz-agent emitted its `usage_update` notification in exactly one place: after `ctx.run()` returned. Until that moment a turn's token counters lived only in the prompt task's stack frame. **A turn killed mid-flight reported nothing at all** — the provider had already billed every round it completed, and no consumer ever saw any of it. That is not a corner case for anything that ends a turn on a clock. It is the normal case for a long-horizon benchmark run that relaunches its agent between phases. ## How big Measured against a provider's own billing ledger over one run's window: | | provider ledger | what we recorded | |---|---|---| | the relaunched lead seat | $485 / 348M tok | $98.99 / 90.3M tok | | the two seats that were not relaunched | $29.90 / 856M | $25.81 / 765M — reconciles | 97% of that run's usage rows came back all zeros, against 1–4% for comparable runs that never relaunch. In one 450-phase trial exactly 7 phases recorded any usage — and each of those carries 177k–437k input tokens, a whole session's worth landing in the one phase that happened to end gracefully. Worth being precise about what was *not* wrong, since both were plausible and both were checked: - **Not pricing.** The rates were verified against the provider's endpoints API and match what we charge. - **Not a truncation bug.** The usage files were intact and internally consistent. The tokens were never captured in the first place. ## The fix The run loop now emits a session-cumulative `usage_update` after every usage-bearing provider response, so an interrupted turn has reported everything but its single in-flight request. - **Emitting more than once per turn is already part of the contract.** buzz-acp's `UsageTracker` advances its committed baseline only at publish time, and goose behaves the same way — which is why the tracker was written to tolerate it. - **The turn-start session baseline is snapshotted into `RunCtx`** so the mid-turn figure stays *session*-cumulative. A turn-local number would be discarded by a high-water-mark consumer and lose the turn entirely; there is a test for exactly that. - **Snapshot by value, not a session handle.** The loop reports once per round, and taking the sessions lock on each would serialise concurrent sessions behind one another's provider round-trips. Nothing else advances those counters while the turn holds `busy`, so it cannot go stale. - **One shared `wire::usage_update_payload`** for both call sites, so the mid-turn and end-of-turn shapes cannot drift. A drift there would present as tokens silently vanishing, which is the failure this reporting exists to prevent. ## Why not a SIGTERM handler That was the obvious shape and it does not work. At signal time the counters are not sitting anywhere a handler could reach — they are in the turn's stack frame, and the value the handler would need has not been folded into the session yet. Making usage durable *during* the turn is what actually fixes it; once it is, a handler adds nothing beyond the in-flight request, whose cost is unknown until its response lands. ## Tests - `usage_is_reported_after_each_round_not_only_at_turn_end` — two rounds; asserts the **first** notification carries round 1's counts alone, proving it went out before round 2 returned. - `mid_turn_usage_includes_earlier_turns` — a mid-turn report must be session-cumulative, not turn-local. buzz-agent 18/18 on the `fake_llm` suite, 382 unit. `cargo fmt` / `clippy` / `cargo check --workspace --all-targets` clean. ## Scope Agent-side only, against `main`. The matching harness change — settling usage on the timeout path, which was skipped on the reasoning that an incomplete turn has nothing to flush — is **#4553**, against the benchmark branch, since that harness does not exist on `main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Atish Patel Co-authored-by: Claude Code --- crates/buzz-agent/src/agent.rs | 52 ++++++++++- crates/buzz-agent/src/lib.rs | 46 +++++----- crates/buzz-agent/src/types.rs | 24 ++++++ crates/buzz-agent/src/wire.rs | 42 +++++++++ crates/buzz-agent/tests/fake_llm.rs | 129 ++++++++++++++++++++++++++++ 5 files changed, 269 insertions(+), 24 deletions(-) diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 8e14fee1956..ff87a33a1a0 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -13,8 +13,8 @@ use crate::mcp::McpRegistry; use crate::mcp::ResultBudget; use crate::types::{ - AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult, - ToolResultContent, TurnTotalState, + AgentError, ContentBlock, HistoryItem, ProviderStop, SessionUsageBaseline, StopReason, + ToolCall, ToolResult, ToolResultContent, TurnTotalState, }; use crate::wire::{self, WireSender}; @@ -150,9 +150,40 @@ pub struct RunCtx<'a> { /// 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, + /// Session-cumulative counters as they stood when this turn began. Added to + /// the `turn_*` accumulators above to report a cumulative figure mid-turn; + /// the session's own copy is only advanced once, after the turn returns. + pub usage_baseline: SessionUsageBaseline, } impl RunCtx<'_> { + /// Send a session-cumulative `usage_update` reflecting everything observed + /// up to and including the most recent LLM response. + /// + /// The figure is the turn-start baseline plus this turn's running + /// accumulators, which is exactly what `session/prompt` will fold into the + /// session once the turn returns — so a mid-turn notification and the + /// end-of-turn one agree, and a turn that never returns has still reported + /// everything but its final in-flight request. + async fn emit_usage_update(&self) { + let base = self.usage_baseline; + let payload = wire::usage_update_payload( + base.input_tokens + .saturating_add(self.turn_input_tokens.unwrap_or(0)), + base.output_tokens + .saturating_add(self.turn_output_tokens.unwrap_or(0)), + base.cached_input_tokens + .saturating_add(self.turn_cached_input_tokens.unwrap_or(0)), + base.total_state.merge_session(*self.turn_total_state), + self.effective_model, + ); + wire::send( + self.wire, + wire::goose_session_update(self.session_id, payload), + ) + .await; + } + pub async fn run(&mut self, prompt: Vec) -> Result { let user_text = prompt_to_text(prompt)?; if user_text.len() > MAX_PROMPT_BYTES { @@ -299,6 +330,23 @@ impl RunCtx<'_> { // 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); + // Report what the turn has burned SO FAR, before running the + // next round. A turn is many provider round-trips over many + // minutes, and until this point the only report was the one + // `session/prompt` sends after the turn returns — so a turn + // that was cancelled, timed out, or whose process was killed + // reported nothing at all, and its tokens (already billed) + // existed only in this stack frame. Reporting per round bounds + // the loss to the single request in flight. + // + // Emitting more than one `usage_update` per turn is expected by + // the consumer: buzz-acp's UsageTracker advances its committed + // baseline only when the turn's metric is published, so every + // notification within a turn measures from the same frozen + // baseline and the last one seen is the turn's true total. + // goose behaves the same way, which is why the tracker was + // written to tolerate it. + self.emit_usage_update().await; } if !response.reasoning.is_empty() { diff --git a/crates/buzz-agent/src/lib.rs b/crates/buzz-agent/src/lib.rs index 9a45bf4c984..6cd7b6808ff 100644 --- a/crates/buzz-agent/src/lib.rs +++ b/crates/buzz-agent/src/lib.rs @@ -658,6 +658,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender effective_model_override, run_id, mut steer_rx, + usage_baseline, ) = match acquire_session(&app, &p.session_id).await { Ok(v) => v, Err(reason) => { @@ -709,6 +710,7 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender turn_output_tokens: &mut turn_output_tokens, turn_cached_input_tokens: &mut turn_cached_input_tokens, turn_total_state: &mut turn_total_state, + usage_baseline, }; let result = ctx.run(p.prompt).await; if let Some(s) = app.sessions.lock().await.get_mut(&sid) { @@ -766,28 +768,16 @@ async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender 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); - } + // Same builder the run loop uses for its per-round reports, so the + // final notification is shape-identical to the ones that preceded + // it and a consumer taking the high-water mark lands on this one. + let update = wire::usage_update_payload( + accumulated_in, + accumulated_out, + accumulated_cached, + accumulated_total, + effective_model_str, + ); wire::send(&wire_tx, goose_session_update(&sid, update)).await; } } @@ -821,6 +811,7 @@ async fn acquire_session( Option, String, mpsc::UnboundedReceiver>, + crate::types::SessionUsageBaseline, ), &'static str, > { @@ -857,6 +848,17 @@ async fn acquire_session( effective_model, run_id, steer_rx, + // Snapshot rather than a handle: the run loop reports cumulative usage + // after every LLM round, and taking the sessions lock on each of those + // would serialise concurrent sessions behind one another's provider + // round-trips. Nothing else advances these counters while this turn + // holds `busy`, so the snapshot cannot go stale under it. + crate::types::SessionUsageBaseline { + input_tokens: s.accumulated_input_tokens, + output_tokens: s.accumulated_output_tokens, + cached_input_tokens: s.accumulated_cached_input_tokens, + total_state: s.accumulated_total_state, + }, )) } diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index 343a75bf724..e3864219810 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -308,6 +308,30 @@ impl TurnTotalState { } } +/// The session-cumulative usage counters as of the START of a turn. +/// +/// Copied out of the session under the lock when a turn begins and handed to +/// `RunCtx` by value, so the run loop can emit a cumulative `usage_update` +/// after every LLM round without reaching back into `App.sessions` (which it +/// holds no handle to, and which is locked by the turn's own bookkeeping at +/// both ends). +/// +/// This exists so that usage is durable *during* a turn rather than only after +/// it. The counters a turn accrues live in the prompt task's stack frame until +/// the turn returns; a process killed mid-turn takes them with it and the +/// tokens are billed by the provider but recorded nowhere. That is not +/// hypothetical — it silently under-reported a long-horizon benchmark's cost by +/// several-fold, because every phase of a `continue_until_timeout` run is +/// terminated mid-turn by design. +#[derive(Debug, Clone, Copy, Default)] +pub struct SessionUsageBaseline { + pub input_tokens: u64, + pub output_tokens: u64, + /// The cache-served subset of `input_tokens`, not an addition to it. + pub cached_input_tokens: u64, + pub total_state: TurnTotalState, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum StopReason { EndTurn, diff --git a/crates/buzz-agent/src/wire.rs b/crates/buzz-agent/src/wire.rs index 7b50e7982aa..634fca03af3 100644 --- a/crates/buzz-agent/src/wire.rs +++ b/crates/buzz-agent/src/wire.rs @@ -148,6 +148,48 @@ pub fn goose_session_update(sid: &str, update: Value) -> Value { }) } +/// Build the `usage_update` payload for a `_goose/unstable/session/update`. +/// +/// Shared by the two places that report usage — after each LLM round inside a +/// turn, and once more when the turn completes — so the wire shape cannot drift +/// between them. A consumer takes the high-water mark per session, so the +/// mid-turn payloads are supersets of each other and the final one wins; a +/// divergence in field names or units between the two call sites would instead +/// show up as tokens silently vanishing, which is the failure this reporting +/// exists to prevent. +/// +/// All counts are SESSION-cumulative, matching goose, so buzz-acp's +/// `UsageTracker` can compute per-turn deltas symmetrically for both agents. +pub fn usage_update_payload( + accumulated_input_tokens: u64, + accumulated_output_tokens: u64, + accumulated_cached_input_tokens: u64, + accumulated_total: crate::types::TurnTotalState, + model: &str, +) -> Value { + let mut update = json!({ + "sessionUpdate": "usage_update", + // used: total tokens as a context-usage proxy; + // contextLimit: 0 (buzz-agent has no context limit tracking). + "used": accumulated_input_tokens.saturating_add(accumulated_output_tokens), + "contextLimit": 0u64, + "accumulatedInputTokens": accumulated_input_tokens, + "accumulatedOutputTokens": accumulated_output_tokens, + // 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_input_tokens, + "model": model, + }); + // Only 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. + if let Some(total) = accumulated_total.exact_value() { + update["accumulatedTotalTokens"] = json!(total); + } + update +} + /// A `session/update` notification carrying a `update._meta.goose.` field. /// Used to advertise `activeRunId` (so steer-capable clients can target the /// in-flight run) and `queuedSteer` (so they can correlate an accepted steer diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index f782a9d476a..ef6f9d2d80a 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -933,6 +933,135 @@ async fn no_usage_turn_emits_no_usage_notification() { h.shutdown().await; } +/// Usage must be reported after EVERY provider round, not only once the turn +/// returns. +/// +/// A turn is many provider round-trips over many minutes. While the only report +/// was the one `session/prompt` sends after the turn returns, a turn whose +/// process was killed mid-flight reported nothing at all: its counters lived in +/// the prompt task's stack frame, the provider had already billed them, and no +/// consumer ever saw them. That is not a corner case for a long-horizon +/// benchmark — every phase of a `continue_until_timeout` run is terminated +/// mid-turn by design, which under-reported one measured run's cost several-fold. +/// +/// Two rounds with distinct usage. The assertion that matters is the FIRST +/// notification: it must carry round 1's counts alone, proving it was sent +/// before round 2 had returned, so a kill between the rounds would still have +/// left round 1 on the wire. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn usage_is_reported_after_each_round_not_only_at_turn_end() { + let url = spawn_fake_llm(vec![ + openai_tool_call_with_usage("call_round1", "fake__noop", json!({}), 15, 6), + openai_text_with_usage("done", 20, 8), + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + + let p_id = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + + let (frames_before, response) = recv_until_with_drain(&mut h, |v| v["id"] == p_id).await; + assert_eq!( + response["result"]["stopReason"], "end_turn", + "turn must complete with end_turn" + ); + + let usage: Vec<&Value> = frames_before + .iter() + .filter(|v| is_usage_update(v)) + .collect(); + assert!( + usage.len() >= 2, + "expected a usage_update per round (2 rounds), got {}; frames: {frames_before:#?}", + usage.len() + ); + + // Round 1 alone — emitted while round 2 was still outstanding. + assert_eq!( + usage[0]["params"]["update"]["accumulatedInputTokens"], + json!(15u64), + "first notification must carry round 1's input tokens only" + ); + assert_eq!( + usage[0]["params"]["update"]["accumulatedOutputTokens"], + json!(6u64), + "first notification must carry round 1's output tokens only" + ); + + // The last one is the turn total and is what a high-water-mark consumer keeps. + let last = usage[usage.len() - 1]; + assert_eq!( + last["params"]["update"]["accumulatedInputTokens"], + json!(35u64), + "final notification must carry the turn total 15+20=35" + ); + assert_eq!( + last["params"]["update"]["accumulatedOutputTokens"], + json!(14u64), + "final notification must carry the turn total 6+8=14" + ); + + h.shutdown().await; +} + +/// A mid-turn report must be SESSION-cumulative, not turn-local. +/// +/// The baseline handed to the run loop is a snapshot taken when the turn began; +/// if it were dropped, a consumer taking the high-water mark per session would +/// see turn 2's first round (a small number) arrive after turn 1's total and +/// discard it, silently losing turn 2 for any turn that never completed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mid_turn_usage_includes_earlier_turns() { + let url = spawn_fake_llm(vec![ + openai_text_with_usage("turn one", 10, 5), + openai_tool_call_with_usage("call_t2", "fake__noop", json!({}), 20, 8), + openai_text_with_usage("turn two done", 30, 9), + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}), + ) + .await; + let (_, _) = recv_until_with_drain(&mut h, |v| v["id"] == p1).await; + + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}), + ) + .await; + let (frames_before, _) = recv_until_with_drain(&mut h, |v| v["id"] == p2).await; + + let first = frames_before + .iter() + .find(|v| is_usage_update(v)) + .unwrap_or_else(|| { + panic!("expected a usage_update during turn 2; frames: {frames_before:#?}") + }); + assert_eq!( + first["params"]["update"]["accumulatedInputTokens"], + json!(30u64), + "turn 2 round 1 must report 10 (turn 1) + 20 (this round), not 20" + ); + assert_eq!( + first["params"]["update"]["accumulatedOutputTokens"], + json!(13u64), + "turn 2 round 1 must report 5 (turn 1) + 8 (this round), not 8" + ); + + h.shutdown().await; +} + /// When a turn is cancelled AFTER the provider has already returned a response /// (so token counts are observed), buzz-agent must still emit the usage /// notification before the cancelled `session/prompt` response. From 44fa1e8e3af30d561de981a211ff7a79bfa36493 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 3 Aug 2026 10:43:10 -0600 Subject: [PATCH 022/163] docs(release): align desktop handoff instructions (#3988) ## Summary - document exact-head trusted approval as the only desktop tagging authorization - explicitly require `desktop_ref=desktop-v` for the internal desktop handoff - replace the stale `squareup/sprout-releases` repository name with `squareup/buzz-releases` ## Audit coverage Compared `block/buzz` release documentation and automation with `squareup/buzz-releases` `main` (`5b09e5c5d71c80a0849a33458f4e45695df515d7`), including its README, agent guide, Buildkite field hint, desktop validator, release validation tests, and protected updater promotion instructions. ## Validation - `bash scripts/test-release-ref-contract.sh` - `git diff --check origin/main...HEAD` Signed-off-by: Wes Co-authored-by: Carl --- AGENTS.md | 4 ++-- RELEASING.md | 25 ++++++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4f03b312bc3..7cd43061b21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,14 +13,14 @@ Buzz spans five repos. This one (`block/buzz`) is the OSS source for the relay, | Repo | Purpose | |------|---------| | [block/buzz](https://github.com/block/buzz) | OSS source — relay, desktop app, mobile app, CLI, agent harness | -| [squareup/sprout-releases](https://github.com/squareup/sprout-releases) | Buildkite pipeline producing Block-signed macOS + iOS builds with `-block` version suffix | +| [squareup/buzz-releases](https://github.com/squareup/buzz-releases) | Buildkite pipelines producing Block-signed macOS + iOS builds with `-block` desktop version suffix | | [squareup/sprout-oss](https://github.com/squareup/sprout-oss) | CI pipeline building the relay Docker image and pushing to internal ECR | | [squareup/block-coder-tf-stacks](https://github.com/squareup/block-coder-tf-stacks) | Terraform + ArgoCD deploying the relay to the staging Kubernetes cluster | | [squareup/sprout-backend-blox](https://github.com/squareup/sprout-backend-blox) | Desktop backend provider script connecting Blox workstation agents to the relay | ``` block/buzz (source) - ├─► sprout-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases) + ├─► buzz-releases (desktop + mobile builds → Artifactory, GitHub, Mobile Releases) ├─► sprout-oss (relay Docker image → ECR) │ └─► block-coder-tf-stacks (Helm chart → ArgoCD → staging cluster) └─── sprout-backend-blox (Blox compute provider for Desktop agent launch) diff --git a/RELEASING.md b/RELEASING.md index e729f8b50c9..23dacea2cee 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -56,14 +56,15 @@ or mobile GitHub Release. updates the PR. 2. Review the recorded base and candidate SHA, the complete changelog, and CI. The required **Desktop Release Candidate** check validates the exact head. - Authorization is either an approval on that exact head or a permitted Default - ruleset bypass at merge time. Any regeneration changes the head and requires - the checks—and, for the review path, approval—to run again. + A trusted repository member, owner, or collaborator must approve that exact + candidate head. Any regeneration or push changes the head, invalidates the + prior approval, and requires both the checks and approval to run again. 3. **Squash merge** the PR. The protected branch must still be exactly the recorded base; otherwise regenerate the candidate from current `main`. 4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity, - required checks, and one of the two authorization paths, then tags the squash - commit as `desktop-v`. + required checks, and trusted approval on the exact candidate head, then tags + the squash commit as `desktop-v`. An admin or ruleset bypass does not + authorize desktop tagging. 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 @@ -184,10 +185,12 @@ Buildkite pipeline accepts only an exact candidate tag. For mobile, trigger the private [Release Mobile pipeline](https://buildkite.com/runway/buzz-mobile-releases) with -an exact RC tag for the platform build being cut. For desktop, use -[Release Desktop](https://buildkite.com/runway/sprout-releases). See the +an exact RC tag for the platform build being cut. For desktop, start +[Release Desktop](https://buildkite.com/runway/sprout-releases) and enter the +exact public source tag as `desktop_ref=desktop-v`; a generic +`v` tag is intentionally rejected. See the [buzz-releases README](https://github.com/squareup/buzz-releases#cutting-a-release) -for the private pipeline contract. +for the rest of the private pipeline contract. --- @@ -269,9 +272,9 @@ actor list. Do not update the branch manually and do not weaken the ruleset. Run `just release-desktop ` again from current `main`; this regenerates the -candidate, reruns CI, and requires a fresh approval when using the review path. -The post-merge verifier refuses to tag a squash whose parent differs from the -recorded candidate base or whose tree differs from the validated PR head. +candidate, reruns CI, and requires a fresh trusted approval on the new exact +head. The post-merge verifier refuses to tag a squash whose parent differs from +the recorded candidate base or whose tree differs from the validated PR head. ### Local `just release-desktop` fails with "must be on main branch" Switch to `main` and pull latest before running the release recipe. From 6de85fe31d781122756aecf954bae7d357a56b9a Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 3 Aug 2026 10:56:14 -0600 Subject: [PATCH 023/163] test(mobile): assert follow boundary semantics (#4559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - replace a platform-dependent mounted-`RichText` assertion with the production follow-mode boundary predicate - retain the jump-to-latest assertion as the visible consequence of follow mode remaining off - leave production behavior and desktop PR #4549 unchanged ## Why `ScrollablePositionedList` may keep an offscreen item mounted within cache extent on macOS while Linux does not. Mounting therefore does not establish whether reversed-list item 0 is at the latest boundary. The replacement reads the list's public `itemPositionsNotifier` and applies the same `index == 0 && abs(itemLeadingEdge) < 0.01` contract used by `message_list.dart`. ## Validation At commit `bc88617e61d8e9edf8fea832baa8d918163ee212` on macOS with repo Flutter 3.41.7: - `cd mobile && ../bin/flutter test` — 1088 passed, 1 skipped - `cd mobile && ../bin/flutter analyze` — no issues - pre-push `mobile-test` and `branch-skew` hooks — passed Signed-off-by: Wes Co-authored-by: Carl --- .../channels/channel_detail_page_test.dart | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 51c06d02839..7f8d0774c35 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1294,7 +1294,20 @@ void main() { ]); await tester.pumpAndSettle(); - expect(findRichText('Newest live update'), findsNothing); + // Cache-extent mounting varies by platform, so assert the reversed + // list's semantic boundary rather than whether item 0 is mounted. + final positions = tester + .widget(messageList) + .itemPositionsNotifier! + .itemPositions + .value; + expect( + positions.any( + (position) => + position.index == 0 && position.itemLeadingEdge.abs() < 0.01, + ), + isFalse, + ); expect( find.byKey(const ValueKey('channel-jump-to-latest')), findsOneWidget, From 651f6372754e60e3f936b3397040eb0f1e44c9f3 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 3 Aug 2026 11:33:19 -0600 Subject: [PATCH 024/163] chore(release): release Buzz Desktop version 0.5.4 (#4562) ## Buzz Desktop release v0.5.4 - **Frozen main:** `6de85fe31d781122756aecf954bae7d357a56b9a` - **Reviewed candidate:** `5836cb8f0af478ed3ee3bc6464a20fa4cc91303f` - **Previous desktop release:** `desktop-v0.5.3` - **Proposed immutable tag:** `desktop-v0.5.4` This PR must be **squash merged** only after the Desktop Release Candidate check passes. The branch must remain based directly on current `main`; stale base, payload drift, incomplete notes, or an unauthorized merge produce no tag. The checked-in changelog accounts for every non-merge commit in the release range. Publication remains bound to the immutable candidate tag. Signed-off-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 10 +++---- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++ 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, 59 insertions(+), 9 deletions(-) diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index 1bf2efb66bc..1ba64765ffe 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,8 +1,8 @@ { "schema": 1, - "version": "0.5.3", - "base_sha": "54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a", - "previous_tag": "v0.5.2", - "tag": "desktop-v0.5.3", - "commit_count": 58 + "version": "0.5.4", + "base_sha": "6de85fe31d781122756aecf954bae7d357a56b9a", + "previous_tag": "desktop-v0.5.3", + "tag": "desktop-v0.5.4", + "commit_count": 40 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 71a4bbd4495..e30941a355b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## v0.5.4 + +### Desktop and shared changes + +- fix: report agent usage per provider round, not once per turn ([#4545](https://github.com/block/buzz/pull/4545)) ([`09c86c56e52651c017743268fc8ce708bb83b265`](https://github.com/block/buzz/commit/09c86c56e52651c017743268fc8ce708bb83b265)) +- fix(desktop): harden Windows installs against Defender block and orphaned Node ([#4382](https://github.com/block/buzz/pull/4382)) ([`80315ac1a68024c40b61f3a062c9cb6bf7d4efb5`](https://github.com/block/buzz/commit/80315ac1a68024c40b61f3a062c9cb6bf7d4efb5)) +- feat(desktop): improve channel template discovery ([#4549](https://github.com/block/buzz/pull/4549)) ([`c1b88af8d71d1cf6aaca517e92ce9e918cd0e8bd`](https://github.com/block/buzz/commit/c1b88af8d71d1cf6aaca517e92ce9e918cd0e8bd)) +- fix(desktop): save key backups to authorized path ([#4022](https://github.com/block/buzz/pull/4022)) ([`01c80aa9b3eaa569361966877994438ad84a280a`](https://github.com/block/buzz/commit/01c80aa9b3eaa569361966877994438ad84a280a)) +- Add channel activity hover menu ([#3935](https://github.com/block/buzz/pull/3935)) ([`b0c6d6f744e63ac88a1738f0e995680c163e1d13`](https://github.com/block/buzz/commit/b0c6d6f744e63ac88a1738f0e995680c163e1d13)) +- feat(desktop): show saved Run on settings when editing an agent ([#4539](https://github.com/block/buzz/pull/4539)) ([`f865c0054b0a400657126c9321b4d4cb7d9cc746`](https://github.com/block/buzz/commit/f865c0054b0a400657126c9321b4d4cb7d9cc746)) +- fix(desktop): disambiguate provider API key labels and annotate mint key ([#4406](https://github.com/block/buzz/pull/4406)) ([`5e0efb0bb95182f588390b55cc5affa09114c87e`](https://github.com/block/buzz/commit/5e0efb0bb95182f588390b55cc5affa09114c87e)) +- fix(desktop): make OpenAI key re-enterable after first save in card mint dialog ([#4140](https://github.com/block/buzz/pull/4140)) ([`f810a2f49e213d25119f2aa75b5b577655119b74`](https://github.com/block/buzz/commit/f810a2f49e213d25119f2aa75b5b577655119b74)) +- fix(config-bridge): add harness-definition env tier and fix equal-value model override ([#3580](https://github.com/block/buzz/pull/3580)) ([`be95a8a986d02319b27e8fb57aefe59e33a1eb13`](https://github.com/block/buzz/commit/be95a8a986d02319b27e8fb57aefe59e33a1eb13)) +- fix(desktop): stop the create-agent provider config probe from erasing keystrokes ([#4411](https://github.com/block/buzz/pull/4411)) ([`2c0ac2467437b30953a95e00f419143488bcfcc7`](https://github.com/block/buzz/commit/2c0ac2467437b30953a95e00f419143488bcfcc7)) +- feat(acp): deliver system prompt via _meta.systemPrompt for claude-agent-acp ([#4395](https://github.com/block/buzz/pull/4395)) ([`7ff5fc31895efe6265a379d01637c8ee301872e5`](https://github.com/block/buzz/commit/7ff5fc31895efe6265a379d01637c8ee301872e5)) +- fix(security): bump nostr crates for RUSTSEC-2026-0225..0232 + default sprig image to published digest ([#4392](https://github.com/block/buzz/pull/4392)) ([`318fbf896ec335bc7bcb40edafde0b6ebca53428`](https://github.com/block/buzz/commit/318fbf896ec335bc7bcb40edafde0b6ebca53428)) +- fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures ([#3778](https://github.com/block/buzz/pull/3778)) ([`f86cfc7369d4471f8939ed98be6f597b0a4b0bb2`](https://github.com/block/buzz/commit/f86cfc7369d4471f8939ed98be6f597b0a4b0bb2)) +- feat(k8s): Kubernetes backend plugin + desktop deploy path ([#4289](https://github.com/block/buzz/pull/4289)) ([`6530b58a61d4602d0a371100fedf80c5998b1e34`](https://github.com/block/buzz/commit/6530b58a61d4602d0a371100fedf80c5998b1e34)) +- feat(projects): add buzz projects CLI commands (NIP-MP kind:30621) ([#4020](https://github.com/block/buzz/pull/4020)) ([`b7bb15122e8a2053b545dc2210afc167f6c7a626`](https://github.com/block/buzz/commit/b7bb15122e8a2053b545dc2210afc167f6c7a626)) +- fix(desktop): keep thread-open affordance in archived channels ([#4012](https://github.com/block/buzz/pull/4012)) ([`8e81afa431deecd172f1ad6aab6f022f31cd812c`](https://github.com/block/buzz/commit/8e81afa431deecd172f1ad6aab6f022f31cd812c)) +- fix(desktop): point Oh My Pi preset at omp.sh ([#3516](https://github.com/block/buzz/pull/3516)) ([`3ade48d5030a8f7dbb9d3693f171e5544dcd8df1`](https://github.com/block/buzz/commit/3ade48d5030a8f7dbb9d3693f171e5544dcd8df1)) +- fix(mesh): stop restarting a busy or loading shared-compute node ([#3909](https://github.com/block/buzz/pull/3909)) ([`fa1a5b1a797870724f5c7e7e26931861a60f22cb`](https://github.com/block/buzz/commit/fa1a5b1a797870724f5c7e7e26931861a60f22cb)) +- fix(desktop): preserve first huddle speech ([#3962](https://github.com/block/buzz/pull/3962)) ([`45314fc504113aec7c54ae6520cfe5e1562aae40`](https://github.com/block/buzz/commit/45314fc504113aec7c54ae6520cfe5e1562aae40)) +- feat(desktop): Agent Trading Cards — mintable agent-snapshot card PNGs with optional NIP-44 lock ([#3278](https://github.com/block/buzz/pull/3278)) ([`eb049ddf815d48195e1713afe039d28c950d7933`](https://github.com/block/buzz/commit/eb049ddf815d48195e1713afe039d28c950d7933)) +- feat(relay): accept kind:30621 multi-repo projects at ingest ([#3171](https://github.com/block/buzz/pull/3171)) ([`cb9701cd30fb344bf134585634a09007f3155bfb`](https://github.com/block/buzz/commit/cb9701cd30fb344bf134585634a09007f3155bfb)) + +### Other repository changes + +- test(mobile): assert follow boundary semantics ([#4559](https://github.com/block/buzz/pull/4559)) ([`6de85fe31d781122756aecf954bae7d357a56b9a`](https://github.com/block/buzz/commit/6de85fe31d781122756aecf954bae7d357a56b9a)) +- docs(release): align desktop handoff instructions ([#3988](https://github.com/block/buzz/pull/3988)) ([`44fa1e8e3af30d561de981a211ff7a79bfa36493`](https://github.com/block/buzz/commit/44fa1e8e3af30d561de981a211ff7a79bfa36493)) +- Polish mobile composer and messaging UI ([#3918](https://github.com/block/buzz/pull/3918)) ([`857e63c4ddfb76f95ab40bb691e00544413f6b81`](https://github.com/block/buzz/commit/857e63c4ddfb76f95ab40bb691e00544413f6b81)) +- ci(linux): enable mesh-llm feature in Linux release and canary builds ([#4524](https://github.com/block/buzz/pull/4524)) ([`83a285f1b1a0be862d55781fad9c75ec8813886d`](https://github.com/block/buzz/commit/83a285f1b1a0be862d55781fad9c75ec8813886d)) +- fix(mobile): recover and pace live subscriptions ([#3053](https://github.com/block/buzz/pull/3053)) ([`a5dbdf5e61e4c512acd99c219c79c154ddb57295`](https://github.com/block/buzz/commit/a5dbdf5e61e4c512acd99c219c79c154ddb57295)) +- fix(git): allow deleting the default branch ([#4297](https://github.com/block/buzz/pull/4297)) ([`fc598f5f8d70728d11d0712b9fa8e3acc44ea4c3`](https://github.com/block/buzz/commit/fc598f5f8d70728d11d0712b9fa8e3acc44ea4c3)) +- docs: formal spec for remote agents and their management ([#3748](https://github.com/block/buzz/pull/3748)) ([`28ae6cd2174309529305724e455c7ca082f6fe4b`](https://github.com/block/buzz/commit/28ae6cd2174309529305724e455c7ca082f6fe4b)) +- fix(nip-oa): accept raw Nostr tag form in parse_json_array ([#4203](https://github.com/block/buzz/pull/4203)) ([`89bf03c05df795a3575b7abbe648be898ef13388`](https://github.com/block/buzz/commit/89bf03c05df795a3575b7abbe648be898ef13388)) +- perf(relay): serve relay-membership checks from the read replica ([#4124](https://github.com/block/buzz/pull/4124)) ([`ac4fa13b8e4d947071d57deb6918dcf12bf74961`](https://github.com/block/buzz/commit/ac4fa13b8e4d947071d57deb6918dcf12bf74961)) +- chore(deps): bump nostr-relay-pool for RUSTSEC-2026-0224 ([#4139](https://github.com/block/buzz/pull/4139)) ([`9d6726e5b387310975f5809473ce8372f6fde0dc`](https://github.com/block/buzz/commit/9d6726e5b387310975f5809473ce8372f6fde0dc)) +- docs(nostr): document #h requirement for live reaction subscriptions ([#3487](https://github.com/block/buzz/pull/3487)) ([`756dd7f65d6f2995e9188a0ffe54294057f8ef4f`](https://github.com/block/buzz/commit/756dd7f65d6f2995e9188a0ffe54294057f8ef4f)) +- docs(chart): fix ArgoCD example for native OCI sources (full artifact repoURL + path) ([#3426](https://github.com/block/buzz/pull/3426)) ([`36cf932ff0105a4cf574fc687deb4c1cb01bc0d1`](https://github.com/block/buzz/commit/36cf932ff0105a4cf574fc687deb4c1cb01bc0d1)) +- docs(readme): clarify which release asset to download per platform ([#3481](https://github.com/block/buzz/pull/3481)) ([`8d5afb606763fcaffd3af811be2106e41cc7347d`](https://github.com/block/buzz/commit/8d5afb606763fcaffd3af811be2106e41cc7347d)) +- fix(relay): allow open relays to set their NIP-11 workspace icon (kind:9033) ([#3998](https://github.com/block/buzz/pull/3998)) ([`5765fc74b77224f0207ddd4b41736a5ff18d333d`](https://github.com/block/buzz/commit/5765fc74b77224f0207ddd4b41736a5ff18d333d)) +- docs: note that addressable channel events scope by d, not h ([#4103](https://github.com/block/buzz/pull/4103)) ([`3d7712cc36e8da563cb1c121fc58bfc505d38496`](https://github.com/block/buzz/commit/3d7712cc36e8da563cb1c121fc58bfc505d38496)) +- docs: fix stale kind count, quick-start numbering, and empty Further Reading ([#2613](https://github.com/block/buzz/pull/2613)) ([`909a3b2c318b2ec477a3438a998a3b611f5b6d6a`](https://github.com/block/buzz/commit/909a3b2c318b2ec477a3438a998a3b611f5b6d6a)) +- docs: add one-click Railway deploy for a hosted relay ([#2733](https://github.com/block/buzz/pull/2733)) ([`19d57b0d46baa55814ac737041a36d0b405c9f64`](https://github.com/block/buzz/commit/19d57b0d46baa55814ac737041a36d0b405c9f64)) +- fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events ([#3999](https://github.com/block/buzz/pull/3999)) ([`b1b283cd4c7f926e12eeee8ae1f38c7471922b16`](https://github.com/block/buzz/commit/b1b283cd4c7f926e12eeee8ae1f38c7471922b16)) +- fix(release): preserve main in desktop PR body ([#3979](https://github.com/block/buzz/pull/3979)) ([`e5e5bac2a932b2b2e4eb6b559d5545a992c21b96`](https://github.com/block/buzz/commit/e5e5bac2a932b2b2e4eb6b559d5545a992c21b96)) + +[Compare desktop-v0.5.3...desktop-v0.5.4](https://github.com/block/buzz/compare/desktop-v0.5.3...desktop-v0.5.4) + ## v0.5.3 ### Desktop and shared changes diff --git a/desktop/package.json b/desktop/package.json index e8145f54686..d0b99c53f64 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.3", + "version": "0.5.4", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 9feecbee01c..365c8f712e1 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.4" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index fd58f27878a..bf3a4ffe96b 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.4" 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 93326b99688..4bda55fd095 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.4", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From ce56e34411d2940e70a6c0de653ffae36d334701 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Mon, 3 Aug 2026 12:28:50 -0700 Subject: [PATCH 025/163] fix(mobile): recover stale relay sessions (#4372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Summary Fixes [this issue](buzz://message?channel=e62570dd-33ad-42c5-b92b-75f2689f9694&id=b726c366abfe62429ee3cdcd34d0c0fb98c33c3ea053480585bed71745412b56): > I often don’t see my bot responses until after I post. they’re usually time stamped correctly so I think it’s just a refresh issue? ### What changed? Buzz Mobile now reconnects relay sessions after the app has remained backgrounded beyond the existing 5-second grace period, even when the session still reports a stale `connected` state. This makes resume recovery independent of whether iOS runs the grace timer before or after delivering `resumed`. Reconnection is now based on elapsed background time rather than a direct socket-health probe. - If the app was backgrounded for at least the 5-second grace period, the socket is presumed dead and the session reconnects regardless of reported status. - If it was backgrounded for less than that, a reported `connected` status is still trusted. In the sub-5-second window the socket is either genuinely alive, which is the common case for a momentary background, or it is dead and the client ping detects it within the two-interval worst case described below. That is now a degraded-latency path, not a silent-forever path. The mobile relay socket now uses `IOWebSocketChannel.connect` with a 30-second `pingInterval`. An unanswered ping closes the Dart socket through the existing disconnect and reconnect path. Detection takes up to two ping intervals, so about 60 seconds worst case, not 30. One interval of idleness elapses and a ping is sent, then a second interval elapses with no pong and the socket closes. Any inbound pong restarts the first stage, so the clock measures idleness rather than running on a fixed cadence. ### Why? Buzz iOS can sometimes stop showing new bot or agent responses after a phone has been locked for 5 to 10 minutes. When the user later posts a message, the missing responses can appear all at once. iOS may suspend Buzz before the short delayed cleanup that would normally close its connection has a chance to run. Before this change, Buzz trusted the resulting stale healthy status on resume and skipped reconnecting, so the missing responses stayed hidden until a later post exposed the dead connection. A state-machine test with a stubbed connection reproduced this reported pattern and showed that it matches this failure mode: the failed post triggered a reconnect that fetched the missing messages. The same test also checked the other candidate explanation, the bug tracked in [#3053](https://github.com/block/buzz/pull/3053), where the relay has closed the app's subscription. That state does not produce the pattern. Posting succeeds and the user's own message appears, but nothing looks for the missed messages, so they stay hidden. The test confirmed that the missed messages were still available to fetch in that state, so the missing step was a trigger to fetch them. This was not an end-to-end reproduction on an iOS device or a live relay. The new resume check covers the normal lock and unlock path. If the app was backgrounded for less than the 5-second grace period, it still trusts a connection marked as healthy. A dead connection in that window is instead detected by the ping check, which can take up to about 60 seconds but prevents the app from remaining silently stuck. The ping only runs while iOS is running the app, so it does not detect a connection that died during suspension; the resume check owns the lock and unlock path. A pre-existing path also runs the same resume handling when network connectivity returns while the app is already in the foreground. Because the app was not backgrounded, this change does not alter that path, which still trusts a connection marked as healthy and relies on the slower ping check. Recovery from a subscription that the relay explicitly closes remains in [#3053](https://github.com/block/buzz/pull/3053), and the two changes overlap in one file. Changes to how missed messages are backfilled or replayed are out of scope. ### How is it tested? Full mobile suite at base and head. Both runs have the same known macOS-host-only failure in `ChannelDetailPage keeps follow mode off while a tall newest message stays visible` at line 1053: - Base: 1,021 passed, 1 skipped, 1 failed - Head: 1,025 passed, 1 skipped, 1 failed Added tests: - [`relay_session_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/shared/relay/relay_session_test.dart): long-background resume reconnect and within-grace control - [`relay_socket_liveness_test.dart`](https://github.com/block/buzz/tree/main/mobile/test/shared/relay/relay_socket_liveness_test.dart): silent-peer disconnect and idle-but-healthy control Mutation checks confirm that removing elapsed-background resume recovery fails with one socket instead of two, and removing `pingInterval` leaves the silent peer connected. Restored production code passes both mutations' regression tests and the healthy idle control. Signed-off-by: Tom Brow Co-authored-by: npub1tquskdu6yc4h8l7xxtceculxw600grekeq0xg2ukqfrwl7vrzg3quz3gmp <58390b379a262b73ffc632f19c73e6769ef40f36c81e642b960246eff9831222@buzz.block.builderlab.xyz> --- mobile/lib/shared/relay/relay_session.dart | 21 +++- mobile/lib/shared/relay/relay_socket.dart | 12 +- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + .../test/shared/relay/relay_session_test.dart | 119 +++++++++++++++++- .../relay/relay_socket_liveness_test.dart | 111 ++++++++++++++++ 6 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 mobile/test/shared/relay/relay_socket_liveness_test.dart diff --git a/mobile/lib/shared/relay/relay_session.dart b/mobile/lib/shared/relay/relay_session.dart index 877bd15e827..1c5c305b4cd 100644 --- a/mobile/lib/shared/relay/relay_session.dart +++ b/mobile/lib/shared/relay/relay_session.dart @@ -89,17 +89,20 @@ class RelaySessionNotifier extends Notifier { RelaySessionNotifier({ http.Client? httpClient, RelaySocketFactory socketFactory = RelaySocket.new, + DateTime Function()? now, RelayRateLimitGate? rateLimitGate, RelayTimerFactory retryTimerFactory = Timer.new, Future Function(Duration) replayDelay = Future.delayed, }) : _httpClient = httpClient, _socketFactory = socketFactory, + _now = now ?? DateTime.now, _rateLimitGate = rateLimitGate ?? RelayRateLimitGate(), _retryTimerFactory = retryTimerFactory, _replayDelay = replayDelay; final http.Client? _httpClient; final RelaySocketFactory _socketFactory; + final DateTime Function() _now; final RelayRateLimitGate _rateLimitGate; final RelayTimerFactory _retryTimerFactory; final Future Function(Duration) _replayDelay; @@ -111,6 +114,7 @@ class RelaySessionNotifier extends Notifier { static const _replayBatchSize = 8; static const _replayInterBatchDelay = Duration(milliseconds: 50); static const _maxRecentDeliveryKeys = 5000; + static const _backgroundGraceDuration = Duration(seconds: 5); RelaySocket? _socket; final Map _historySubscriptions = {}; @@ -122,6 +126,7 @@ class RelaySessionNotifier extends Notifier { Timer? _reconnectTimer; Timer? _flushTimer; Timer? _backgroundGraceTimer; + DateTime? _backgroundedAt; int _reconnectDelayMs = _baseReconnectDelayMs; int _subIdCounter = 0; bool _disposed = false; @@ -394,8 +399,9 @@ class RelaySessionNotifier extends Notifier { /// Called by the app lifecycle provider when the app goes to background. void onAppPaused() { + _backgroundedAt = _now(); _backgroundGraceTimer?.cancel(); - _backgroundGraceTimer = Timer(const Duration(seconds: 5), _pauseNow); + _backgroundGraceTimer = Timer(_backgroundGraceDuration, _pauseNow); } void _pauseNow() { @@ -411,12 +417,18 @@ class RelaySessionNotifier extends Notifier { /// Called by the app lifecycle provider when the app returns to foreground. void onAppResumed() { _paused = false; + final backgroundedAt = _backgroundedAt; + _backgroundedAt = null; _backgroundGraceTimer?.cancel(); _backgroundGraceTimer = null; - // If still connected, nothing to do — the socket survived the background - // grace window. - if (state.status == SessionStatus.connected) return; + final backgroundedLongEnoughToRequireReconnect = + backgroundedAt != null && + _now().difference(backgroundedAt) >= _backgroundGraceDuration; + if (!backgroundedLongEnoughToRequireReconnect && + state.status == SessionStatus.connected) { + return; + } // Cancel any in-flight reconnect backoff timer so we reconnect immediately // instead of waiting for the (possibly large) exponential delay. @@ -908,6 +920,7 @@ class RelaySessionNotifier extends Notifier { _reconnectTimer?.cancel(); _flushTimer?.cancel(); _backgroundGraceTimer?.cancel(); + _backgroundedAt = null; _cancelAllClosedRetries(); _rateLimitGate.reset(); _visibleChannelsByOwner.clear(); diff --git a/mobile/lib/shared/relay/relay_socket.dart b/mobile/lib/shared/relay/relay_socket.dart index 267b030391d..5b23279e814 100644 --- a/mobile/lib/shared/relay/relay_socket.dart +++ b/mobile/lib/shared/relay/relay_socket.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:nostr/nostr.dart' as nostr; +import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'nostr_models.dart'; @@ -30,6 +31,12 @@ Exception classifyRelayAuthFailure(String message) { } class RelaySocket { + /// Interval for sending a ping and awaiting its pong before disconnecting. + static const pingInterval = Duration(seconds: 30); + + @visibleForTesting + static Duration debugPingInterval = pingInterval; + final String _wsUrl; final String? _nsec; final void Function(List message) _onMessage; @@ -63,7 +70,10 @@ class RelaySocket { _state = SocketState.connecting; try { - _channel = WebSocketChannel.connect(Uri.parse(_wsUrl)); + _channel = IOWebSocketChannel.connect( + Uri.parse(_wsUrl), + pingInterval: debugPingInterval, + ); await _channel!.ready; } catch (e) { _state = SocketState.disconnected; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 05ccc2ea0f7..6287e4c86c4 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -274,7 +274,7 @@ packages: source: hosted version: "0.3.5+2" crypto: - dependency: transitive + dependency: "direct dev" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 42b79355829..41d2a0aeb8e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -47,6 +47,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + crypto: ^3.0.7 custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 mocktail: ^1.0.4 diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index b97df0849d5..826e2344000 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -432,6 +432,120 @@ void main() { expect(session.state.status, SessionStatus.disconnected); }); + test( + 'resume reconnects a stale connected session after a long pause', + () async { + final sockets = <_ControlledRelaySocket>[]; + final keychain = nostr.Keys.generate(); + var now = DateTime(2026, 8, 2, 12); + final session = RelaySessionNotifier( + now: () => now, + socketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) { + final socket = _ControlledRelaySocket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: onMessage, + onConnected: onConnected, + onDisconnected: onDisconnected, + ); + sockets.add(socket); + return socket; + }, + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + ), + ), + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + final subscription = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(subscription.close); + await Future.delayed(Duration.zero); + sockets.single.connectSuccessfully(); + + session.onAppPaused(); + now = now.add(const Duration(minutes: 5)); + session.onAppResumed(); + await Future.delayed(Duration.zero); + + expect(sockets, hasLength(2)); + expect(sockets.first.disposeCalls, 1); + expect(session.state.status, SessionStatus.reconnecting); + }, + ); + + test( + 'resume keeps a connected session within the background grace period', + () async { + final sockets = <_ControlledRelaySocket>[]; + final keychain = nostr.Keys.generate(); + var now = DateTime(2026, 8, 2, 12); + final session = RelaySessionNotifier( + now: () => now, + socketFactory: + ({ + required wsUrl, + required nsec, + required onMessage, + required onConnected, + required onDisconnected, + }) { + final socket = _ControlledRelaySocket( + wsUrl: wsUrl, + nsec: nsec, + onMessage: onMessage, + onConnected: onConnected, + onDisconnected: onDisconnected, + ); + sockets.add(socket); + return socket; + }, + ); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + relayConfigProvider.overrideWith( + () => _FakeRelayConfigNotifier( + baseUrl: 'https://relay.example', + nsec: keychain.nsec, + ), + ), + authProvider.overrideWith(() => _AuthenticatedAuthNotifier()), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + final subscription = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(subscription.close); + await Future.delayed(Duration.zero); + sockets.single.connectSuccessfully(); + + session.onAppPaused(); + now = now.add(const Duration(seconds: 4)); + session.onAppResumed(); + await Future.delayed(Duration.zero); + + expect(sockets, hasLength(1)); + expect(sockets.single.disposeCalls, 0); + expect(session.state.status, SessionStatus.connected); + }, + ); + test('delivers the same live event to each matching subscription', () async { final session = RelaySessionNotifier(); final firstEvents = []; @@ -1143,6 +1257,7 @@ class _AuthenticatedAuthNotifier extends AuthNotifier { class _ControlledRelaySocket extends RelaySocket { final void Function() _connected; final void Function(Object? error) _disconnected; + int disposeCalls = 0; _ControlledRelaySocket({ required super.wsUrl, @@ -1157,7 +1272,9 @@ class _ControlledRelaySocket extends RelaySocket { Future connect() async {} @override - void dispose() {} + void dispose() { + disposeCalls++; + } void connectSuccessfully() => _connected(); diff --git a/mobile/test/shared/relay/relay_socket_liveness_test.dart b/mobile/test/shared/relay/relay_socket_liveness_test.dart new file mode 100644 index 00000000000..835ebc2b61d --- /dev/null +++ b/mobile/test/shared/relay/relay_socket_liveness_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A server that completes the WS handshake then never speaks again: no pongs, +/// no close frame. Only a client-side ping timeout can notice. +Future _silentAfterHandshakeServer() async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + server.listen((client) { + client.listen( + (data) { + final match = RegExp( + r'Sec-WebSocket-Key: (.*)\r\n', + caseSensitive: false, + ).firstMatch(String.fromCharCodes(data)); + if (match == null) return; + final accept = base64.encode( + sha1 + .convert( + utf8.encode( + '${match.group(1)!.trim()}258EAFA5-E914-47DA-95CA-C5AB0DC85B11', + ), + ) + .bytes, + ); + client.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\nConnection: Upgrade\r\n' + 'Sec-WebSocket-Accept: $accept\r\n\r\n', + ); + }, + onError: (_) {}, + onDone: () {}, + ); + }); + return server; +} + +void main() { + const testPingInterval = Duration(milliseconds: 150); + + setUp(() { + RelaySocket.debugPingInterval = testPingInterval; + }); + + tearDown(() { + RelaySocket.debugPingInterval = RelaySocket.pingInterval; + }); + + test('detects a peer that stops answering pings', () async { + final server = await _silentAfterHandshakeServer(); + + final disconnected = Completer(); + final socket = RelaySocket( + wsUrl: 'ws://127.0.0.1:${server.port}', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (error) { + if (!disconnected.isCompleted) disconnected.complete(error); + }, + ); + unawaited(socket.connect()); + + var detected = true; + try { + await disconnected.future.timeout(testPingInterval * 4); + } on TimeoutException { + detected = false; + } + + expect( + detected, + isTrue, + reason: + 'RelaySocket must surface an unanswered ping through onDisconnected', + ); + + socket.dispose(); + await server.close(); + }); + + test('keeps an idle but healthy peer connected', () async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + server.transform(WebSocketTransformer()).listen((ws) { + // A healthy relay answers pings without sending application data. + ws.listen((_) {}, onError: (_) {}, onDone: () {}); + }); + + var tornDown = false; + final socket = RelaySocket( + wsUrl: 'ws://127.0.0.1:${server.port}', + nsec: null, + onMessage: (_) {}, + onConnected: () {}, + onDisconnected: (_) => tornDown = true, + ); + unawaited(socket.connect()); + + await Future.delayed(testPingInterval * 4); + + expect(tornDown, isFalse); + + await socket.disconnect(); + await server.close(force: true); + }); +} From e1f6da7c42b0cac6f307023f0479e1e2c3a6d1c0 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 3 Aug 2026 14:03:26 -0600 Subject: [PATCH 026/163] ci: add guarded desktop release cache prewarm (#4575) ## Summary Gate 1 only for desktop release caching: - replaces canary `rust-cache` use with explicit exact-key `actions/cache/restore` + `save` - computes keys after `cargo update --workspace`, including platform, target, Rust toolchain, Cargo manifests/locks, profile/features, and native-toolchain inputs - normalizes only the desktop package version so a trusted `main` canary can warm an otherwise identical release tag - excludes Tauri bundle directories, so installers and signed artifacts are never cached - adds a restore-only `cache-proof-*` tag workflow that fails unless tag scope sees the exact default-branch cache - adds contract tests that enforce no release-workflow cache change in Gate 1 `release.yml` is intentionally unchanged. A cache miss remains the current cold canary build; the release path cannot be affected by merging this PR. ## Validation - `scripts/test-desktop-release-cache-key.sh` - `scripts/test-desktop-release-cache-workflow.sh` - `scripts/test-release-ref-contract.sh` - Ruby YAML parse of all four changed workflows - `git diff --check` - pre-push `branch-skew` ## Post-merge proof plan 1. Run each canary cold on trusted `main`, recording cache size/save time and fresh artifact inventory. 2. Run each canary warm, requiring the exact-key hit and recording restore/build time. 3. Create a disposable `cache-proof-*` tag at that same trusted `main` SHA and dispatch **Desktop release cache tag-scope proof** from the tag. 4. Do not begin Gate 2 or modify `release.yml` unless the exact tag-scope restore succeeds and cache transfer economics are favorable. --------- Signed-off-by: Wes Co-authored-by: Carl --- .../workflows/desktop-release-cache-proof.yml | 164 ++++++++++++++++++ .github/workflows/linux-canary.yml | 66 +++++-- .github/workflows/macos-intel-canary.yml | 126 ++++++++++++++ .github/workflows/signed-macos-canary.yml | 60 +++++-- .github/workflows/windows-canary.yml | 68 ++++++-- scripts/desktop-native-toolchain-id.sh | 34 ++++ scripts/desktop-release-cache-key.py | 85 +++++++++ scripts/test-desktop-release-cache-key.sh | 29 ++++ .../test-desktop-release-cache-workflow.sh | 77 ++++++++ scripts/test-release-ref-contract.sh | 2 + 10 files changed, 672 insertions(+), 39 deletions(-) create mode 100644 .github/workflows/desktop-release-cache-proof.yml create mode 100644 .github/workflows/macos-intel-canary.yml create mode 100755 scripts/desktop-native-toolchain-id.sh create mode 100755 scripts/desktop-release-cache-key.py create mode 100755 scripts/test-desktop-release-cache-key.sh create mode 100755 scripts/test-desktop-release-cache-workflow.sh diff --git a/.github/workflows/desktop-release-cache-proof.yml b/.github/workflows/desktop-release-cache-proof.yml new file mode 100644 index 00000000000..cf9c8e78275 --- /dev/null +++ b/.github/workflows/desktop-release-cache-proof.yml @@ -0,0 +1,164 @@ +name: Desktop release cache tag-scope proof + +# Dispatch from a cache-proof-* tag at the same trusted-main SHA warmed by all +# four canaries. Every job restores only and requires an exact cache hit. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + macos: + name: Prove macOS ${{ matrix.target }} cache visibility + if: github.repository == 'block/buzz' + runs-on: macos-latest + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + features: mesh-llm + - target: x86_64-apple-darwin + features: default + steps: + - name: Require cache proof tag + run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Patch proof dependency graph + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + env: + CACHE_TARGET: ${{ matrix.target }} + CACHE_FEATURES: ${{ matrix.features }} + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target "$CACHE_TARGET" --features "$CACHE_FEATURES" --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' + + linux: + name: Prove Linux cache visibility + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + container: ubuntu:24.04@sha256:4fbb8e6a8395de5a7550b33509421a2bafbc0aab6c06ba2cef9ebffbc7092d90 + timeout-minutes: 15 + defaults: + run: + shell: bash + steps: + - name: Require cache proof tag and install release native tools + run: | + [[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; } + apt-get update + apt-get install -y --no-install-recommends build-essential ca-certificates curl git libasound2-dev libayatana-appindicator3-dev libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev libxdo-dev patchelf pkg-config + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Patch proof dependency graph + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' + + windows: + name: Prove Windows cache visibility + if: github.repository == 'block/buzz' + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Require cache proof tag + shell: bash + run: '[[ "$GITHUB_REF" == refs/tags/cache-proof-* ]] || { echo "::error::Expected cache-proof-* tag; got $GITHUB_REF"; exit 1; }' + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - name: Patch proof dependency graph + shell: bash + run: | + cd desktop && node scripts/set-version-from-tag.mjs "0.0.0-cache-proof" + cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + shell: bash + run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT" + - name: Compute exact release cache key + id: rust_cache_key + shell: bash + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py --platform "$RUNNER_OS" --target x86_64-pc-windows-msvc --features default --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + - name: Restore exact default-branch cache from tag + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Require exact cache hit + shell: bash + env: + CACHE_HIT: ${{ steps.rust_cache.outputs.cache-hit }} + CACHE_KEY: ${{ steps.rust_cache.outputs.cache-primary-key }} + EXPECTED_KEY: ${{ steps.rust_cache_key.outputs.key }} + run: '[[ "$CACHE_HIT" == true && "$CACHE_KEY" == "$EXPECTED_KEY" ]] || { echo "::error::Exact tag cache miss (hit=$CACHE_HIT restored=$CACHE_KEY expected=$EXPECTED_KEY)"; exit 1; }' diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index e1625f4ec83..d8b10032b2a 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -7,8 +7,8 @@ name: Linux Canary # Design notes vs. signed-macos-canary.yml: # - fix-appimage.sh is run without signing env vars; the script detects # their absence and skips re-signing, repacking only (documented inline). -# - mold linker added (rui314/setup-mold) to reduce link time, matching -# the Linux Rust CI jobs in ci.yml. +# - Build tools match release.yml; cache keys derive the concrete linker and +# native library identity rather than assuming the moving runner image. # - pnpm store restore/save pattern mirrors ci.yml:149-196. on: workflow_dispatch: @@ -83,18 +83,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - uses: rui314/setup-mold@9c9c13bf4c3f1adef0cc596abc155580bcb04444 # v1 - - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to linux-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: linux-canary-release - - name: Install appimagetool run: | case "$(uname -m)" in @@ -154,6 +142,38 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh linux)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target x86_64-unknown-linux-gnu \ + --features mesh-llm \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config run: | cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' @@ -190,6 +210,24 @@ jobs: fi bash desktop/scripts/fix-appimage.sh "${APPIMAGES[0]}" + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.github/workflows/macos-intel-canary.yml b/.github/workflows/macos-intel-canary.yml new file mode 100644 index 00000000000..35b05313c9f --- /dev/null +++ b/.github/workflows/macos-intel-canary.yml @@ -0,0 +1,126 @@ +name: macOS Intel Canary + +# Produces an unsigned Intel DMG from trusted main. Its release-equivalent +# Cargo state warms the distinct x86_64 release target without signing or +# publishing anything. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build macOS Intel canary + if: github.repository == 'block/buzz' + runs-on: macos-latest + timeout-minutes: 60 + env: + TARGET: x86_64-apple-darwin + steps: + - name: Require main + env: + SOURCE_REF: ${{ github.ref }} + run: | + if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then + echo "::error::Canary builds must run from main; got $SOURCE_REF" + exit 1 + fi + + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + + - name: Add Rust target + run: rustup target add "$TARGET" + + - name: Install desktop dependencies + run: just desktop-install-ci + + - name: Derive and patch canary version + run: | + BASE_VERSION=$(node -p "require('./desktop/package.json').version") + VERSION="${BASE_VERSION%%-*}-intel-test.${GITHUB_RUN_NUMBER}" + cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" + cd src-tauri && cargo update --workspace + + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target "$TARGET" \ + --features default \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + + - name: Generate non-updating bundle config + run: | + cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' + {"bundle":{"createUpdaterArtifacts":false,"macOS":{"minimumSystemVersion":"10.15"}}} + JSON + + - name: Build Intel sidecars + run: | + cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli + ./scripts/bundle-sidecars.sh "$TARGET" + + - name: Build unsigned Intel DMG + run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --bundles dmg --config src-tauri/tauri.canary.conf.json + env: + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + MACOSX_DEPLOYMENT_TARGET: "10.15" + CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" + TAURI_BUNDLER_DMG_IGNORE_CI: "true" + + - name: Locate fresh Intel DMG + id: artifact + run: | + DMG=$(find "desktop/src-tauri/target/${TARGET}/release/bundle/dmg" -name '*.dmg' -type f | head -1) + [[ -n "$DMG" ]] || { echo "::error::No Intel DMG found"; exit 1; } + echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + + - name: Upload Intel canary + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: buzz-macos-intel-canary-${{ github.sha }} + path: ${{ steps.artifact.outputs.dmg }} + if-no-files-found: error + retention-days: 7 + + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index 0a3a513eef0..5957f4785dd 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -34,16 +34,6 @@ jobs: - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to macos-canary-release so canary runs - # warm each other without colliding with CI's debug-profile keys. - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: macos-canary-release - - name: Get pnpm store directory id: pnpm-cache run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" @@ -78,6 +68,38 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + run: echo "id=$(scripts/desktop-native-toolchain-id.sh macos)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target aarch64-apple-darwin \ + --features mesh-llm \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config run: | cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' @@ -210,6 +232,24 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Measure release Cargo cache inputs + if: always() + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers or signed artifacts from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 29f74fa0f64..7093efd2dc6 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -46,24 +46,9 @@ jobs: shell: bash run: rustup target add "$TARGET" - # Rust cache covering both the workspace sidecar build and the Tauri - # crate build. shared-key scoped to windows-canary-release so canary - # runs warm each other without colliding with CI's debug-profile key - # (CI windows job does clippy/check, not --release). - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - with: - workspaces: | - . - desktop/src-tauri - shared-key: windows-canary-release - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.14.1 - # Disable setup-node's built-in cache: we manage the pnpm store cache - # explicitly below (restore before install, save after) to mirror the - # pattern used by ci.yml and to keep caching logic consistent across - # all three canary workflows. package-manager-cache: false - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 @@ -108,6 +93,40 @@ jobs: cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" cd src-tauri && cargo update --workspace + - name: Resolve native toolchain identity + id: native_toolchain + shell: bash + run: echo "id=$(scripts/desktop-native-toolchain-id.sh windows)" >> "$GITHUB_OUTPUT" + + # Compute this after cargo update so the key describes the graph that is + # actually compiled. The helper normalizes only Buzz Desktop's release + # version, allowing a canary to warm an otherwise identical tag build. + - name: Compute exact release cache key + id: rust_cache_key + shell: bash + env: + NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }} + run: | + KEY=$(scripts/desktop-release-cache-key.py \ + --platform "$RUNNER_OS" \ + --target x86_64-pc-windows-msvc \ + --features default \ + --native-inputs "$NATIVE_TOOLCHAIN_ID") + echo "key=$KEY" >> "$GITHUB_OUTPUT" + echo "Release cache key: $KEY" + + - name: Restore exact release Cargo cache + id: rust_cache + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Generate non-updating bundle config shell: bash run: | @@ -152,6 +171,25 @@ jobs: if-no-files-found: error retention-days: 7 + - name: Measure release Cargo cache inputs + if: always() + shell: bash + run: du -sh ~/.cargo/registry ~/.cargo/git target desktop/src-tauri/target 2>/dev/null || true + + # Only this trusted, main-bound canary writes the cache. Excluding bundle + # output prevents installers from entering it. + - name: Save exact release Cargo cache + if: steps.rust_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + desktop/src-tauri/target + !desktop/src-tauri/target/**/release/bundle + key: ${{ steps.rust_cache_key.outputs.key }} + - name: Save pnpm store cache uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5 with: diff --git a/scripts/desktop-native-toolchain-id.sh b/scripts/desktop-native-toolchain-id.sh new file mode 100755 index 00000000000..b3c5f8bb758 --- /dev/null +++ b/scripts/desktop-native-toolchain-id.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +platform=${1:?usage: desktop-native-toolchain-id.sh } +case "$platform" in + macos) + { + sw_vers + xcodebuild -version + xcrun --sdk macosx --show-sdk-path + xcrun --sdk macosx --show-sdk-version + xcrun clang --version + } ;; + linux) + { + cat /etc/os-release + dpkg-query -W -f='${Package}=${Version}\n' \ + build-essential libasound2-dev libayatana-appindicator3-dev \ + libgtk-3-dev librsvg2-dev libssl-dev libwebkit2gtk-4.1-dev \ + libxdo-dev patchelf pkg-config + gcc -dumpfullversion -dumpversion + gcc -dumpmachine + ld --version + } ;; + windows) + { + cmd.exe //c ver + powershell.exe -NoProfile -Command '$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"; & $vswhere -latest -products * -property installationVersion; Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio\2022" -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name; Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\Include" -Directory | Select-Object -ExpandProperty Name; Get-ChildItem "${env:ProgramFiles}\Microsoft Visual Studio\2022\*\VC\Tools\MSVC" -Directory | Select-Object -ExpandProperty Name' + cmake --version + } ;; + *) + echo "unsupported native toolchain platform: $platform" >&2 + exit 1 ;; +esac | tr -d '\r' | python3 -c 'import hashlib, sys; print(hashlib.sha256(sys.stdin.buffer.read()).hexdigest())' diff --git a/scripts/desktop-release-cache-key.py b/scripts/desktop-release-cache-key.py new file mode 100755 index 00000000000..736b3fcfa95 --- /dev/null +++ b/scripts/desktop-release-cache-key.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Compute an exact, version-agnostic Cargo release cache key.""" + +from __future__ import annotations + +import argparse +import hashlib +import pathlib +import re +import subprocess +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[1] +DESKTOP_MANIFEST = pathlib.Path("desktop/src-tauri/Cargo.toml") +DESKTOP_LOCK = pathlib.Path("desktop/src-tauri/Cargo.lock") + + +def normalized(path: pathlib.Path, data: bytes) -> bytes: + text = data.decode() + if path == DESKTOP_MANIFEST: + text, count = re.subn( + r'(?ms)(^\[package\].*?^version\s*=\s*)"[^"]+"', + r'\1""', + text, + count=1, + ) + if count != 1: + raise ValueError(f"could not normalize package version in {path}") + elif path == DESKTOP_LOCK: + text, count = re.subn( + r'(?ms)(^name = "buzz-desktop"\nversion = )"[^"]+"', + r'\1""', + text, + count=1, + ) + if count != 1: + raise ValueError(f"could not normalize package version in {path}") + return text.encode() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--platform", required=True) + parser.add_argument("--target", required=True) + parser.add_argument("--features", default="default") + parser.add_argument("--native-inputs", required=True) + args = parser.parse_args() + + manifest_output = subprocess.check_output( + ["git", "ls-files", "*Cargo.toml"], cwd=ROOT, text=True + ) + paths = [ROOT / path for path in manifest_output.splitlines()] + paths += [ROOT / "Cargo.lock", ROOT / DESKTOP_LOCK, ROOT / "rust-toolchain.toml"] + cargo_config = ROOT / ".cargo/config.toml" + if cargo_config.exists(): + paths.append(cargo_config) + + digest = hashlib.sha256() + descriptors = { + "schema": "desktop-rust-release-v1", + "platform": args.platform, + "target": args.target, + "profile": "release", + "features": args.features, + "native-inputs": args.native_inputs, + "rustc": subprocess.check_output(["rustc", "-Vv"], text=True).strip(), + } + for name, value in sorted(descriptors.items()): + digest.update(f"{name}\0{value}\0".encode()) + + for absolute in sorted(set(paths)): + relative = absolute.relative_to(ROOT) + digest.update(str(relative).encode() + b"\0") + digest.update(normalized(relative, absolute.read_bytes()) + b"\0") + + print(f"desktop-rust-release-v1-{args.platform}-{args.target}-{digest.hexdigest()}") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, ValueError, subprocess.CalledProcessError) as error: + print(f"error: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/scripts/test-desktop-release-cache-key.sh b/scripts/test-desktop-release-cache-key.sh new file mode 100755 index 00000000000..0b91b3e281c --- /dev/null +++ b/scripts/test-desktop-release-cache-key.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "$0")/.." && pwd) +key_script="scripts/desktop-release-cache-key.py" +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +cp -R "$repo_root"/. "$tmp/repo" +cd "$tmp/repo" + +args=(--platform Linux --target x86_64-unknown-linux-gnu --features mesh-llm --native-inputs ubuntu-24.04-mold) +original=$("$key_script" "${args[@]}") +python3 - <<'PY' +from pathlib import Path +manifest = Path("desktop/src-tauri/Cargo.toml") +manifest.write_text(manifest.read_text().replace('version = "0.5.4"', 'version = "9.8.7"', 1)) +lock = Path("desktop/src-tauri/Cargo.lock") +text = lock.read_text() +start = text.index('name = "buzz-desktop"') +version = text.index('version = "0.5.4"', start) +lock.write_text(text[:version] + 'version = "9.8.7"' + text[version + len('version = "0.5.4"'):]) +PY +version_only=$("$key_script" "${args[@]}") +[[ "$original" == "$version_only" ]] || { echo "desktop version changed cache key" >&2; exit 1; } +printf '\n# dependency input\n' >> crates/buzz-acp/Cargo.toml +dependency_changed=$("$key_script" "${args[@]}") +[[ "$original" != "$dependency_changed" ]] || { echo "dependency manifest did not change cache key" >&2; exit 1; } +[[ "$original" == desktop-rust-release-v1-Linux-x86_64-unknown-linux-gnu-* ]] || { echo "unexpected key: $original" >&2; exit 1; } +echo "desktop release cache key contract passed" diff --git a/scripts/test-desktop-release-cache-workflow.sh b/scripts/test-desktop-release-cache-workflow.sh new file mode 100755 index 00000000000..67f054a603f --- /dev/null +++ b/scripts/test-desktop-release-cache-workflow.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail +root=$(cd "$(dirname "$0")/.." && pwd) +release="$root/.github/workflows/release.yml" +proof="$root/.github/workflows/desktop-release-cache-proof.yml" +canaries=( + "$root/.github/workflows/signed-macos-canary.yml" + "$root/.github/workflows/macos-intel-canary.yml" + "$root/.github/workflows/windows-canary.yml" + "$root/.github/workflows/linux-canary.yml" +) + +if grep -q 'desktop-rust-release-v1\|desktop-release-cache-key' "$release"; then + echo "Gate 1 must not alter the release cache path" >&2 + exit 1 +fi +for workflow in "${canaries[@]}"; do + grep -q 'refs/heads/main' "$workflow" + grep -q 'desktop-native-toolchain-id.sh' "$workflow" + grep -q 'steps.native_toolchain.outputs.id' "$workflow" + grep -q 'actions/cache/restore@' "$workflow" + grep -q 'actions/cache/save@' "$workflow" + grep -q 'steps.rust_cache.outputs.cache-hit' "$workflow" + grep -q '!desktop/src-tauri/target/\*\*/release/bundle' "$workflow" + if grep -q 'restore-keys:.*desktop-rust\|Swatinem/rust-cache' "$workflow"; then + echo "release Cargo cache must use split actions with no fallback: $workflow" >&2 + exit 1 + fi +done + +# GitHub expressions must enter cache-key steps through env, never by direct +# interpolation into generated shell scripts. This blocks shell injection if a +# matrix or upstream output ever becomes attacker-controlled. +python3 - "$proof" "${canaries[@]}" <<'PY' +import pathlib +import re +import sys + +for filename in sys.argv[1:]: + text = pathlib.Path(filename).read_text() + steps = re.findall( + r"(?ms)^ - name: Compute exact release cache key\n(.*?)(?=^ - (?:name:|uses:)|\Z)", + text, + ) + if not steps: + raise SystemExit(f"cache-key step missing: {filename}") + for step in steps: + run = re.search(r"(?ms)^ run: \|\n(.*?)(?=^ \S|\Z)", step) + if not run: + raise SystemExit(f"cache-key run block missing: {filename}") + if "${{" in run.group(1): + raise SystemExit(f"GitHub expression interpolated into cache-key shell: {filename}") + if "NATIVE_TOOLCHAIN_ID: ${{ steps.native_toolchain.outputs.id }}" not in step: + raise SystemExit(f"native toolchain output not passed through env: {filename}") +PY + +# Producer/proof coverage must match all four release targets and features. +for target in aarch64-apple-darwin x86_64-apple-darwin x86_64-unknown-linux-gnu x86_64-pc-windows-msvc; do + grep -q -- "$target" "$proof" || { echo "proof missing $target" >&2; exit 1; } +done +grep -q -- '--features mesh-llm' "$proof" +grep -q -- '--features default' "$proof" +[[ $(grep -c 'actions/cache/save@' "$proof") -eq 0 ]] +[[ $(grep -c 'Require exact cache hit' "$proof") -eq 3 ]] +grep -q 'refs/tags/cache-proof-' "$proof" + +# Linux producer must match release's default linker, not the CI-only mold path. +if grep -q 'setup-mold\|ubuntu-24.04-mold' "$root/.github/workflows/linux-canary.yml"; then + echo "Linux cache producer diverges from the release linker" >&2 + exit 1 +fi +if grep -q 'setup-mold' "$release"; then + echo "release linker changed; re-review cache equivalence" >&2 + exit 1 +fi + +echo "desktop release cache workflow contract passed" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 6722135bb84..8bfd6798eeb 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -54,6 +54,8 @@ grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/release.yml" grep -q 'verify-release-ref\.sh' "$repo_root/.github/workflows/docker.yml" grep -q 'test-release-ref-contract\.sh' "$repo_root/.github/workflows/ci.yml" "$repo_root/scripts/test-signed-canary-contract.sh" +"$repo_root/scripts/test-desktop-release-cache-key.sh" +"$repo_root/scripts/test-desktop-release-cache-workflow.sh" auto_tag="$repo_root/.github/workflows/auto-tag-on-release-pr-merge.yml" grep -q 'actions/create-github-app-token@' "$auto_tag" grep -q 'client-id:.*vars\.BUZZ_RELEASE_TAGGER_CLIENT_ID' "$auto_tag" From 5c98932c59ee5344e9e8c14525c51f3de16ad2c2 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 3 Aug 2026 13:05:47 -0700 Subject: [PATCH 027/163] feat(desktop): make onboarding model defaults skippable (#3968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Category:** improvement **User Impact:** Users can skip default model configuration during onboarding and finish it later in Settings → Agents. **Problem:** Requiring model defaults during onboarding can block users who are not ready to choose a harness, provider, or model. Skipping also needs to leave existing configuration untouched rather than persisting partial selections. **Solution:** Stage onboarding edits locally and persist them only when users choose Next or Back. A delayed Skip action advances without any configuration write, while a footer hint points users to the settings location for completing setup later.
File changes **desktop/src/features/onboarding/ui/DefaultConfigStep.tsx** Adds the skip action and future-settings hint, and makes model configuration transactional so Skip discards staged changes while Next and Back preserve the intended save behavior. **desktop/src/testing/e2eBridge.ts** Exposes model-config setter call counts so tests can distinguish a true zero-write skip from a write-and-rollback implementation. **desktop/tests/e2e/onboarding-agent-defaults.spec.ts** Covers skipping during loading and after staged edits, verifies zero persistence calls, and confirms Next and Back still commit changes.
## Reproduction steps 1. Start fresh onboarding and continue through harness setup to **Configure your default model settings**. 2. Change the selected harness or model, then choose **Skip for now**. 3. Confirm onboarding advances to **Join or create a community** and the prior global model configuration remains unchanged. 4. Return through onboarding and confirm **Next** saves the staged selection; confirm **Back** also preserves staged changes before returning. 5. Confirm the footer says model defaults can be configured later in **Settings → Agents**. --------- Signed-off-by: Taylor Ho Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- desktop/src/features/agents/AGENTS.md | 19 +- .../onboarding/ui/DefaultConfigStep.tsx | 235 +++++++++++------ .../onboarding/ui/MachineOnboardingFlow.tsx | 6 + .../onboarding/ui/saveCoalescer.test.mjs | 242 ------------------ .../features/onboarding/ui/saveCoalescer.ts | 96 ------- desktop/src/features/onboarding/ui/types.ts | 15 +- desktop/src/testing/e2eBridge.ts | 16 +- .../e2e/onboarding-agent-defaults.spec.ts | 219 +++++++++++++++- desktop/tests/helpers/bridge.ts | 2 + 9 files changed, 412 insertions(+), 438 deletions(-) delete mode 100644 desktop/src/features/onboarding/ui/saveCoalescer.test.mjs delete mode 100644 desktop/src/features/onboarding/ui/saveCoalescer.ts diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index d9222c70329..f2eb7f285cf 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -67,11 +67,17 @@ with a TypeScript lookup table or an id comparison in a component. 7. **Onboarding setup detects readiness; it does not select defaults.** The setup page derives visible and ready harnesses from the runtime catalog and only offers install or sign-in actions. The following defaults page is the - sole onboarding surface that chooses and persists `preferred_runtime`, and - its Finish gate consumes the shared renderer's `onValidityChange` signal — - a harness selection alone does not complete onboarding when the harness + sole onboarding surface that chooses `preferred_runtime`. Its complete draft + lives in machine-onboarding session state, so Back performs no write and + restores even incomplete edits when the user returns. Skip abandons that + draft and advances with zero config writes. Next is the only persistence + boundary: it consumes the shared renderer's `onValidityChange` signal, + disables editing while awaiting `set_global_agent_config`, advances only on + success, and leaves the draft in place with a retryable inline error on + failure. A harness selection alone does not enable Next when the harness requires provider/model/credential config (e.g. buzz-agent with no - provider). Baked build env and runtime-file config satisfy the gate. + provider). Baked build env and runtime-file config satisfy the gate. Drafts + intentionally do not survive an app restart. `onboarding-agent-defaults.spec.ts` is the acceptance gate for anything touching this flow or the shared renderer. 8. **Omit the Model control only after a confirmed successful empty @@ -168,8 +174,9 @@ with a TypeScript lookup table or an id comparison in a component. plus both resolvers, including unknown-reads-as-local and blank-`runOn`-is-not-a-provider. - `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior - acceptance coverage for readiness, failure states, defaults, navigation, - successful-empty vs failed optional-model discovery, and persistence races. + acceptance coverage for readiness, failure states, defaults, session-draft + restoration, zero-write Skip, Next save failure/retry, navigation, and + successful-empty vs failed optional-model discovery. - Rust: `runtime_metadata_env_vars` tests pin spawn-time key application. - Rust: persona sharing/retention tests pin relay+owner scoping, durable enqueue errors, relay rejection/unavailability, and accepted publication. diff --git a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx index 50887f08aa7..78a7a32db8e 100644 --- a/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx +++ b/desktop/src/features/onboarding/ui/DefaultConfigStep.tsx @@ -10,7 +10,6 @@ import { } from "@/features/agents/ui/AgentConfigFields"; import { resetConfigForHarnessChange } from "@/features/agents/ui/agentConfigOptions"; import { AgentDropdownSelect } from "@/features/agents/ui/agentConfigControls"; -import { createSaveCoalescer } from "./saveCoalescer"; import { getBakedBuildEnv, type BakedEnvEntry } from "@/shared/api/tauri"; import { getGlobalAgentConfig, @@ -32,11 +31,12 @@ import { getReadyOnboardingRuntimes, getVisibleOnboardingRuntimes, } from "./onboardingRuntimeSelection"; -import type { DefaultConfigStepActions } from "./types"; +import type { DefaultConfigDraft, DefaultConfigStepActions } from "./types"; type DefaultConfigStepProps = { actions: DefaultConfigStepActions; direction: OnboardingTransitionDirection; + draft: DefaultConfigDraft | null; readyRuntimeIds: readonly string[]; }; @@ -46,28 +46,40 @@ function formatHarnessLabel(runtime: AcpRuntimeCatalogEntry | undefined) { } function AgentDefaultsSection({ + draft, + isPending, + onDraftChange, onPersistenceStateChange, readyRuntimeIds, }: { + draft: DefaultConfigDraft | null; + isPending: boolean; + onDraftChange: (draft: DefaultConfigDraft) => void; onPersistenceStateChange: (state: { canComplete: boolean; - flush: () => Promise; + commit: () => Promise; }) => void; readyRuntimeIds: readonly string[]; }) { const runtimesQuery = useAcpRuntimesQuery(); - const [config, setConfig] = - React.useState(EMPTY_GLOBAL_CONFIG); - const [isLoading, setIsLoading] = React.useState(true); - const [isCustomProvider, setIsCustomProvider] = React.useState(false); - const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); + const initialDraftRef = React.useRef(draft); + const [config, setConfig] = React.useState( + initialDraftRef.current?.config ?? EMPTY_GLOBAL_CONFIG, + ); + const [isLoading, setIsLoading] = React.useState( + initialDraftRef.current === null, + ); + const [isCustomProvider, setIsCustomProvider] = React.useState( + initialDraftRef.current?.isCustomProvider ?? false, + ); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState( + initialDraftRef.current?.isCustomModelEditing ?? false, + ); const [bakedEnv, setBakedEnv] = React.useState([]); - const coalescerRef = React.useRef<{ - enqueue: (value: GlobalAgentConfig) => void; - flush: () => Promise; - cancel: () => void; - } | null>(null); - const [isSaving, setIsSaving] = React.useState(false); + const configRef = React.useRef( + initialDraftRef.current?.config ?? EMPTY_GLOBAL_CONFIG, + ); + const isDirtyRef = React.useRef(initialDraftRef.current?.isDirty ?? false); const [configIsValid, setConfigIsValid] = React.useState(false); React.useEffect(() => { @@ -81,7 +93,11 @@ function AgentDefaultsSection({ if (unmounted) return; - if (configResult.status === "fulfilled") { + if ( + initialDraftRef.current === null && + configResult.status === "fulfilled" + ) { + configRef.current = configResult.value; setConfig(configResult.value); } if (bakedEnvResult.status === "fulfilled") { @@ -92,25 +108,8 @@ function AgentDefaultsSection({ void loadDefaults(); - // The coalescer serializes autosaves and drains any edit that arrived - // while a previous save was in flight. Cancel on unmount so a slow - // in-flight request never calls setState on an unmounted component. - const coalescer = createSaveCoalescer( - // set_global_agent_config returns a save result (config + restart - // counts); the coalescer round-trips the persisted config only. - async (next) => (await setGlobalAgentConfig(next)).config, - (saving) => { - if (!unmounted) setIsSaving(saving); - }, - (saved) => { - if (!unmounted) setConfig(saved); - }, - ); - coalescerRef.current = coalescer; - return () => { unmounted = true; - coalescer.cancel(); }; }, []); @@ -160,15 +159,33 @@ function AgentDefaultsSection({ [readyRuntimes], ); + const updateDraft = React.useCallback( + (next: GlobalAgentConfig, overrides: Partial = {}) => { + isDirtyRef.current = overrides.isDirty ?? true; + configRef.current = next; + setConfig(next); + onDraftChange({ + config: next, + isCustomModelEditing, + isCustomProvider, + isDirty: isDirtyRef.current, + ...overrides, + }); + }, + [isCustomModelEditing, isCustomProvider, onDraftChange], + ); + const handleHarnessChange = React.useCallback( (runtimeId: string) => { const next = resetConfigForHarnessChange(config, runtimeId); setIsCustomModelEditing(false); setIsCustomProvider(false); - setConfig(next); - coalescerRef.current?.enqueue(next); + updateDraft(next, { + isCustomModelEditing: false, + isCustomProvider: false, + }); }, - [config], + [config, updateDraft], ); React.useEffect(() => { @@ -182,28 +199,34 @@ function AgentDefaultsSection({ selectedRuntimeId, ]); - const flushPersistence = React.useCallback( - () => coalescerRef.current?.flush() ?? Promise.resolve(), - [], - ); + const commitPersistence = React.useCallback(async () => { + if (!isDirtyRef.current) return; + const saved = await setGlobalAgentConfig(configRef.current); + isDirtyRef.current = false; + configRef.current = saved.config; + setConfig(saved.config); + }, []); React.useEffect(() => { onPersistenceStateChange({ // configIsValid comes from AgentConfigFields' onValidityChange and // covers model + provider credentials — a harness selection alone is // not a working default (e.g. buzz-agent with no provider configured). - canComplete: selectedRuntimeId.length > 0 && configIsValid && !isSaving, - flush: flushPersistence, + canComplete: selectedRuntimeId.length > 0 && configIsValid, + commit: commitPersistence, }); }, [ + commitPersistence, configIsValid, - flushPersistence, - isSaving, onPersistenceStateChange, selectedRuntimeId, ]); return ( -
+
{configSurfaceLoading ? (
@@ -240,15 +263,25 @@ function AgentDefaultsSection({ config={config} isCustomModelEditing={isCustomModelEditing} isCustomProvider={isCustomProvider} - onConfigChange={(next) => { - // Always apply optimistically so the UI never reverts mid-save, - // then enqueue the persist — the coalescer serialises multiple - // rapid edits into a single trailing request. - setConfig(next); - coalescerRef.current?.enqueue(next); + onConfigChange={updateDraft} + onCustomModelEditingChange={(next) => { + setIsCustomModelEditing(next); + onDraftChange({ + config: configRef.current, + isCustomModelEditing: next, + isCustomProvider, + isDirty: isDirtyRef.current, + }); + }} + onIsCustomProviderChange={(next) => { + setIsCustomProvider(next); + onDraftChange({ + config: configRef.current, + isCustomModelEditing, + isCustomProvider: next, + isDirty: isDirtyRef.current, + }); }} - onCustomModelEditingChange={setIsCustomModelEditing} - onIsCustomProviderChange={setIsCustomProvider} onValidityChange={setConfigIsValid} placeholderClassName="text-foreground/70" runtimeFileConfig={runtimeFileConfig} @@ -259,7 +292,7 @@ function AgentDefaultsSection({ />
)} -
+ ); } @@ -271,28 +304,39 @@ function AgentDefaultsSection({ export function DefaultConfigStep({ actions, direction, + draft, readyRuntimeIds, }: DefaultConfigStepProps) { const [persistenceState, setPersistenceState] = React.useState<{ canComplete: boolean; - flush: () => Promise; - }>({ canComplete: false, flush: () => Promise.resolve() }); - const [completionError, setCompletionError] = React.useState( - null, - ); - const [isCompleting, setIsCompleting] = React.useState(false); + commit: () => Promise; + }>({ canComplete: false, commit: () => Promise.resolve() }); + const [isSaving, setIsSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); const handleComplete = React.useCallback(async () => { - setIsCompleting(true); - setCompletionError(null); + if (isSaving) return; + setIsSaving(true); + setSaveError(null); try { - await persistenceState.flush(); + await persistenceState.commit(); + actions.discardDraft(); actions.complete(); - } catch { - setCompletionError("Couldn't save your default harness. Try again."); - setIsCompleting(false); + } catch (cause) { + setSaveError( + cause instanceof Error + ? cause.message + : "Couldn’t save model settings.", + ); + } finally { + setIsSaving(false); } - }, [actions, persistenceState]); + }, [actions, isSaving, persistenceState]); + + const handleSkip = React.useCallback(() => { + actions.discardDraft(); + actions.complete(); + }, [actions]); return (
- {completionError ? ( -

- {completionError} -

- ) : null}
- + {/* Keep Next centered while the optional action sits beside it. */} +
+ + +
+ + {saveError ? ( +

+ Couldn’t save model settings. {saveError} Try again. +

+ ) : null} + +

+ Configure default models in{" "} + Settings → Agents after + setup. +

); diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index cee17c68f85..693d1af0584 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -33,6 +33,7 @@ import { import { OnboardingFooterProvider } from "./OnboardingFooter"; import { OnboardingSlideTransition } from "./OnboardingSlideTransition"; import { SetupStep } from "./SetupStep"; +import type { DefaultConfigDraft } from "./types"; export type MachineOnboardingPage = | "identity" @@ -85,6 +86,8 @@ export function MachineOnboardingFlow({ IdentityStorage | undefined >(); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); + const [defaultConfigDraft, setDefaultConfigDraft] = + React.useState(null); const [backupSubview, setBackupSubview] = React.useState("created"); const [backupDirection, setBackupDirection] = React.useState< @@ -381,8 +384,11 @@ export function MachineOnboardingFlow({ actions={{ back: () => setPage("setup"), complete: () => complete(selectedPubkey ?? undefined), + discardDraft: () => setDefaultConfigDraft(null), + updateDraft: setDefaultConfigDraft, }} direction="forward" + draft={defaultConfigDraft} readyRuntimeIds={readyRuntimeIds} /> )} diff --git a/desktop/src/features/onboarding/ui/saveCoalescer.test.mjs b/desktop/src/features/onboarding/ui/saveCoalescer.test.mjs deleted file mode 100644 index 02e23c131f2..00000000000 --- a/desktop/src/features/onboarding/ui/saveCoalescer.test.mjs +++ /dev/null @@ -1,242 +0,0 @@ -/** - * Unit tests for the save coalescer helper. - * - * Each test controls the in-flight save duration with a deferred promise so - * it can precisely verify interleaving: one edit during flight, multiple - * overwrites, cancellation, etc. - */ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createSaveCoalescer } from "./saveCoalescer.ts"; - -function deferred() { - let resolve; - let reject; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -// Drain one microtask queue turn so the async drain() loop can advance. -const tick = () => new Promise((r) => setTimeout(r, 0)); - -test("saveCoalescer_single_edit_is_persisted_and_saved_is_applied", async () => { - const persisted = []; - const saved = []; - - const coalescer = createSaveCoalescer( - async (v) => { - persisted.push(v); - return { ...v, fromServer: true }; - }, - () => {}, - (v) => saved.push(v), - ); - - coalescer.enqueue({ model: "claude" }); - await tick(); - await tick(); - - assert.equal(persisted.length, 1); - assert.equal(persisted[0].model, "claude"); - assert.equal(saved.length, 1); - assert.equal(saved[0].fromServer, true); -}); - -test("saveCoalescer_rapid_edits_coalesce_second_is_drained_after_first", async () => { - const d = deferred(); - const persisted = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) await d.promise; - persisted.push(v.n); - return v; - }, - () => {}, - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - // Second edit arrives while first save is still in flight. - coalescer.enqueue({ n: 1 }); - - d.resolve(); - await tick(); - await tick(); - - assert.deepEqual(persisted, [0, 1]); -}); - -test("saveCoalescer_three_rapid_edits_only_first_and_last_are_persisted", async () => { - const d = deferred(); - const persisted = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) await d.promise; - persisted.push(v.n); - return v; - }, - () => {}, - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - coalescer.enqueue({ n: 1 }); // overwritten before drain picks it up - coalescer.enqueue({ n: 2 }); // overwrites n:1 - - d.resolve(); - await tick(); - await tick(); - - // n:1 was never the pending value when the drain loop checked; only n:2. - assert.deepEqual(persisted, [0, 2]); -}); - -test("saveCoalescer_onSaved_suppressed_for_first_when_second_is_pending", async () => { - const d = deferred(); - const savedCalls = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) await d.promise; - return v; - }, - () => {}, - (v) => savedCalls.push(v.n), - ); - - coalescer.enqueue({ n: 0 }); - // Queue n:1 before n:0 resolves — onSaved for n:0 should be suppressed. - coalescer.enqueue({ n: 1 }); - - d.resolve(); - await tick(); - await tick(); - - // Only n:1 (the final save round) triggers onSaved. - assert.deepEqual(savedCalls, [1]); -}); - -test("saveCoalescer_isSaving_transitions_true_then_false", async () => { - const d = deferred(); - const states = []; - - const coalescer = createSaveCoalescer( - async (v) => { - await d.promise; - return v; - }, - (s) => states.push(s), - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - assert.deepEqual(states, [true]); - - d.resolve(); - await tick(); - await tick(); - - assert.deepEqual(states, [true, false]); -}); - -test("saveCoalescer_flush_waits_for_the_latest_pending_save", async () => { - const d = deferred(); - const persisted = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) await d.promise; - persisted.push(v.n); - return v; - }, - () => {}, - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - coalescer.enqueue({ n: 1 }); - let flushed = false; - const flush = coalescer.flush().then(() => { - flushed = true; - }); - await tick(); - assert.equal(flushed, false); - - d.resolve(); - await flush; - assert.deepEqual(persisted, [0, 1]); -}); - -test("saveCoalescer_flush_rejects_when_the_final_save_fails", async () => { - const coalescer = createSaveCoalescer( - async () => { - throw new Error("save failed"); - }, - () => {}, - () => {}, - ); - - coalescer.enqueue({ n: 0 }); - await assert.rejects(coalescer.flush(), /save failed/); -}); - -test("saveCoalescer_cancel_prevents_onSaved_and_onSaving_false", async () => { - const d = deferred(); - const states = []; - const savedCalls = []; - - const coalescer = createSaveCoalescer( - async (v) => { - await d.promise; - return v; - }, - (s) => states.push(s), - (v) => savedCalls.push(v), - ); - - coalescer.enqueue({ n: 0 }); - coalescer.cancel(); - d.resolve(); - await tick(); - await tick(); - - // After cancel, neither onSaved nor onSaving(false) fire. - assert.equal(savedCalls.length, 0); - assert.equal(states.includes(false), false); -}); - -test("saveCoalescer_save_error_does_not_call_onSaved_but_drains_pending", async () => { - const d = deferred(); - const persisted = []; - const savedCalls = []; - - const coalescer = createSaveCoalescer( - async (v) => { - if (v.n === 0) { - await d.promise; - throw new Error("network error"); - } - persisted.push(v.n); - return v; - }, - () => {}, - (v) => savedCalls.push(v.n), - ); - - coalescer.enqueue({ n: 0 }); - coalescer.enqueue({ n: 1 }); // pending while n:0 fails - - d.resolve(); - await tick(); - await tick(); - - // n:0 errored — no onSaved for it; n:1 was drained and saved. - assert.deepEqual(persisted, [1]); - assert.deepEqual(savedCalls, [1]); -}); diff --git a/desktop/src/features/onboarding/ui/saveCoalescer.ts b/desktop/src/features/onboarding/ui/saveCoalescer.ts deleted file mode 100644 index f34327453c2..00000000000 --- a/desktop/src/features/onboarding/ui/saveCoalescer.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Creates an async save coalescer. - * - * When multiple calls to enqueue() arrive while a save is in flight, only - * the latest enqueued value is submitted per drain round — no edit is - * silently dropped and the final persisted state always reflects the most - * recent local change. - * - * Lifecycle: call cancel() on unmount so in-flight saves do not invoke - * callbacks after the owning component is gone. Call flush() before leaving - * a surface that must guarantee its latest optimistic value was persisted. - */ -export function createSaveCoalescer( - save: (value: T) => Promise, - onSaving: (isSaving: boolean) => void, - onSaved: (value: T) => void, -): { - enqueue: (value: T) => void; - flush: () => Promise; - cancel: () => void; -} { - let pending: T | undefined; - let hasPending = false; - let running = false; - let cancelled = false; - let finalError: unknown; - let flushWaiters: Array<{ - resolve: () => void; - reject: (error: unknown) => void; - }> = []; - - function settleFlushWaiters() { - const waiters = flushWaiters; - flushWaiters = []; - for (const waiter of waiters) { - if (finalError === undefined) waiter.resolve(); - else waiter.reject(finalError); - } - } - - async function drain() { - while (hasPending) { - const toSave = pending as T; - hasPending = false; - pending = undefined; - try { - const saved = await save(toSave); - finalError = undefined; - // Apply backend response only when no newer local edit is pending — - // a stale response must never overwrite fresher optimistic state. - if (!cancelled && !hasPending) { - onSaved(saved); - } - } catch (error) { - finalError = error; - } - } - running = false; - if (!cancelled) { - onSaving(false); - settleFlushWaiters(); - } - } - - return { - enqueue(value: T) { - pending = value; - hasPending = true; - if (running) return; - running = true; - finalError = undefined; - onSaving(true); - void drain(); - }, - flush() { - if (!running) { - return finalError === undefined - ? Promise.resolve() - : Promise.reject(finalError); - } - return new Promise((resolve, reject) => { - flushWaiters.push({ resolve, reject }); - }); - }, - cancel() { - cancelled = true; - hasPending = false; - pending = undefined; - const waiters = flushWaiters; - flushWaiters = []; - for (const waiter of waiters) { - waiter.reject(new Error("Save cancelled")); - } - }, - }; -} diff --git a/desktop/src/features/onboarding/ui/types.ts b/desktop/src/features/onboarding/ui/types.ts index 443a1f3f4e7..5216bfefa4d 100644 --- a/desktop/src/features/onboarding/ui/types.ts +++ b/desktop/src/features/onboarding/ui/types.ts @@ -1,4 +1,8 @@ -import type { AcpRuntimeCatalogEntry, Profile } from "@/shared/api/types"; +import type { + AcpRuntimeCatalogEntry, + GlobalAgentConfig, + Profile, +} from "@/shared/api/types"; export type OnboardingPage = | "profile" @@ -62,9 +66,18 @@ export type SetupStepActions = { navigateToAgentSettings?: () => void; }; +export type DefaultConfigDraft = { + config: GlobalAgentConfig; + isCustomModelEditing: boolean; + isCustomProvider: boolean; + isDirty: boolean; +}; + export type DefaultConfigStepActions = { back: () => void; complete: () => void; + discardDraft: () => void; + updateDraft: (draft: DefaultConfigDraft) => void; }; export type SetupStepRuntimeState = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 03d37ed72ed..5cbfb03331a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -448,9 +448,11 @@ type E2eConfig = { * initial render gating around build defaults. 0/undefined = instant. */ bakedBuildEnvDelayMs?: number; /** Delay (ms) applied to `set_global_agent_config` so tests can observe - * autosave behaviour while a request is in flight. 0/undefined = instant. - * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ + * pending save behaviour. 0/undefined = instant. Alias of + * `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Sequenced save failures. A string rejects that call; null succeeds. */ + setGlobalAgentConfigErrors?: (string | null)[]; /** Errors returned by successive backup verification attempts. Null succeeds. */ backupVerificationErrors?: (string | null)[]; /** Public identities returned by successive successful backup verifications. */ @@ -7333,6 +7335,7 @@ let installCallCount = 0; /** Per-runtime call counters for `installAcpRuntimeByRuntime` sequences. */ const installCallCountByRuntime: Record = {}; let addChannelMembersCallCount = 0; +let setGlobalAgentConfigCallCount = 0; let mockGlobalAgentConfig: { env_vars: Record; provider: string | null; @@ -11643,7 +11646,16 @@ export function maybeInstallE2eTauriMocks() { } ); } + case "get_global_agent_config_set_call_count": + return setGlobalAgentConfigCallCount; case "set_global_agent_config": { + setGlobalAgentConfigCallCount += 1; + const saveErrors = activeConfig?.mock?.setGlobalAgentConfigErrors; + const saveError = + saveErrors?.[ + Math.min(setGlobalAgentConfigCallCount - 1, saveErrors.length - 1) + ]; + if (saveError) throw new Error(saveError); // Echo back the submitted config as the saved value (mirrors the // backend's strip-on-write pass in tests where all values are already // non-empty). The invoke payload wraps it as { config }. diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index dbd726d1792..b3462a49039 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -57,6 +57,24 @@ async function readSavedRuntime(page: Parameters[0]) { }); } +async function readGlobalConfigSetterCallCount( + page: Parameters[0], +) { + return await page.evaluate(async () => { + return await ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.( + "get_global_agent_config_set_call_count", + null, + ); + }); +} + test("setup shows all bundled harnesses as detected", async ({ page }) => { await installMockBridge( page, @@ -528,21 +546,127 @@ test("defaults keeps model control when optional harness discovery fails", async await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); }); -test("defaults Back returns to harness setup", async ({ page }) => { +test("defaults can be skipped while loading without persisting configuration", async ({ + page, +}) => { await installMockBridge( page, { acpRuntimesCatalog: [ runtime("claude", "available", { status: "logged_in" }), ], + bakedBuildEnvDelayMs: 500, + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, }, { skipCommunitySeed: true, skipOnboardingSeed: true }, ); await page.goto("/"); await navigateToSetupPage(page); await page.getByTestId("onboarding-setup-next").click(); + + await expect(page.getByText("Loading…")).toBeVisible(); + await page.getByTestId("onboarding-config-skip").click(); + + await expect(page.getByText("Join or create a community")).toBeVisible(); + expect(await readSavedRuntime(page)).toBeNull(); +}); + +test("defaults stages auto-selection and edits without writing when skipped", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Claude Code", + ); + await page.getByTestId("global-agent-model").click(); + await page + .getByTestId("global-agent-model-option-claude-opus-4-20250514") + .click(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); + await expect( + page.getByText( + "Configure default models in Settings → Agents after setup.", + ), + ).toBeVisible(); + + await page.getByTestId("onboarding-config-skip").click(); + + await expect(page.getByText("Join or create a community")).toBeVisible(); + expect(await readSavedRuntime(page)).toBeNull(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); +}); + +test("Back preserves incomplete defaults draft without writing", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("buzz-agent", "available", { status: "not_applicable" }), + runtime("claude", "available", { status: "logged_in" }), + ], + discoverAgentModels: { + models: [{ id: "claude-sonnet-4", name: "Claude Sonnet 4" }], + supportsSwitching: true, + }, + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + + const harness = page.getByTestId("global-agent-default-harness"); + await harness.click(); + await page + .getByTestId("global-agent-default-harness-option-buzz-agent") + .click(); + await page.getByTestId("global-agent-provider").click(); + await page.getByTestId("global-agent-provider-option-anthropic").click(); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + await page.getByTestId("onboarding-back").click(); await expect(page.getByTestId("onboarding-page-2")).toBeVisible(); + expect(await readSavedRuntime(page)).toBeNull(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); + + await page.getByTestId("onboarding-setup-next").click(); + await expect(harness).toHaveText("Buzz"); + await expect(page.getByTestId("global-agent-provider")).toHaveText( + "Anthropic", + ); + await expect(page.getByTestId("onboarding-finish")).toBeDisabled(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); }); test("defaults auto-selects the only ready visible harness", async ({ @@ -575,12 +699,10 @@ test("defaults auto-selects the only ready visible harness", async ({ "Claude Code", ); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); - await expect.poll(() => readSavedRuntime(page)).toBe("claude"); + expect(await readSavedRuntime(page)).toBeNull(); }); -test("Finish waits for the latest rapid harness choice to persist", async ({ - page, -}) => { +test("Next persists the latest staged harness choice", async ({ page }) => { await installMockBridge( page, { @@ -608,13 +730,94 @@ test("Finish waits for the latest rapid harness choice to persist", async ({ await harness.click(); await page.getByTestId("global-agent-default-harness-option-codex").click(); const finish = page.getByTestId("onboarding-finish"); - await expect(finish).toBeDisabled(); - await expect(finish).toBeEnabled({ timeout: 2_000 }); + await expect(finish).toBeEnabled(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(0); await finish.click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + await expect.poll(() => readSavedRuntime(page)).toBe("codex"); +}); + +test("Next shows saving state and advances only after persistence", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + runtime("codex", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + setGlobalAgentConfigDelayMs: 500, + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + + const harness = page.getByTestId("global-agent-default-harness"); + await harness.click(); + await page.getByTestId("global-agent-default-harness-option-codex").click(); + await page.getByTestId("onboarding-finish").click(); + + await expect(page.getByTestId("onboarding-finish")).toHaveText("Saving…"); + await expect(page.getByTestId("onboarding-config-skip")).toBeDisabled(); + await expect(page.getByTestId("onboarding-back")).toBeDisabled(); + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + expect(await readSavedRuntime(page)).toBeNull(); + await expect(page.getByText("Join or create a community")).toBeVisible(); expect(await readSavedRuntime(page)).toBe("codex"); }); +test("Next keeps the draft and retries after a save failure", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime("claude", "available", { status: "logged_in" }), + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: null, + }, + setGlobalAgentConfigErrors: ["Disk is read-only", null], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + await page.getByTestId("onboarding-setup-next").click(); + await expect(page.getByTestId("global-agent-default-harness")).toHaveText( + "Claude Code", + ); + + await page.getByTestId("onboarding-finish").click(); + + await expect(page.getByTestId("onboarding-page-config")).toBeVisible(); + await expect(page.getByTestId("onboarding-config-save-error")).toContainText( + "Disk is read-only", + ); + await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); + expect(await readSavedRuntime(page)).toBeNull(); + expect(await readGlobalConfigSetterCallCount(page)).toBe(1); + + await page.getByTestId("onboarding-finish").click(); + await expect(page.getByText("Join or create a community")).toBeVisible(); + expect(await readSavedRuntime(page)).toBe("claude"); + expect(await readGlobalConfigSetterCallCount(page)).toBe(2); +}); + test("defaults requires a choice when multiple visible harnesses are ready", async ({ page, }) => { @@ -660,7 +863,7 @@ test("defaults requires a choice when multiple visible harnesses are ready", asy await page.getByTestId("global-agent-default-harness-option-codex").click(); await expect(harness).toHaveText("Codex"); await expect(page.getByTestId("onboarding-finish")).toBeEnabled(); - await expect.poll(() => readSavedRuntime(page)).toBe("codex"); + expect(await readSavedRuntime(page)).toBeNull(); }); /** diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 345e1ee4d7f..830a82879a2 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -467,6 +467,8 @@ type MockBridgeOptions = { /** Delay (ms) for `set_global_agent_config` — hold saves open in tests. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Sequenced save failures. A string rejects that call; null succeeds. */ + setGlobalAgentConfigErrors?: (string | null)[]; /** Errors returned by successive backup verification attempts. Null succeeds. */ backupVerificationErrors?: (string | null)[]; /** Public identities returned by successive successful backup verifications. */ From d4a4570b9769743899d97480b3bf482860b51d9c Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 3 Aug 2026 14:47:52 -0600 Subject: [PATCH 028/163] fix(desktop): clarify inherited agent parallelism (#4010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - show an unambiguous `App default (10)` inherited state for parallelism in create and edit forms - explain that blank inherits the app default and suppress create-form number steppers that could silently set `1` - align the E2E mint fallback with production while preserving explicit input → definition → app-default precedence ## Why The forms displayed `1` even though an untouched field is omitted and desktop minting materializes `10`. The create-form spinner could also turn blank/inherited into an explicit `1` with one click while leaving the field looking nearly unchanged. ## Testing - `pnpm test` (desktop: 3,886 passed) - `pnpm typecheck` (desktop) - `pnpm check` (desktop) - pre-push `desktop-check` and `desktop-test` --------- Signed-off-by: Wes Co-authored-by: Carl --- desktop/src-tauri/src/key_backup_tests.rs | 12 +++++++---- .../agents/lib/agentParallelism.test.mjs | 20 +++++++++++++++++++ .../features/agents/lib/agentParallelism.ts | 18 +++++++++++++++++ .../agents/ui/EditAgentAdvancedFields.tsx | 7 ++++++- .../agents/ui/PersonaAdvancedFields.tsx | 10 +++++++--- desktop/src/testing/e2eBridge.ts | 7 +++++-- 6 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 desktop/src/features/agents/lib/agentParallelism.test.mjs create mode 100644 desktop/src/features/agents/lib/agentParallelism.ts diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index 35b486f78dd..ff9367641af 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -250,12 +250,16 @@ fn generated_passphrase_respects_word_count_and_separator() { #[test] fn generated_passphrase_clamps_word_count() { + // Use a separator that cannot appear in the EFF wordlist so a generated + // word such as "yo-yo" cannot be mistaken for two words. + const SEPARATOR: &str = "|"; + // 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); + let phrase = generate_passphrase(1, SEPARATOR).unwrap(); + assert_eq!(phrase.split(SEPARATOR).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); + let phrase = generate_passphrase(50, SEPARATOR).unwrap(); + assert_eq!(phrase.split(SEPARATOR).count(), MAX_PASSPHRASE_WORDS); } #[test] diff --git a/desktop/src/features/agents/lib/agentParallelism.test.mjs b/desktop/src/features/agents/lib/agentParallelism.test.mjs new file mode 100644 index 00000000000..8d03d087e7c --- /dev/null +++ b/desktop/src/features/agents/lib/agentParallelism.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DEFAULT_AGENT_PARALLELISM, + resolveAgentParallelism, +} from "./agentParallelism.ts"; + +test("parallelism uses the app default only when input and definition omit it", () => { + assert.equal( + resolveAgentParallelism(undefined, undefined), + DEFAULT_AGENT_PARALLELISM, + ); + assert.equal( + resolveAgentParallelism(undefined, null), + DEFAULT_AGENT_PARALLELISM, + ); + assert.equal(resolveAgentParallelism(undefined, 4), 4); + assert.equal(resolveAgentParallelism(2, 4), 2); +}); diff --git a/desktop/src/features/agents/lib/agentParallelism.ts b/desktop/src/features/agents/lib/agentParallelism.ts new file mode 100644 index 00000000000..89544c5bb47 --- /dev/null +++ b/desktop/src/features/agents/lib/agentParallelism.ts @@ -0,0 +1,18 @@ +/** + * Desktop-managed agents materialize this value when neither the create input + * nor the linked definition sets parallelism. Keep in sync with + * `managed_agents::DEFAULT_AGENT_PARALLELISM` in the Tauri backend. + */ +export const DEFAULT_AGENT_PARALLELISM = 10; + +export const AGENT_PARALLELISM_PLACEHOLDER = `App default (${DEFAULT_AGENT_PARALLELISM})`; +export const AGENT_PARALLELISM_HELP = `Leave blank to use the app default (currently ${DEFAULT_AGENT_PARALLELISM}). Custom values may be 1–32.`; +export const EDIT_AGENT_PARALLELISM_HELP = + "Current value for this agent. Custom values may be 1–32."; + +export function resolveAgentParallelism( + input: number | undefined, + definition: number | null | undefined, +): number { + return input ?? definition ?? DEFAULT_AGENT_PARALLELISM; +} diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index c8e21cd6fa1..972c4e287e3 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -11,6 +11,7 @@ import { import type { AgentPersona } from "@/shared/api/types"; import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; import { isBuzzAgentRuntime } from "./buzzAgentConfig"; +import { EDIT_AGENT_PARALLELISM_HELP } from "../lib/agentParallelism"; export function EditAgentAdvancedFields({ acpCommand, @@ -173,10 +174,14 @@ export function EditAgentAdvancedFields({ id="edit-agent-parallelism" inputMode="numeric" onChange={(event) => onParallelismChange(event.target.value)} - placeholder="1" + placeholder="Current value" + type="text" value={parallelism} />
+

+ {EDIT_AGENT_PARALLELISM_HELP} +

{/* Relay URL: intentionally no editor. The legacy per-record relay pin diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index 01485dd9eb6..b7d19037846 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -4,6 +4,10 @@ import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { CreateAgentRespondToField } from "./RespondToField"; import type { PersonaBehaviorDraft } from "./personaBehaviorDraft"; import { isBuzzAgentRuntime } from "./buzzAgentConfig"; +import { + AGENT_PARALLELISM_HELP, + AGENT_PARALLELISM_PLACEHOLDER, +} from "../lib/agentParallelism"; import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; import { CARD_MINT_KEY_ANNOTATIONS, @@ -83,7 +87,7 @@ export function PersonaAdvancedFields({ >

- How many conversations each running instance handles at once (1–32). + {AGENT_PARALLELISM_HELP}

diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5cbfb03331a..895e770f0f4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -12,6 +12,7 @@ import { import { relayClient } from "@/shared/api/relayClient"; import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; +import { resolveAgentParallelism } from "@/features/agents/lib/agentParallelism"; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { ChannelTemplate, RelayEvent } from "@/shared/api/types"; import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache"; @@ -8075,8 +8076,10 @@ async function handleCreateManagedAgent( args.input.respondTo !== undefined ? (args.input.respondToAllowlist ?? []) : (linkedPersona?.respond_to_allowlist ?? []); - const mintParallelism = - args.input.parallelism ?? linkedPersona?.parallelism ?? 1; + const mintParallelism = resolveAgentParallelism( + args.input.parallelism, + linkedPersona?.parallelism, + ); const personaAvatarUrl = args.input.personaId === undefined ? null From 79815978483ef0ab78f7159c0add3492da6457a1 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 3 Aug 2026 14:09:46 -0700 Subject: [PATCH 029/163] fix(reactions): wrap long popover names (#3834) **Category:** fix **User Impact:** Long custom emoji names now stay contained inside reaction popovers and remain fully readable. **Problem:** An unbroken custom emoji name could force a reaction popover beyond its intended maximum width and overflow the message view. **Solution:** Give the reaction popover a definite 288px width and allow the complete emoji name to wrap within it without truncation or ellipsis. Short names retain the same content and interaction behavior.
File changes **desktop/src/features/messages/ui/MessageReactions.tsx** Bounds the reaction popover width and allows long names to break across lines while preserving the full shortcode. **desktop/tests/e2e/reaction-names.spec.ts** Covers fixed width, full text preservation, and wrapping for the maximum supported colon-wrapped reaction name, with deterministic seeded Picsum visual fixtures and explicit image-load waits.
## Reproduction Steps 1. Open a message with a custom emoji reaction whose name is 64 characters. 2. Hover or focus the reaction pill to open its details popover. 3. Confirm the popover remains 288px wide and the complete name wraps within it without ellipsis. 4. Open a short-name reaction and confirm its popover remains readable and unchanged in behavior. ## Screenshots | Before | After | | --- | --- | | ![Maximum-length name before](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/max-length-before-picsum.png) | ![Maximum-length name after](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/max-length-after-picsum.png) | **Short-name regression check** ![Short reaction name](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3834/short-name-after-picsum.png) ## Verification - `pnpm test` in `desktop`: 3,858 passed - Focused reaction-name E2E with seeded Picsum captures: 2 passed - Desktop checks and commit hooks passed Originating Buzz channel: `f2ec9671-d78e-4cde-894c-9f4c458c7f1f` --------- Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- .../features/messages/ui/MessageReactions.tsx | 7 +- desktop/tests/e2e/reaction-names.spec.ts | 129 +++++++++++++++++- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index 2250b3931f9..cbcb873f5b6 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -126,7 +126,10 @@ function ReactionPopoverContent({ reaction }: { reaction: TimelineReaction }) {
{userText} reacted with
-
+
{displayName}
@@ -504,7 +507,7 @@ function ReactionPill({ align="start" side="top" sideOffset={6} - className="w-auto min-w-56 max-w-72 rounded-xl p-3" + className="w-72 rounded-xl p-3" onMouseEnter={handleMouseEnter} onMouseLeave={scheduleClose} onOpenAutoFocus={(e) => e.preventDefault()} diff --git a/desktop/tests/e2e/reaction-names.spec.ts b/desktop/tests/e2e/reaction-names.spec.ts index 512b913dffe..028bb1645df 100644 --- a/desktop/tests/e2e/reaction-names.spec.ts +++ b/desktop/tests/e2e/reaction-names.spec.ts @@ -1,11 +1,23 @@ import { expect, test } from "@playwright/test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { waitForAnimations } from "../helpers/animations"; import { installMockBridge } from "../helpers/bridge"; const REACTION_TARGET_CONTENT = "React to me with a custom emoji"; const REACTION_TARGET_EVENT_ID = "d".repeat(64); const BOB_PUBKEY = "bb22a5299220cad76ffd46190ccbeede8ab5dc260faa28b6e5a2cb31b9aff260"; +const MAX_REACTION_NAME = + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl"; +const MAX_REACTION_AVATAR_URL = + 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%23e5484d"/%3E%3C/svg%3E'; +const SHORT_REACTION_AVATAR_URL = + 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"%3E%3Crect width="16" height="16" rx="4" fill="%2300a36c"/%3E%3C/svg%3E'; +const SCREENSHOT_DIR = + process.env.REACTION_POPOVER_SCREENSHOT_DIR ?? + "test-results/reaction-popover-screenshots"; function reactionTargetRow(page: import("@playwright/test").Page) { return page @@ -14,8 +26,45 @@ function reactionTargetRow(page: import("@playwright/test").Page) { .last(); } +async function waitForImage( + image: import("@playwright/test").Locator, +): Promise { + await expect(image).toBeVisible(); + await expect + .poll(() => + image.evaluate( + (element) => + element instanceof HTMLImageElement && + element.complete && + element.naturalWidth > 0, + ), + ) + .toBe(true); +} + +async function capturePopover( + page: import("@playwright/test").Page, + popover: import("@playwright/test").Locator, + filename: string, +): Promise { + fs.mkdirSync(SCREENSHOT_DIR, { recursive: true }); + await waitForAnimations(page); + await popover.screenshot({ + animations: "disabled", + path: path.join(SCREENSHOT_DIR, filename), + }); +} + test.beforeEach(async ({ page }) => { - await installMockBridge(page); + await installMockBridge(page, { + searchProfiles: [ + { + pubkey: BOB_PUBKEY, + displayName: "bob", + avatarUrl: SHORT_REACTION_AVATAR_URL, + }, + ], + }); }); test("reaction popover resolves a reactor with no authored message in the window", async ({ @@ -33,21 +82,89 @@ test("reaction popover resolves a reactor with no authored message in the window ); await page.evaluate( - ({ pubkey, targetId }) => { + ({ pubkey, targetId, avatarUrl }) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "general", - content: "🎉", - extraTags: [["e", targetId]], + content: ":react:", + extraTags: [ + ["e", targetId], + ["emoji", "react", avatarUrl], + ], kind: 7, pubkey, }); }, - { pubkey: BOB_PUBKEY, targetId: REACTION_TARGET_EVENT_ID }, + { + avatarUrl: SHORT_REACTION_AVATAR_URL, + pubkey: BOB_PUBKEY, + targetId: REACTION_TARGET_EVENT_ID, + }, ); const row = reactionTargetRow(page); - const pill = row.getByRole("button", { name: "Toggle 🎉 reaction" }); + const pill = row.getByRole("button", { name: "Toggle :react: reaction" }); await expect(pill).toBeVisible(); await pill.hover(); await expect(page.getByText("bob reacted with")).toBeVisible(); + const popover = page + .locator("[data-radix-popper-content-wrapper]") + .filter({ hasText: "bob reacted with" }); + const avatar = popover.locator("img"); + await expect(avatar).toHaveAttribute("src", SHORT_REACTION_AVATAR_URL); + await waitForImage(avatar); + await capturePopover(page, popover, "short-name-after.png"); +}); + +test("maximum-length reaction name wraps inside a fixed-width popover", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForFunction( + () => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + kind: 7, + }) === true, + ); + + const reaction = `:${MAX_REACTION_NAME}:`; + await page.evaluate( + ({ content, targetId, avatarUrl }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + extraTags: [ + ["e", targetId], + ["emoji", content.slice(1, -1), avatarUrl], + ], + kind: 7, + }); + }, + { + avatarUrl: MAX_REACTION_AVATAR_URL, + content: reaction, + targetId: REACTION_TARGET_EVENT_ID, + }, + ); + + const pill = reactionTargetRow(page).getByRole("button", { + name: `Toggle ${reaction} reaction`, + }); + await expect(pill).toBeVisible(); + await pill.focus(); + + const popover = page + .locator("[data-radix-popper-content-wrapper]") + .filter({ hasText: reaction }); + await expect(popover).toBeVisible(); + await expect(popover).toHaveCSS("width", "288px"); + const reactionName = popover.getByTestId("reaction-popover-name"); + await expect(reactionName).toHaveCSS("word-break", "break-all"); + await expect(reactionName).toHaveText(reaction); + const avatar = popover.locator("img"); + await expect(avatar).toHaveAttribute("src", MAX_REACTION_AVATAR_URL); + await waitForImage(avatar); + await capturePopover(page, popover, "max-length-after.png"); }); From 027a74a61c8643a1d1086d3e8307fad89d7735f7 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Mon, 3 Aug 2026 22:12:21 +0100 Subject: [PATCH 030/163] Polish Share Compute settings (#3735) ## Summary - Refresh Share Compute with the shared agent-style model controls. - Reveal sharing details and advanced options only while sharing. - Remove the preview-only mesh API path. ## Validation - `pnpm check` - `pnpm test` - `pnpm exec playwright test tests/e2e/mesh-compute.spec.ts` Snapshots are attached in a follow-up comment. --------- Signed-off-by: kenny lopez --- .../ui/MeshComputeSettingsCard.tsx | 511 ++++++++++-------- desktop/src/testing/e2eBridge.ts | 47 +- .../global-agent-config-screenshots.spec.ts | 1 + desktop/tests/e2e/mesh-compute.spec.ts | 63 ++- 4 files changed, 364 insertions(+), 258 deletions(-) diff --git a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx index fd7550eff05..bf2d8e7c225 100644 --- a/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx +++ b/desktop/src/features/mesh-compute/ui/MeshComputeSettingsCard.tsx @@ -1,9 +1,19 @@ import * as React from "react"; -import { ChevronDown, Cpu } from "lucide-react"; +import { ChevronDown } from "lucide-react"; +import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { Input } from "@/shared/ui/input"; import { Switch } from "@/shared/ui/switch"; import { cn } from "@/shared/lib/cn"; +import { + AgentConfigTextInput, + AgentDropdownSelect, + type AgentDropdownOption, +} from "@/features/agents/ui/agentConfigControls"; +import { + CUSTOM_MODEL_DROPDOWN_VALUE, + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, +} from "@/features/agents/ui/agentConfigOptions"; import { meshStartNode, @@ -17,10 +27,6 @@ import type { MeshModelOption, MeshNodeStatus, } from "@/shared/api/tauriMesh"; -import { - SettingsOptionGroup, - SettingsOptionRow, -} from "@/features/settings/ui/SettingsOptionGroup"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; import { classifyModelRef } from "../classifyModelRef"; import { @@ -36,6 +42,20 @@ import { deriveServingIndicator } from "../servingUsage"; const MODEL_DRAFT_STORAGE_KEY = "buzz.mesh-compute.share.model.v1"; const MAX_VRAM_DRAFT_STORAGE_KEY = "buzz.mesh-compute.share.max-vram-gb.v1"; +// Keep the Share compute controls visually and behaviorally aligned with the +// agent configuration fields. This is intentionally the same shell used by +// AgentDefaultsEditor rather than a local approximation of a select. +const MESH_SELECT_TRIGGER_CLASS = cn( + PERSONA_FIELD_CONTROL_CLASS, + PERSONA_FIELD_SHELL_CLASS, + "h-11 px-3 py-2 leading-6 hover:bg-muted/40 focus:bg-muted/40 [&>svg]:text-muted-foreground/60", +); + +const SHARE_COMPUTE_REVEAL_TRANSITION = { + duration: 0.22, + ease: [0.23, 1, 0.32, 1], +} as const; + function readDraft(key: string): string { try { return window.localStorage.getItem(key) ?? ""; @@ -64,6 +84,7 @@ function writeDraft(key: string, value: string): void { * exposing implementation protocols or raw mesh controls. */ export function MeshComputeSettingsCard() { + const shouldReduceMotion = useReducedMotion(); const { status, error, refresh } = useMeshNodeStatus(); const [installedModels, setInstalledModels] = React.useState< MeshModelOption[] @@ -75,6 +96,7 @@ export function MeshComputeSettingsCard() { const [maxVramGb, setMaxVramGb] = React.useState(() => readDraft(MAX_VRAM_DRAFT_STORAGE_KEY), ); + const [isCustomModelEditing, setIsCustomModelEditing] = React.useState(false); const [advancedOpen, setAdvancedOpen] = React.useState(false); const [actionInFlight, setActionInFlight] = React.useState(false); const [pendingAction, setPendingAction] = React.useState< @@ -163,6 +185,7 @@ export function MeshComputeSettingsCard() { const controlsDisabled = actionInFlight || (slotOccupied && !isConsuming); const refClass = classifyModelRef(modelInput); const canStart = refClass.kind !== "unknown" && !actionInFlight; + const showSharingControls = isSharing || pendingAction === "start"; async function handleToggle(next: boolean) { // Never let the Share switch tear down a consume session. The switch is @@ -204,12 +227,7 @@ export function MeshComputeSettingsCard() {
- Share this machine with your relay. When on, other members can run - their agents here. - - } + description="Share this machine with members of this relay so they can run agents here." /> {error ? ( @@ -226,8 +244,8 @@ export function MeshComputeSettingsCard() { ) : null} - - +
+
- - {servingIndicator.show ? ( -

- {servingIndicator.label} - {servingIndicator.detail ? ( - - {" "} - · {servingIndicator.detail} - - ) : null} -

+ {!isSharing ? ( + ) : null}
- +
+ + { + setModelInput(next); + writeDraft(MODEL_DRAFT_STORAGE_KEY, next); + }} + /> -
-
- ) : null} -
+ ) : null} + + ) : null} -
- setAdvancedOpen((e.target as HTMLDetailsElement).open) - } - open={advancedOpen} - > - - - Advanced - -
- - { - const next = e.target.value; - setMaxVramGb(next); - writeDraft(MAX_VRAM_DRAFT_STORAGE_KEY, next); - }} - placeholder="No limit" - value={maxVramGb} - /> - {status?.consoleUrl ? ( -

- Debug console:{" "} - - {status.consoleUrl} - -

- ) : null} -
-
-
- -

- Only members of this relay can use this machine's shared compute. -

+ + {showSharingControls ? ( + +
+

Sharing

+
+ + {servingIndicator.show ? ( +

+ {servingIndicator.label} + {servingIndicator.detail ? ( + + {" "} + · {servingIndicator.detail} + + ) : null} +

+ ) : null} +
+
+
+ ) : null} +
+
); } @@ -465,100 +465,159 @@ const FIT_CLASS: Record = { }; /** - * Hardware-ranked curated model list (mesh-console's diagnose pattern). - * Click a row to fill the model field. Models too large for this machine are - * listed but disabled — honest about why, instead of hiding them. + * Share Compute's models use the agent configuration picker instead of a + * second, hand-rolled option system. The catalog remains hardware-aware, but + * its recommendations, installed models, and advanced choices now appear in + * the same searchable dropdown used when customizing an agent. */ -function CatalogPicker({ +function MeshModelPicker({ catalog, disabled, - onPick, - selected, + installedModels, + isCustomModelEditing, + model, + onCustomModelEditingChange, + onModelChange, }: { - catalog: MeshModelCatalog; + catalog: MeshModelCatalog | null; disabled: boolean; - onPick: (name: string) => void; - selected: string; + installedModels: readonly MeshModelOption[]; + isCustomModelEditing: boolean; + model: string; + onCustomModelEditingChange: (editing: boolean) => void; + onModelChange: (model: string) => void; }) { - const [expanded, setExpanded] = React.useState(false); - // Above the fold: the Buzz-curated picks (models known to work well with - // agents on shared compute). Below: everything else, as advanced options. - const curated = catalog.entries.filter((e) => e.curated); - const advanced = catalog.entries.filter((e) => !e.curated); - const visible = expanded ? catalog.entries : curated; + const options = React.useMemo(() => { + const seen = new Set(); + const catalogOptions = (catalog?.entries ?? []).map((entry) => { + seen.add(entry.name); + return { + disabled: entry.fit === "too_large", + label: , + value: entry.name, + }; + }); + const localOptions = installedModels.flatMap((installed) => { + if (seen.has(installed.id)) return []; + return [ + { + label: ( +
+ + {installed.name ?? installed.id} + + + Installed + +
+ ), + value: installed.id, + }, + ]; + }); + return [ + ...catalogOptions, + ...localOptions, + { label: "Custom model…", value: CUSTOM_MODEL_DROPDOWN_VALUE }, + ]; + }, [catalog?.entries, installedModels]); + const knownModel = options.some((option) => option.value === model.trim()); + const showCustomModelInput = + isCustomModelEditing || (model.trim().length > 0 && !knownModel); + const selectedValue = showCustomModelInput + ? CUSTOM_MODEL_DROPDOWN_VALUE + : model.trim(); + + function handleModelChange(next: string) { + if (next === CUSTOM_MODEL_DROPDOWN_VALUE) { + onCustomModelEditingChange(true); + return; + } + onCustomModelEditingChange(false); + onModelChange(next); + } + return ( -
+
+ + + {showCustomModelInput ? ( + { + // A stored custom ref starts out inferred rather than explicitly + // selected. Mark it as an active custom edit before applying a + // cleared value so the field stays mounted while it is replaced. + onCustomModelEditingChange(true); + onModelChange(event.target.value); + }} + placeholder="Qwen3-8B-Q4_K_M or hf://meshllm/qwen3-8b@main" + usePersonaInputStyle + value={model} + /> + ) : null}

- Recommended for this machine - {catalog.gpuName ? ` (${catalog.gpuName}, ` : " ("} - {catalog.vramDisplay} AI memory): + {catalog + ? `Recommended for this machine${catalog.gpuName ? ` (${catalog.gpuName}, ${catalog.vramDisplay} AI memory)` : ""}.` + : "Choose a model or enter a model reference or local file."}{" "} + Buzz downloads remote models when sharing starts.

-
    - {visible.map((entry) => { - const isSelected = entry.name === selected; - const tooLarge = entry.fit === "too_large"; - return ( -
  • - -
  • - ); - })} -
- {advanced.length > 0 ? ( - +
+ ); +} + +function MeshModelOptionLabel({ entry }: { entry: MeshCatalogEntry }) { + return ( +
+ {entry.name} + {entry.size} + + {FIT_LABEL[entry.fit]} + + {entry.recommended ? ( + + Recommended + + ) : null} + {entry.installed ? ( + + Installed + + ) : null} + {!entry.curated ? ( + + Advanced + ) : null}
); } function StatusLine({ + displayModel, isConsuming, + omitSharingVerb = false, pendingAction, status, }: { + displayModel?: string; isConsuming: boolean; + omitSharingVerb?: boolean; pendingAction: "start" | "stop" | null; status: MeshNodeStatus | null; }) { @@ -583,12 +642,10 @@ function StatusLine({ return

Checking status…

; } const { state, health, modelId, modelName } = status; - const modelLabel = modelName ?? modelId ?? ""; + const modelLabel = displayModel ?? modelName ?? modelId ?? ""; if (state === "off") { - return ( -

Not sharing right now.

- ); + return null; } if (state === "starting") { const reason = @@ -614,7 +671,9 @@ function StatusLine({ } return (

- Sharing{modelLabel ? ` ${modelLabel}` : ""} with relay members. + {omitSharingVerb ? "" : "Sharing"} + {modelLabel ? `${omitSharingVerb ? "" : " "}${modelLabel}` : ""} with + relay members.

); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 895e770f0f4..6520dd760d4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -2950,6 +2950,7 @@ const ZERO_SERVING_USAGE: MockServingUsage = { const mockMeshState: { admitted: boolean; models: Array<{ id: string; name: string | null }>; + activeModel: { id: string; name: string | null } | null; denyReason: string; nodeState: "off" | "running"; nodeMode: "serve" | "client" | null; @@ -2957,6 +2958,7 @@ const mockMeshState: { } = { admitted: true, models: [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }], + activeModel: null, denyReason: "not a relay member", nodeState: "off", nodeMode: null, @@ -2966,6 +2968,7 @@ const mockMeshState: { function resetMockMesh() { mockMeshState.admitted = true; mockMeshState.models = [{ id: "Gemma-4-E4B-it-Q4_K_M", name: "Gemma 4 E4B" }]; + mockMeshState.activeModel = null; mockMeshState.denyReason = "not a relay member"; mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; @@ -9908,22 +9911,32 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ = ({ agentPubkey, events }) => { injectObserverEventsForE2E(agentPubkey, events); }; + const meshModelName = (modelId: string) => { + const basename = modelId.split("/").at(-1) ?? modelId; + return basename + .replace(/-(?:Instruct|GGUF)(?:-|:|$).*/, "") + .replace(/:.*/, "") + .replaceAll("-", " "); + }; const meshNodeStatus = ( state: "off" | "running", mode: "serve" | "client" | null, - ) => ({ - state, - mode, - health: { status: "ok" as const, reason: null }, - apiBaseUrl: state === "running" ? "http://127.0.0.1:9337/v1" : null, - consoleUrl: null, - modelId: mockMeshState.models[0]?.id ?? null, - modelName: mockMeshState.models[0]?.name ?? null, - inviteToken: state === "running" ? "mock-endpoint-addr" : null, - endpointId: state === "running" ? "mock-endpoint-id" : null, - deviceId: state === "running" ? "mock-endpoint-id" : null, - deviceName: state === "running" ? "Mock desktop" : null, - }); + ) => { + const model = mockMeshState.activeModel ?? mockMeshState.models[0] ?? null; + return { + state, + mode, + health: { status: "ok" as const, reason: null }, + apiBaseUrl: state === "running" ? "http://127.0.0.1:9337/v1" : null, + consoleUrl: null, + modelId: model?.id ?? null, + modelName: model?.name ?? null, + inviteToken: state === "running" ? "mock-endpoint-addr" : null, + endpointId: state === "running" ? "mock-endpoint-id" : null, + deviceId: state === "running" ? "mock-endpoint-id" : null, + deviceName: state === "running" ? "Mock desktop" : null, + }; + }; let mockImportedVoices: Array<{ key: string; displayName: string; @@ -10300,10 +10313,15 @@ export function maybeInstallE2eTauriMocks() { return mockMeshState.servingUsage; case "mesh_start_node": { const req = ( - payload as { request?: { mode?: "serve" | "client" } } | null + payload as { + request?: { mode?: "serve" | "client"; modelId?: string }; + } | null )?.request; mockMeshState.nodeState = "running"; mockMeshState.nodeMode = req?.mode ?? "serve"; + mockMeshState.activeModel = req?.modelId + ? { id: req.modelId, name: meshModelName(req.modelId) } + : (mockMeshState.models[0] ?? null); return meshNodeStatus(mockMeshState.nodeState, mockMeshState.nodeMode); } case "mesh_stop_node": @@ -10317,6 +10335,7 @@ export function maybeInstallE2eTauriMocks() { } mockMeshState.nodeState = "off"; mockMeshState.nodeMode = null; + mockMeshState.activeModel = null; return meshNodeStatus("off", null); case "get_identity": { const isLost = diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 4c72b72f755..451989fb7f1 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -226,6 +226,7 @@ test.describe("global agent config screenshots", () => { await page .getByTestId("global-agent-default-harness-option-claude") .click(); + await waitForAnimations(page); await expect(page.getByTestId("global-agent-model")).toHaveText( /Default model/, ); diff --git a/desktop/tests/e2e/mesh-compute.spec.ts b/desktop/tests/e2e/mesh-compute.spec.ts index b2e8fc28a9c..c55b9db5d95 100644 --- a/desktop/tests/e2e/mesh-compute.spec.ts +++ b/desktop/tests/e2e/mesh-compute.spec.ts @@ -15,9 +15,8 @@ type E2eWindow = Window & { }) => void; }; -test("Share compute selects the curated default and starts and stops sharing", async ({ - page, -}) => { +test("Share compute chooses a model before sharing", async ({ page }) => { + const modelRef = "hf://demo/SmolLM2-135M-Instruct-GGUF:Q4_K_M"; await installMockBridge(page); await page.goto("/"); await openSettings(page, "compute"); @@ -26,19 +25,34 @@ test("Share compute selects the curated default and starts and stops sharing", a const toggle = page.getByTestId("mesh-share-compute-toggle"); const model = page.getByTestId("mesh-share-compute-model"); - await expect(card).toContainText("Not sharing right now"); - await expect(card).toContainText( - "Choose a suggested model below, or enter a model reference or local file", - ); - await expect(model).toHaveValue("Gemma-4-E4B-it-Q4_K_M"); + await expect(card).not.toContainText("Not sharing right now"); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toHaveCount(0); + await expect(model).toBeVisible(); await expect(toggle).toBeEnabled(); + await model.click(); + await page.getByRole("option", { name: "Custom model…" }).click(); + await page.getByLabel("Custom model reference").fill(modelRef); + + await toggle.click(); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toBeVisible(); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toBeVisible(); + await expect(model).toBeVisible(); await expect(card).toContainText( "Buzz downloads remote models when sharing starts", ); - - await toggle.click(); await expect(toggle).toBeChecked(); - await expect(card).toContainText("Sharing Gemma 4 E4B with relay members"); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toContainText("SmolLM2 135M with relay members"); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), @@ -53,13 +67,20 @@ test("Share compute selects the curated default and starts and stops sharing", a .toContainEqual({ command: "mesh_start_node", payload: { - request: { mode: "serve", modelId: "Gemma-4-E4B-it-Q4_K_M" }, + request: { mode: "serve", modelId: modelRef }, }, }); await toggle.click(); await expect(toggle).not.toBeChecked(); - await expect(card).toContainText("Not sharing right now"); + await expect(card).not.toContainText("Not sharing right now"); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); + await expect( + page.getByTestId("mesh-share-compute-sharing-status"), + ).toHaveCount(0); + await expect(model).toBeVisible(); await expect .poll(() => page.evaluate(() => (window as E2eWindow).__BUZZ_E2E_COMMANDS__ ?? []), @@ -97,16 +118,20 @@ test("a consuming client can switch to sharing its saved local model", async ({ const card = page.getByTestId("settings-mesh-share-compute"); const toggle = page.getByTestId("mesh-share-compute-toggle"); - const model = page.getByTestId("mesh-share-compute-model"); - await expect(card).toContainText( "This machine is currently using another member's shared compute", ); await expect(card).toContainText("Buzz may briefly restart"); await expect(toggle).not.toBeChecked(); - await expect(model).toBeEnabled(); - await expect(model).toHaveValue(localModel); + await expect( + page.getByTestId("mesh-share-compute-options-motion"), + ).toHaveCount(0); await expect(toggle).toBeEnabled(); + const customModel = page.getByLabel("Custom model reference"); + await expect(customModel).toHaveValue(localModel); + await customModel.fill(""); + await expect(customModel).toBeVisible(); + await customModel.fill("hf://demo/replacement-model:Q4_K_M"); await toggle.click(); await expect(toggle).toBeChecked(); @@ -117,6 +142,8 @@ test("a consuming client can switch to sharing its saved local model", async ({ expect(commands.names).not.toContain("mesh_stop_node"); expect(commands.payloads).toContainEqual({ command: "mesh_start_node", - payload: { request: { mode: "serve", modelId: localModel } }, + payload: { + request: { mode: "serve", modelId: "hf://demo/replacement-model:Q4_K_M" }, + }, }); }); From 985cdcc6eac33ccd77bc50c26e22c701d07eda4e Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Mon, 3 Aug 2026 18:09:24 -0400 Subject: [PATCH 031/163] feat(agents): model-tuning parity in global Agent Defaults editor (#4578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview The global Agent Defaults surface (Settings card, defaults modal, onboarding) exposed structured controls for Effort but left Max Output Tokens, Context Limit, and Max Rounds as raw env vars. Per-agent dialogs had structured numeric fields but only for `isBuzzAgentRuntime` — incorrectly excluding Goose. This PR unifies numeric-tuning capability across all surfaces, fixes a pre-existing dual-editor defect, and adds full test coverage. ## What changed ### Phase 1 — Catalog projection - Add `max_rounds_env_var` to `KnownAcpRuntime` in `runtime_metadata.rs` (`Some("BUZZ_AGENT_MAX_ROUNDS")` for buzz-agent, `None` elsewhere). - Project all three numeric env-var fields (`max_tokens_env_var`, `context_limit_env_var`, `max_rounds_env_var`) end-to-end: `AcpRuntimeCatalogEntry` Rust struct, TS `types.ts`, `RawAcpRuntimeCatalogEntry` + `fromRawAcpRuntimeCatalogEntry` in `tauri.ts`, and the e2e mock bridge (`withMockRuntimeConfigMetadata`). ### Phase 2 — Field model - `deriveAgentConfigFieldModel` now derives `maxOutputTokens` / `contextLimit` / `maxRounds` descriptors from catalog-projected fields. - `structuredEnvKeys(descriptors)` — exported helper that takes the **rendered** descriptor set (not the whole model). Hidden keys follow what is actually rendered per surface: global hides effort + all three numeric keys for buzz-agent / two for Goose; per-agent buzz-agent hides effort + three numeric keys; per-agent Goose hides only its two numeric keys. `BUZZ_AGENT_THINKING_EFFORT` stays a visible generic env row per-agent because no effort control renders there. ### Phase 3 — UI - Extract `NumericTuningFields` from `buzzAgentModelTuningFields.tsx` as a shared descriptor-driven component (`descriptors`, `envVars`, `inheritedEnvVars`, `onEnvVarChange`). Kind-specific minima: `NUMERIC_KIND_MIN` map (`maxOutputTokens`/`contextLimit`: 1, `maxRounds`: 0) applied to ``. - **Global surface** (`AgentConfigFields.tsx`): deduplicate the previously duplicated Advanced env-editor block; render `NumericTuningFields` below the env editor when descriptors exist; `hiddenKeys` and `bakedGenericRows` exclusions use `structuredEnvKeys` so structured keys are never double-rendered. Under 1000 lines. - **Per-agent surfaces** (`EditAgentAdvancedFields`, `PersonaAdvancedFields`): replace `isBuzzAgentRuntime` as the numeric-field gate with `deriveNumericDescriptors(selectedRuntime)` from `agentConfigCore`; hidden keys come from `structuredEnvKeys(numericDescriptors)` — the same rendered descriptor set, no local rebuilding (fixes pre-existing dual-editor defect). Catalog status carried as `RuntimeCatalogStatus` (`loading | ready | error`); both error and loading withhold structured controls and leave saved values visible as generic rows, making error distinguishable from "runtime not capable" (`ready` + no runtime). - **Dialogs** (`AgentDefinitionDialog`, `AgentInstanceEditDialog`, callers): `AgentDefinitionDialog` accepts `runtimeCatalogStatus?: "loading" | "ready" | "error"` (replaces separate `runtimesLoading`/`runtimesError` booleans); all call sites — `AgentManagementDialogs`, `AgentsView`, `RequestedAgentCreateDialogs`, `UserProfilePersonaDialogs` — compute and pass the status. ### Phase 4 — Tests - `buildRecord` exported from `EnvVarsEditor.tsx` as a pure `(nextRows, value, requiredKeys, hiddenKeys) => Record` helper for isolation testing. - **17 new node tests** in `agentConfigCore.test.mjs`: `deriveNumericDescriptors` (all three fields, partial, undefined runtime, matches field-model subset); `structuredEnvKeys` per surface including discriminating Goose per-agent effort-key invariant; `NUMERIC_KIND_MIN` values. - **4 new node tests** in `EnvVarsEditor.test.mjs`: hidden tuning key preserved through generic row edits; runtime-switch then generic edit (derives both descriptor sets, asserts new-runtime hidden key survives `buildRecord` via `hiddenKeys` and old-runtime key survives via generic rows); baked numeric key excluded via `filterBakedGenericRows` with `numericTuningPlaceholder` assertion; clearing a structured override — `numericTuningPlaceholder` verifies placeholder text. - **5 new Playwright tests** in `agent-numeric-tuning.spec.ts` (added to smoke project `testMatch`): global numeric fields visible for buzz-agent; global: non-capable runtime hides numeric controls; Goose per-agent shows `Inherit (16384)` after saving global value through the UI; delayed catalog: saved values visible as generic rows while loading then structured controls appear after settle; failed catalog: saved values remain visible as generic rows (never the "unsupported" empty state). ## Result - buzz-agent global defaults: Max output tokens, Context limit, Max rounds as structured inputs with `Inherit (N)` placeholders from baked env. - Goose global defaults: Max output tokens, Context limit as structured inputs. - A Goose global value surfaces as `Inherit ()` in the per-agent Goose edit dialog. - No structured key is editable in two places on any surface; no persisted key has zero editors. - No `runtime.id === "buzz-agent"` comparison decides numeric-field visibility anywhere — capability flows catalog → `AcpRuntimeCatalogEntry` → field model → UI. Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/playwright.config.ts | 1 + .../src-tauri/src/commands/agent_config.rs | 3 +- .../src/commands/agent_config_tests.rs | 1 + .../src-tauri/src/commands/agent_discovery.rs | 27 +- .../config_bridge/reader_tests.rs | 2 + .../src-tauri/src/managed_agents/discovery.rs | 39 +- .../src/managed_agents/discovery/presets.rs | 3 + .../discovery/runtime_metadata.rs | 2 + .../src-tauri/src/managed_agents/readiness.rs | 12 +- desktop/src-tauri/src/managed_agents/types.rs | 12 +- .../agents/lib/agentConfigCore.test.mjs | 411 +++++++++++++++++- .../features/agents/lib/agentConfigCore.ts | 157 +++++++ .../features/agents/ui/AgentConfigFields.tsx | 126 +++--- .../agents/ui/AgentDefinitionDialog.tsx | 13 +- .../src/features/agents/ui/AgentDialog.tsx | 8 +- .../agents/ui/AgentInstanceEditDialog.tsx | 38 +- .../agents/ui/AgentManagementDialogs.tsx | 4 +- desktop/src/features/agents/ui/AgentsView.tsx | 16 +- .../agents/ui/EditAgentAdvancedFields.tsx | 85 +++- .../features/agents/ui/EnvVarsEditor.test.mjs | 308 +++++++++++++ .../src/features/agents/ui/EnvVarsEditor.tsx | 39 +- .../agents/ui/PersonaAdvancedFields.tsx | 79 +++- .../agents/ui/RequestedAgentCreateDialogs.tsx | 8 +- .../agents/ui/buzzAgentModelTuningFields.tsx | 196 ++++----- .../src/features/agents/useAgentManagement.ts | 6 +- .../features/profile/ui/UserProfilePanel.tsx | 1 + .../profile/ui/UserProfilePersonaDialogs.tsx | 9 +- desktop/src/shared/api/tauri.ts | 26 +- desktop/src/shared/api/types.ts | 10 +- desktop/src/testing/e2eBridge.ts | 56 ++- .../tests/e2e/agent-numeric-tuning.spec.ts | 372 ++++++++++++++++ desktop/tests/helpers/bridge.ts | 21 +- 32 files changed, 1769 insertions(+), 322 deletions(-) create mode 100644 desktop/tests/e2e/agent-numeric-tuning.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 796fdede1eb..f5c1e34a528 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -136,6 +136,7 @@ export default defineConfig({ "**/inline-custom-harness.spec.ts", "**/where-to-run-config.spec.ts", "**/huddle-transcription.spec.ts", + "**/agent-numeric-tuning.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 7aded79599b..12e6983eea3 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -31,8 +31,7 @@ pub struct RuntimeFileConfigSubset { pub provider: Option, /// Model set in the harness config file, if any. pub model: Option, - /// Flat credential env keys found in the harness config file's `extra` map - /// (e.g. `DATABRICKS_HOST`). Only non-empty values are included. + /// Flat credential env keys in the harness config file's `extra` map (e.g. `DATABRICKS_HOST`); only non-empty values included. pub satisfied_env_keys: Vec, } diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index f3667cff451..55191535784 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -57,6 +57,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { thinking_env_var: Some("GOOSE_THINKING_EFFORT"), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 4abf53ee918..0eb024a86ae 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -21,25 +21,13 @@ fn active_installs() -> &'static std::sync::Mutex( runtime_id: &str, adapter_path: Option<&std::path::Path>, @@ -177,6 +165,9 @@ pub async fn save_custom_harness( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: definition.install_hint, install_instructions_url: definition.install_instructions_url, can_auto_install: false, diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 153db1bbd8a..62caffeb2e4 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -56,6 +56,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { thinking_env_var: Some("GOOSE_THINKING_EFFORT"), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -644,6 +645,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 248625ce931..2cccccb95ef 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -103,6 +103,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: Some("GOOSE_THINKING_EFFORT"), max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), + max_rounds_env_var: None, required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -135,6 +136,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: Some("Run the Claude CLI to complete authentication."), auth_probe_args: Some(&["claude", "auth", "status"]), @@ -167,6 +169,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: Some("Run `codex login` to authenticate."), // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. @@ -200,6 +203,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ thinking_env_var: Some("BUZZ_AGENT_THINKING_EFFORT"), max_tokens_env_var: Some("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + max_rounds_env_var: Some("BUZZ_AGENT_MAX_ROUNDS"), required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, @@ -278,11 +282,8 @@ pub(crate) fn known_acp_runtime_exact(id: &str) -> Option<&'static KnownAcpRunti /// The agent command a freshly-created agent defaults to when the create /// request supplies none. Resolves the bundled `buzz-agent` from the catalog so /// the default cannot drift from the provider definition. Falls back to the id -/// if the catalog entry is missing. -/// -/// The previous default was the bare global `goose`, which is not on PATH on a -/// stock Windows install: every worker failed with `program not found`. The -/// bundled `buzz-agent` ships with the app and resolves on every platform. +/// if the catalog entry is missing. (Previous default was bare `goose`, which +/// is not on PATH on a stock Windows install; buzz-agent ships with the app.) pub fn default_agent_command() -> String { known_acp_runtime_exact("buzz-agent") .and_then(|p| p.commands.first().copied()) @@ -375,10 +376,8 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri /// Prefix of the typed dangling-harness error produced by /// `try_record_agent_command` / `resolve_effective_harness_descriptor`. -/// -/// This sentinel is an internal Rust contract: user-facing surfaces must -/// convert it to a sentence via [`user_facing_harness_error`] (spawn) or to -/// the missing id via [`dangling_harness_id`] (summary) — never show it raw. +/// Internal Rust contract: surfaces must convert it via [`user_facing_harness_error`] or +/// [`dangling_harness_id`] — never show it raw. pub(crate) const DANGLING_HARNESS_PREFIX: &str = "DANGLING_HARNESS_ID:"; /// Extract the missing harness id from a `DANGLING_HARNESS_ID:` error. @@ -398,22 +397,16 @@ pub(crate) fn user_facing_harness_error(error: &str) -> String { } } -/// Summary-row display for a dangling harness id: shows the *missing* id so -/// the agent list tells the same story as spawn (which refuses with the -/// sentence above), rather than silently falling back to the default command -/// as if the agent were healthy. +/// Summary-row display for a dangling harness id: shows the *missing* id so the agent list +/// tells the same story as spawn rather than silently falling back to the default command. pub(crate) fn dangling_harness_display(id: &str) -> String { format!("harness (deleted): {id}") } /// Spawn-time variant of `record_agent_command` that returns a typed error when -/// a record's `runtime` id or its persona's `runtime` id is set but cannot be -/// resolved (i.e. the definition was deleted after the agent was created). -/// -/// Returns `Err("DANGLING_HARNESS_ID:")` so callers can surface the error -/// without falling through to `buzz-agent`. When there is no runtime id at all -/// the fallback to `default_agent_command()` is intentional (legacy agents -/// pre-date the unified harness model). +/// a record's `runtime` id or persona's `runtime` id is set but unresolvable +/// (definition deleted after agent was created). Returns `Err("DANGLING_HARNESS_ID:")`. +/// When there is no runtime id at all, falls through to `default_agent_command()` intentionally. pub fn try_record_agent_command( record: &crate::managed_agents::types::ManagedAgentRecord, personas: &[crate::managed_agents::types::AgentDefinition], @@ -1413,6 +1406,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr model_env_var: runtime.model_env_var.map(str::to_string), provider_env_var: runtime.provider_env_var.map(str::to_string), thinking_env_var: runtime.thinking_env_var.map(str::to_string), + max_tokens_env_var: runtime.max_tokens_env_var.map(str::to_string), + context_limit_env_var: runtime.context_limit_env_var.map(str::to_string), + max_rounds_env_var: runtime.max_rounds_env_var.map(str::to_string), install_hint, install_instructions_url: install_instructions_url.to_string(), can_auto_install, @@ -1571,6 +1567,9 @@ pub fn discover_acp_runtimes_from( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: def.install_hint.clone(), install_instructions_url: def.install_instructions_url.clone(), // Security line: custom definitions carry no install scripts. diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 3622b21c4a3..bcc42880052 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -67,6 +67,9 @@ pub(super) fn preset_catalog_entry( model_env_var: None, provider_env_var: None, thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, install_hint: def.install_hint.to_string(), install_instructions_url: def.install_instructions_url.to_string(), can_auto_install: false, diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index fdfe9b8be71..34edecdcd9c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -52,6 +52,8 @@ pub(crate) struct KnownAcpRuntime { pub max_tokens_env_var: Option<&'static str>, /// Env var for normalizing `context_limit`. `None` when not applicable. pub context_limit_env_var: Option<&'static str>, + /// Env var for normalizing `max_rounds`. `None` when not applicable. + pub max_rounds_env_var: Option<&'static str>, /// Normalized field keys that must be set for this harness to function. /// Used by the config bridge to mark fields as required in the UI. /// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider"). diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1d..26902ae8dea 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1051,19 +1051,16 @@ mod tests { thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: None, auth_probe_args: None, } } - /// Returns the absolute path of the currently-running test binary as a - /// `&'static str`. Host-portable stand-in for a "present" binary: - /// the path is absolute so `find_command` resolves it via `path.exists()` - /// rather than searching `PATH`, and the file always exists on the host. - /// - /// The tiny allocation is intentionally leaked — this runs at most once per - /// test process and the process exits immediately after tests complete. + /// Returns the absolute path of the currently-running test binary as a `&'static str`. + /// Host-portable stand-in for a "present" binary: absolute path so `find_command` resolves + /// it via `path.exists()`. Leaked allocation is intentional — process exits after tests. fn present_binary_str() -> &'static str { let path = std::env::current_exe().expect("current_exe must be available in tests"); Box::leak(path.to_string_lossy().into_owned().into_boxed_str()) @@ -1246,6 +1243,7 @@ mod tests { thinking_env_var: None, max_tokens_env_var: None, context_limit_env_var: None, + max_rounds_env_var: None, required_normalized_fields: &[], login_hint: None, auth_probe_args: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index fcd8b13fc96..255c1aae322 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -594,10 +594,8 @@ pub enum AcpAvailabilityStatus { NotInstalled, } -/// Authentication/login status for a CLI-based ACP runtime. -/// -/// Serializes as a tagged union `{ status: "...", diagnostic?: "..." }` so -/// the TypeScript side can exhaustively switch on `status`. +/// Authentication/login status for a CLI-based ACP runtime. Serializes as a tagged union +/// `{ status: "...", diagnostic?: "..." }` so the TypeScript side can exhaustively switch on `status`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case", tag = "status")] pub enum AuthStatus { @@ -616,8 +614,7 @@ pub enum AuthStatus { Unknown, } -/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string -/// so the TypeScript consumer can switch on it without numeric comparisons. +/// Origin of an ACP runtime catalog entry. Serializes as a lowercase string so the TypeScript consumer can switch on it without numeric comparisons. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum HarnessSource { @@ -645,6 +642,9 @@ pub struct AcpRuntimeCatalogEntry { pub provider_env_var: Option, /// Environment variable used to apply thinking effort, when supported. pub thinking_env_var: Option, + pub max_tokens_env_var: Option, + pub context_limit_env_var: Option, + pub max_rounds_env_var: Option, pub install_hint: String, pub install_instructions_url: String, /// true when at least one automated install step is available diff --git a/desktop/src/features/agents/lib/agentConfigCore.test.mjs b/desktop/src/features/agents/lib/agentConfigCore.test.mjs index 62d8a61a6fa..92159ff2754 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.test.mjs +++ b/desktop/src/features/agents/lib/agentConfigCore.test.mjs @@ -1,7 +1,12 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { deriveAgentConfigFieldModel } from "./agentConfigCore.ts"; +import { + deriveAgentConfigFieldModel, + deriveNumericDescriptors, + structuredEnvKeys, +} from "./agentConfigCore.ts"; +import { NUMERIC_KIND_MIN } from "../ui/buzzAgentModelTuningFields.tsx"; const config = { env_vars: { BUZZ_AGENT_THINKING_EFFORT: "high" }, @@ -23,6 +28,9 @@ function runtime(id, metadata = {}) { modelEnvVar: null, providerEnvVar: null, thinkingEnvVar: null, + maxTokensEnvVar: null, + contextLimitEnvVar: null, + maxRoundsEnvVar: null, installHint: "", installInstructionsUrl: "", canAutoInstall: false, @@ -152,3 +160,404 @@ test("catalog mismatch cleanup is named and restricted to onboarding", () => { onCatalogMismatch: "explainOnly", }); }); + +// ── Numeric descriptor derivation per runtime ───────────────────────────── +// +// The catalog-projected fields (maxTokensEnvVar, contextLimitEnvVar, +// maxRoundsEnvVar) determine which numeric descriptors appear in the field +// model. Capability facts flow catalog → descriptor → UI; no runtime-ID +// comparison decides numeric-field visibility. + +test("buzz-agent derives three numeric descriptors from catalog fields", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "global", + }); + + const numericKinds = model.fields + .filter((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ) + .map((f) => f.kind); + assert.deepEqual(numericKinds, [ + "maxOutputTokens", + "contextLimit", + "maxRounds", + ]); + + const maxOutput = field(model, "maxOutputTokens"); + assert.equal(maxOutput.render, "control"); + assert.deepEqual(maxOutput.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + }); + assert.deepEqual(maxOutput.targetApplication, { + kind: "envVar", + key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + }); + + const ctx = field(model, "contextLimit"); + assert.deepEqual(ctx.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + }); + + const rounds = field(model, "maxRounds"); + assert.deepEqual(rounds.currentPersistence, { + kind: "envVar", + key: "BUZZ_AGENT_MAX_ROUNDS", + }); +}); + +test("Goose derives two numeric descriptors and no maxRounds", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { + modelEnvVar: "GOOSE_MODEL", + providerEnvVar: "GOOSE_PROVIDER", + thinkingEnvVar: "GOOSE_THINKING_EFFORT", + maxTokensEnvVar: "GOOSE_MAX_TOKENS", + contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT", + maxRoundsEnvVar: null, // Goose has no max-rounds env var + }), + scope: "global", + }); + + const numericKinds = model.fields + .filter((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ) + .map((f) => f.kind); + assert.deepEqual(numericKinds, ["maxOutputTokens", "contextLimit"]); + assert.equal( + field(model, "maxRounds"), + undefined, + "maxRounds must be absent for Goose", + ); + + assert.deepEqual(field(model, "maxOutputTokens").currentPersistence, { + kind: "envVar", + key: "GOOSE_MAX_TOKENS", + }); + assert.deepEqual(field(model, "contextLimit").currentPersistence, { + kind: "envVar", + key: "GOOSE_CONTEXT_LIMIT", + }); +}); + +test("Claude derives no numeric descriptors", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("claude"), + scope: "global", + }); + + const hasNumeric = model.fields.some((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ); + assert.equal(hasNumeric, false, "Claude must have no numeric descriptors"); +}); + +test("Codex derives no numeric descriptors", () => { + const model = deriveAgentConfigFieldModel({ + config, + runtime: runtime("codex"), + scope: "global", + }); + + const hasNumeric = model.fields.some((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ); + assert.equal(hasNumeric, false, "Codex must have no numeric descriptors"); +}); + +test("numeric descriptor value is read from env_vars when set", () => { + const cfgWithTuning = { + env_vars: { + BUZZ_AGENT_MAX_OUTPUT_TOKENS: "8192", + BUZZ_AGENT_MAX_CONTEXT_TOKENS: "100000", + BUZZ_AGENT_MAX_ROUNDS: "25", + }, + model: "test-model", + preferred_runtime: null, + provider: "anthropic", + }; + const model = deriveAgentConfigFieldModel({ + config: cfgWithTuning, + runtime: runtime("buzz-agent", { + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "global", + }); + + assert.equal(field(model, "maxOutputTokens").value, "8192"); + assert.equal(field(model, "contextLimit").value, "100000"); + assert.equal(field(model, "maxRounds").value, "25"); +}); + +test("numeric descriptor value is null when env var is absent", () => { + const cfgEmpty = { + env_vars: {}, + model: "test-model", + preferred_runtime: null, + provider: null, + }; + const model = deriveAgentConfigFieldModel({ + config: cfgEmpty, + runtime: runtime("buzz-agent", { + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "global", + }); + + assert.equal(field(model, "maxOutputTokens").value, null); + assert.equal(field(model, "contextLimit").value, null); + assert.equal(field(model, "maxRounds").value, null); +}); + +// ── structuredEnvKeys: rendered-descriptor ownership ───────────────────── +// +// structuredEnvKeys accepts the descriptors a surface ACTUALLY renders and +// returns the env-var keys that surface owns. Keys only appear in the output +// when a first-class control for them renders — a persisted value must never +// have zero editors. +// +// Critical invariant: per-agent Goose passes only its two numeric descriptors +// (no effort descriptor, because no effort control renders there). The effort +// key (BUZZ_AGENT_THINKING_EFFORT) must NOT appear in the output — it must +// stay a visible generic env row where any saved value can be edited. + +test("structuredEnvKeys_global_includes_effort_key_and_numeric_keys", () => { + // Global surface renders effort + all numeric descriptors. + const buzzAgentModel = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "global", + }); + + // Global renders all renderable descriptors. + const renderedDescriptors = buzzAgentModel.fields.filter( + (f) => f.render === "control", + ); + const keys = structuredEnvKeys(renderedDescriptors); + + assert.ok( + keys.includes("BUZZ_AGENT_THINKING_EFFORT"), + "effort key must be hidden on global (effort control renders)", + ); + assert.ok( + keys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + "maxOutputTokens key must be hidden on global", + ); + assert.ok( + keys.includes("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + "contextLimit key must be hidden on global", + ); + assert.ok( + keys.includes("BUZZ_AGENT_MAX_ROUNDS"), + "maxRounds key must be hidden on global", + ); +}); + +test("structuredEnvKeys_per_agent_buzz_agent_includes_effort_and_numeric_keys", () => { + // Per-agent buzz-agent renders effort + all 3 numeric descriptors. + const buzzAgentModel = deriveAgentConfigFieldModel({ + config, + runtime: runtime("buzz-agent", { + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + scope: "definition", + }); + + const renderedDescriptors = buzzAgentModel.fields.filter( + (f) => f.render === "control", + ); + const keys = structuredEnvKeys(renderedDescriptors); + + assert.ok(keys.includes("BUZZ_AGENT_THINKING_EFFORT"), "effort key present"); + assert.ok(keys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), "maxTokens present"); + assert.ok( + keys.includes("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), + "contextLimit present", + ); + assert.ok(keys.includes("BUZZ_AGENT_MAX_ROUNDS"), "maxRounds present"); +}); + +test("structuredEnvKeys_per_agent_goose_excludes_effort_key_discriminating_invariant", () => { + // Per-agent Goose: effort migration is out of scope, so no effort control + // renders on the per-agent surface for Goose. Only the 2 numeric descriptors + // are passed as the rendered set. The effort persistence key + // (BUZZ_AGENT_THINKING_EFFORT) must NOT appear in the output — any saved + // value must remain visible and editable as a generic env row. + const gooseModel = deriveAgentConfigFieldModel({ + config, + runtime: runtime("goose", { + thinkingEnvVar: "GOOSE_THINKING_EFFORT", + maxTokensEnvVar: "GOOSE_MAX_TOKENS", + contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT", + }), + scope: "definition", + }); + + // Simulate per-agent surface: only the numeric descriptors render (no effort + // control for Goose per-agent — effort migration is out of scope). + const numericDescriptorsOnly = gooseModel.fields.filter((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ); + + const keys = structuredEnvKeys(numericDescriptorsOnly); + + assert.equal( + keys.includes("BUZZ_AGENT_THINKING_EFFORT"), + false, + "effort persistence key must NOT be hidden for Goose per-agent — no editor would replace it", + ); + assert.ok( + keys.includes("GOOSE_MAX_TOKENS"), + "maxTokens key must be present (control renders)", + ); + assert.ok( + keys.includes("GOOSE_CONTEXT_LIMIT"), + "contextLimit key must be present (control renders)", + ); +}); + +test("structuredEnvKeys_deferred_effort_excluded_from_result", () => { + // A deferred effort descriptor (render !== "control") must not contribute + // its key to the hidden set — the value has no editor on this surface. + const claudeModel = deriveAgentConfigFieldModel({ + config, + runtime: runtime("claude"), + scope: "global", + }); + + const allDescriptors = claudeModel.fields; // includes deferred effort + const keys = structuredEnvKeys(allDescriptors); + + // Claude's deferred effort has currentPersistence.kind === "unavailable" + // and render === "deferredUntilNativeOptionsAvailable"; no key emitted. + assert.equal( + keys.length, + 0, + "deferred effort and model descriptors must not contribute hidden keys", + ); +}); + +// ── deriveNumericDescriptors: standalone helper ─────────────────────────── +// +// The same logic that populates the numeric portion of deriveAgentConfigFieldModel +// is available as a standalone helper for per-agent surfaces that don't need +// the full field model. + +test("deriveNumericDescriptors_undefined_runtime_returns_empty", () => { + const ds = deriveNumericDescriptors(undefined); + assert.deepEqual(ds, []); +}); + +test("deriveNumericDescriptors_runtime_with_all_three_fields", () => { + const ds = deriveNumericDescriptors( + runtime("buzz-agent", { + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }), + ); + assert.deepEqual( + ds.map((d) => d.kind), + ["maxOutputTokens", "contextLimit", "maxRounds"], + ); + for (const d of ds) { + assert.equal(d.render, "control"); + assert.equal(d.currentPersistence.kind, "envVar"); + assert.equal(d.value, null, "standalone helper returns null values"); + } +}); + +test("deriveNumericDescriptors_partial_fields_match_catalog_projection", () => { + // Goose: two numeric fields, no maxRounds. + const ds = deriveNumericDescriptors( + runtime("goose", { + maxTokensEnvVar: "GOOSE_MAX_TOKENS", + contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT", + maxRoundsEnvVar: null, + }), + ); + assert.deepEqual( + ds.map((d) => d.kind), + ["maxOutputTokens", "contextLimit"], + ); +}); + +test("deriveNumericDescriptors_matches_deriveAgentConfigFieldModel_numeric_subset", () => { + // The standalone helper must produce the same descriptor set (without values) + // that deriveAgentConfigFieldModel embeds, so surfaces that call the helper + // directly get a consistent policy with the full field model. + const runtimeEntry = runtime("buzz-agent", { + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_MAX_CONTEXT_TOKENS", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + }); + + const standalone = deriveNumericDescriptors(runtimeEntry); + const fromModel = deriveAgentConfigFieldModel({ + config, + runtime: runtimeEntry, + scope: "global", + }).fields.filter((f) => + ["maxOutputTokens", "contextLimit", "maxRounds"].includes(f.kind), + ); + + // Kinds and keys must match; values differ (standalone returns null, model + // reads from config). + assert.deepEqual( + standalone.map((d) => d.kind), + fromModel.map((d) => d.kind), + "descriptor kinds must match", + ); + for (let i = 0; i < standalone.length; i++) { + assert.deepEqual( + standalone[i].currentPersistence, + fromModel[i].currentPersistence, + `persistence must match for descriptor ${i}`, + ); + } +}); + +// ── NUMERIC_KIND_MIN: kind-specific input minima ────────────────────────── +// +// max output tokens and context limit must have min=1 (buzz-agent rejects 0). +// max rounds allows 0 (meaning unlimited). + +test("NUMERIC_KIND_MIN_maxOutputTokens_is_1", () => { + assert.equal(NUMERIC_KIND_MIN.maxOutputTokens, 1); +}); + +test("NUMERIC_KIND_MIN_contextLimit_is_1", () => { + assert.equal(NUMERIC_KIND_MIN.contextLimit, 1); +}); + +test("NUMERIC_KIND_MIN_maxRounds_is_0", () => { + assert.equal(NUMERIC_KIND_MIN.maxRounds, 0); +}); diff --git a/desktop/src/features/agents/lib/agentConfigCore.ts b/desktop/src/features/agents/lib/agentConfigCore.ts index 5827aedfa7b..5a8b8cb1c37 100644 --- a/desktop/src/features/agents/lib/agentConfigCore.ts +++ b/desktop/src/features/agents/lib/agentConfigCore.ts @@ -4,6 +4,18 @@ import type { } from "@/shared/api/types"; import { BUZZ_AGENT_THINKING_EFFORT } from "../ui/buzzAgentConfig"; +/** + * Lifecycle status of the ACP runtime catalog query on a per-agent surface. + * + * - `loading` — query in flight; structured controls are withheld and env-var + * keys are not hidden (saved values remain visible as generic rows). + * - `ready` — query resolved; descriptors derived from `selectedRuntime`. + * - `error` — query failed; same gate as loading: no structured controls, + * keys not hidden, saved values stay visible. Distinguishable from + * "runtime not capable" (which is `ready` + no selectedRuntime). + */ +export type RuntimeCatalogStatus = "loading" | "ready" | "error"; + export type AgentConfigScope = | "onboarding" | "global" @@ -69,6 +81,13 @@ export type AgentConfigFieldDescriptor = | { kind: "acpConfigOption"; id: string; category: string }; render: "control" | "deferredUntilNativeOptionsAvailable"; value: string | null; + } + | { + kind: "maxOutputTokens" | "contextLimit" | "maxRounds"; + currentPersistence: EnvVarPersistence; + targetApplication: { kind: "envVar"; key: string }; + render: "control"; + value: string | null; }; export type AgentConfigOmission = { @@ -76,6 +95,18 @@ export type AgentConfigOmission = { reason: "ownedByModelId" | "unsupportedByHarness"; }; +/** + * A numeric tuning descriptor: one of the three env-var-backed number fields + * (max output tokens, context limit, max rounds). + * + * Defined here so both the field model derivation and the rendering surfaces + * share a single type — avoids the type being redefined in UI layers. + */ +export type NumericDescriptor = Extract< + AgentConfigFieldDescriptor, + { kind: "maxOutputTokens" | "contextLimit" | "maxRounds" } +>; + export type AgentConfigFieldModel = { fields: AgentConfigFieldDescriptor[]; omissions: AgentConfigOmission[]; @@ -86,6 +117,51 @@ function valueFromEnv(config: GlobalAgentConfig, key: string) { return config.env_vars[key]?.trim() || null; } +/** + * Derives the numeric descriptor set for a runtime from catalog fields. + * + * The returned descriptors drive `NumericTuningFields` on any surface that + * renders numeric knobs. Surfaces pass the same descriptor set to both the + * renderer and `structuredEnvKeys()` — one policy, no local rebuilding. + * + * Returns `[]` when `runtime` is undefined (catalog not yet settled, or the + * runtime has no numeric env-var fields). + */ +export function deriveNumericDescriptors( + runtime: AcpRuntimeCatalogEntry | undefined, +): NumericDescriptor[] { + if (!runtime) return []; + const ds: NumericDescriptor[] = []; + if (runtime.maxTokensEnvVar) { + ds.push({ + kind: "maxOutputTokens", + currentPersistence: { kind: "envVar", key: runtime.maxTokensEnvVar }, + targetApplication: { kind: "envVar", key: runtime.maxTokensEnvVar }, + render: "control", + value: null, + }); + } + if (runtime.contextLimitEnvVar) { + ds.push({ + kind: "contextLimit", + currentPersistence: { kind: "envVar", key: runtime.contextLimitEnvVar }, + targetApplication: { kind: "envVar", key: runtime.contextLimitEnvVar }, + render: "control", + value: null, + }); + } + if (runtime.maxRoundsEnvVar) { + ds.push({ + kind: "maxRounds", + currentPersistence: { kind: "envVar", key: runtime.maxRoundsEnvVar }, + targetApplication: { kind: "envVar", key: runtime.maxRoundsEnvVar }, + render: "control", + value: null, + }); + } + return ds; +} + /** * Derives the harness-scoped field model consumed by agent config renderers. * @@ -163,6 +239,16 @@ export function deriveAgentConfigFieldModel({ }); } + // Numeric fields — derived from the shared helper, then value-populated + // from config. Any surface needing only the descriptor structure (without + // saved values) calls deriveNumericDescriptors(runtime) directly. + for (const d of deriveNumericDescriptors(runtime)) { + fields.push({ + ...d, + value: valueFromEnv(config, d.currentPersistence.key), + }); + } + return { fields, omissions, @@ -191,3 +277,74 @@ export function getRenderableEffortField( field.kind === "effort" && field.render === "control", ); } + +/** + * Returns the env-var keys owned by the rendered descriptors on a surface. + * + * Pass only the descriptors that **actually render controls** on the surface — + * the resulting key set should be used as `EnvVarsEditor.hiddenKeys` and to + * exclude keys from baked-row generic display. + * + * Invariant: a key appears in the output only when a first-class control for + * it renders on the surface — a persisted value must never have zero editors. + * + * Per-surface consequences (assuming standard descriptor sets): + * - Global: effort key + numeric keys rendered by the descriptors + * - Per-agent buzz-agent: effort key + 3 numeric keys + * - Per-agent Goose: 2 numeric keys only — Goose effort (BUZZ_AGENT_THINKING_EFFORT) + * stays a visible generic env row because no effort control renders per-agent + * for Goose (effort migration is out of scope) + */ +export function structuredEnvKeys( + renderedDescriptors: AgentConfigFieldDescriptor[], +): string[] { + const keys: string[] = []; + for (const d of renderedDescriptors) { + if (d.render !== "control") continue; + if (d.kind === "effort" && d.currentPersistence.kind === "envVar") { + keys.push(d.currentPersistence.key); + } else if ( + d.kind === "maxOutputTokens" || + d.kind === "contextLimit" || + d.kind === "maxRounds" + ) { + keys.push(d.currentPersistence.key); + } + } + return keys; +} + +/** + * Filters a baked-env row array to exclude keys already covered by structured + * controls, preventing double-editing. The result is the set of baked rows + * that the generic env-vars editor should display. + * + * Call with the union of always-structured keys (provider/model/effort set) + * and numeric structured keys derived from `structuredEnvKeys()`. + * + * Pure — suitable for Node-layer unit tests without a component renderer. + */ +export function filterBakedGenericRows( + bakedEnv: readonly T[], + excludeKeys: ReadonlySet | readonly string[], +): T[] { + const exclude = + excludeKeys instanceof Set ? excludeKeys : new Set(excludeKeys); + return bakedEnv.filter((e) => !exclude.has(e.key)); +} + +/** + * Returns the placeholder string for a numeric tuning input. + * + * When an inherited value is present, the field shows `"Inherit ()"`. + * When absent (no global setting), the field shows `"Inherit (agent default)"`. + * + * Pure — used by NumericTuningFields and testable without a component renderer. + */ +export function numericTuningPlaceholder( + inheritedValue: string | null | undefined, +): string { + return inheritedValue + ? `Inherit (${inheritedValue})` + : "Inherit (agent default)"; +} diff --git a/desktop/src/features/agents/ui/AgentConfigFields.tsx b/desktop/src/features/agents/ui/AgentConfigFields.tsx index ce3d2522037..11f68e8a564 100644 --- a/desktop/src/features/agents/ui/AgentConfigFields.tsx +++ b/desktop/src/features/agents/ui/AgentConfigFields.tsx @@ -24,6 +24,8 @@ import { deriveAgentConfigFieldModel, getRenderableEffortField, hasRenderableAgentConfigField, + structuredEnvKeys, + filterBakedGenericRows, } from "@/features/agents/lib/agentConfigCore"; import { getBakedProviderInheritLabel, @@ -52,7 +54,9 @@ import { } from "@/features/agents/ui/buzzAgentConfig"; import { EffortSelectField, + NumericTuningFields, useEffortAutoClear, + type NumericDescriptor, } from "@/features/agents/ui/buzzAgentModelTuningFields"; import { SettingsOptionGroup } from "@/features/settings/ui/SettingsOptionGroup"; import { AdvancedRequiredBadge } from "./AdvancedRequiredBadge"; @@ -66,7 +70,6 @@ export const EMPTY_GLOBAL_CONFIG: GlobalAgentConfig = { preferred_runtime: null, }; -/** Baked env keys that route to structured controls, not the generic env editor. */ const BAKED_STRUCTURED_KEYS = new Set([ "BUZZ_AGENT_PROVIDER", "BUZZ_AGENT_MODEL", @@ -103,12 +106,7 @@ export const CANONICAL_CONFIG_BEHAVIORS = { requireProviderForModelAndEffort, } as const; -/** - * Disclosure preset → the eight visibility decisions it owns. Full and - * progressive defaults expose the same controls; the progressive preset - * changes only when those controls are revealed. Exported for the contract - * test. - */ +/** Disclosure preset → the eight visibility decisions it owns. Exported for the contract test. */ export function resolveDisclosure(disclosure: AgentConfigDisclosure) { const full = disclosure !== "onboarding-essential"; return { @@ -139,14 +137,7 @@ export function shouldRevealDependentConfigFields({ ); } -/** - * Determines whether the status line beneath the Model field should render. - * - * Discovery warnings bypass the `onboarding-essential` preset so that a - * first-run failure is never silently invisible. On the happy path - * (`status === null`) the status line stays hidden in onboarding, keeping - * the page clean. - */ +/** Whether the status line under the Model field renders. Discovery warnings bypass onboarding-essential so first-run failures are never invisible. */ export function shouldShowModelStatusMessage( showDescriptions: boolean, status: { message: string; tone: string } | null, @@ -155,14 +146,8 @@ export function shouldShowModelStatusMessage( } /** - * Whether the Model control should render given discovery state. - * - * Optional-model harnesses (Claude Code / Codex, `acpNative`) omit the control - * while discovery is in flight and after a **confirmed successful empty** - * catalog (IPC resolved, no usable options) — there is nothing useful to pick. - * Discovery failures / unavailable runtimes keep the control so #2246 failure - * UI can render. Full disclosure still shows the control when Custom model is - * available. Required-model harnesses always render the control. + * Renders the Model control given discovery state. Optional-model harnesses omit it while + * discovery is loading or after confirmed successful empty; failures keep it for the #2246 UI. */ export function shouldRenderModelControl({ discoveredModelOptions, @@ -269,6 +254,19 @@ export function AgentConfigFields({ effortField?.currentPersistence.kind === "envVar" ? effortField.currentPersistence.key : null; + + const numericDescriptors = fieldModel.fields.filter( + (d): d is NumericDescriptor => + (d.kind === "maxOutputTokens" || + d.kind === "contextLimit" || + d.kind === "maxRounds") && + d.render === "control", + ); + const allStructuredKeys = structuredEnvKeys([ + ...(effortField ? [effortField] : []), + ...numericDescriptors, + ]); + const bakedEnvMap = Object.fromEntries(bakedEnv.map((e) => [e.key, e.value])); const bakedProvider = React.useMemo( () => bakedEnv.find((e) => e.key === "BUZZ_AGENT_PROVIDER")?.value ?? null, [bakedEnv], @@ -301,8 +299,12 @@ export function AgentConfigFields({ [bakedEnv], ); const bakedGenericRows = React.useMemo( - () => bakedEnv.filter((e) => !BAKED_STRUCTURED_KEYS.has(e.key)), - [bakedEnv], + () => + filterBakedGenericRows(bakedEnv, [ + ...BAKED_STRUCTURED_KEYS, + ...allStructuredKeys, + ]), + [bakedEnv, allStructuredKeys], ); const providerValue = providerFieldVisible ? (config.provider ?? "") : ""; @@ -573,16 +575,15 @@ export function AgentConfigFields({ } function handleEnvVarsChange(next: Record) { - const effort = effortPersistenceKey - ? config.env_vars[effortPersistenceKey] - : undefined; - const merged = { ...next }; - if (effortPersistenceKey && effort !== undefined) { - merged[effortPersistenceKey] = effort; - } - onConfigChange({ ...config, env_vars: merged }); + onConfigChange({ ...config, env_vars: next }); } + const handleNumericEnvVarChange = (key: string, value: string) => { + const next = { ...config.env_vars, [key]: value }; + if (value === "") delete next[key]; + onConfigChange({ ...config, env_vars: next }); + }; + // On internal Block builds, BUZZ_AGENT_PROVIDER is baked in and a boot // migration rewrites v1→v2. Hide the legacy v1 option so it is not offered // for new selections; OSS builds show it. @@ -739,6 +740,33 @@ export function AgentConfigFields({
) : null; + const advancedEditorBlock = ( + <> + + {numericDescriptors.length > 0 ? ( + + ) : null} + + ); + const dependentContent = ( <> {providerFieldVisible && apiKeyEnvVar ? ( @@ -903,40 +931,12 @@ export function AgentConfigFields({ : PROGRESSIVE_FIELDS_TRANSITION } > - k !== BUZZ_AGENT_THINKING_EFFORT, - ), - )} - /> + {advancedEditorBlock} ) : null} ) : advancedOpen ? ( - k !== BUZZ_AGENT_THINKING_EFFORT, - ), - )} - /> + advancedEditorBlock ) : null} ) : null} diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 1916b57069d..12702f45ac4 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -101,7 +101,7 @@ type AgentDefinitionDialogProps = { error: Error | null; isPending: boolean; runtimes: AcpRuntimeCatalogEntry[]; - runtimesLoading?: boolean; + runtimeCatalogStatus?: "loading" | "ready" | "error"; onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, @@ -128,13 +128,14 @@ export function AgentDefinitionDialog({ error, isPending, runtimes, - runtimesLoading = false, + runtimeCatalogStatus = "ready" as const, onOpenChange, onSubmit, publishCatalogUpdatesOnSave = false, createRunSection, createSubmitBlocked = false, }: AgentDefinitionDialogProps) { + const runtimesLoading = runtimeCatalogStatus === "loading"; const [displayName, setDisplayName] = React.useState(""); const [aiDefaultsOpen, setAiDefaultsOpen] = React.useState(false); const aiDefaultsTriggerRef = React.useRef(null); @@ -394,11 +395,7 @@ export function AgentDefinitionDialog({ (runtime.trim().length > 0 && runtimeCanChooseLlmProvider) || blankRuntimeModelProviderEditable; const trimmedProvider = provider.trim(); - // Required credential env keys for this runtime + provider combination. - // Used to show required markers on the LLM provider label and amber - // locked rows in the env vars editor. - // File-layer config for the selected runtime (e.g. goose config.yaml). - // Used to silence requirements already satisfied there. + // Required credential env keys and file-layer config; silences requirements satisfied in the file layer. const { data: runtimeFileConfig } = useRuntimeFileConfigQuery(runtime, { enabled: open, }); @@ -1016,6 +1013,8 @@ export function AgentDefinitionDialog({ model={model} modelTuningRuntimeId={runtime} namePoolText={namePoolText} + catalogStatus={runtimeCatalogStatus} + selectedRuntime={selectedRuntime} onBehaviorDraftChange={(nextBehaviorDraft) => { setHasUserChanges(true); setBehaviorDraft(nextBehaviorDraft); diff --git a/desktop/src/features/agents/ui/AgentDialog.tsx b/desktop/src/features/agents/ui/AgentDialog.tsx index dc608da4890..a875770b38c 100644 --- a/desktop/src/features/agents/ui/AgentDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDialog.tsx @@ -34,7 +34,7 @@ type AgentDialogCreateProps = { definitionError: Error | null; isDefinitionPending: boolean; runtimes: AcpRuntimeCatalogEntry[]; - runtimesLoading: boolean; + runtimeCatalogStatus: "loading" | "ready" | "error"; onSubmitDefinition: ( input: CreatePersonaInput | UpdatePersonaInput, intent: AgentCreateIntent, @@ -68,7 +68,7 @@ type AgentDialogDefinitionEditProps = { error: Error | null; isPending: boolean; runtimes: AcpRuntimeCatalogEntry[]; - runtimesLoading?: boolean; + runtimeCatalogStatus?: "loading" | "ready" | "error"; onOpenChange: (open: boolean) => void; onSubmit: ( input: CreatePersonaInput | UpdatePersonaInput, @@ -125,7 +125,7 @@ function AgentCreateDialogRouter({ definitionError, isDefinitionPending, runtimes, - runtimesLoading, + runtimeCatalogStatus, onSubmitDefinition, }: AgentDialogCreateProps) { const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft); @@ -166,7 +166,7 @@ function AgentCreateDialogRouter({ }} open runtimes={runtimes} - runtimesLoading={runtimesLoading} + runtimeCatalogStatus={runtimeCatalogStatus} submitLabel={copy.submitLabel} title={copy.title} /> diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index d717d773e88..adfb8182a86 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -266,16 +266,10 @@ export function AgentInstanceEditDialog({ return runtimeSupportsLlmProviderSelection(matched?.id ?? ""); }, [runtimes, originalAgentCommand]); - // The runtime id that will actually be active after submit. When inheriting, - // resolve from the LINKED PERSONA's runtime — that is what will run once the - // override is cleared. Deriving from agent.agentCommand here is wrong for a - // pinned agent that just toggled "Inherit runtime from template": the override - // (e.g. a Claude pin) is still present on the record, so it would resolve to - // the old pin instead of the persona's runtime, hiding required credentials. - // Fall back to the agent.agentCommand dual-match (command path, then id) only - // when there is no linked persona or its runtime is unset. This single - // prospective id feeds BOTH the block-save gate (requiredEnvKeys) and the - // submit path so they never disagree on which runtime is being saved. + // The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime + // (that is what runs once the override is cleared, not the current override). + // Falls back to dual-match (command path, then id) when no persona or its runtime is unset. + // This single prospective id feeds BOTH the block-save gate and submit so they always agree. const prospectiveRuntimeId = React.useMemo(() => { if (!inheritHarness) { return selectedRuntime?.id ?? selectedRuntimeId; @@ -307,6 +301,15 @@ export function AgentInstanceEditDialog({ const llmProviderFieldVisible = runtimeSupportsLlmProviderSelection(prospectiveRuntimeId); + const prospectiveRuntime = runtimes.find( + (r) => r.id === prospectiveRuntimeId, + ); + const runtimeCatalogStatus = runtimesQuery.isLoading + ? ("loading" as const) + : runtimesQuery.isError + ? ("error" as const) + : ("ready" as const); + // One-shot focus: when the dialog opens from a card deep-link, scroll and // focus the relevant field. The effect re-runs when `llmProviderFieldVisible` // changes so a provider-field focus request fires once the field materializes. @@ -339,9 +342,8 @@ export function AgentInstanceEditDialog({ return () => cancelAnimationFrame(id); }, [open, initialFocus, agent.pubkey, llmProviderFieldVisible]); - // Provider + env to PERSIST on submit — also fed to the credential gate so - // gate, saved record, and spawn snapshot all agree on one resolved value. - // See resolveInheritedRuntimeSubmission for the inherit/transition contract. + // Provider + env to PERSIST on submit — also fed to the credential gate so gate, saved record, + // and spawn snapshot all agree on one resolved value. See resolveInheritedRuntimeSubmission. const inheritedSubmission = React.useMemo( () => resolveInheritedRuntimeSubmission({ @@ -376,12 +378,8 @@ export function AgentInstanceEditDialog({ inheritedEnvVars: inheritedEnvVarsForAdvanced, } = useAgentDialogDefaults({ inheritedEnvVars, open }); - // Runtime/provider-required credential state, derived from the PROSPECTIVE - // post-submit runtime — see the hook for the inherit-transition rationale. - // Pass globalProvider so the hook uses it as a fallback when the per-agent - // provider is empty (global-provider-only configs must surface required keys). - // Pass globalEnvVars so keys satisfied by global config are excluded from - // requiredEnvKeys and do not block Save (display and gate agree). + // Runtime/provider-required credential state for the PROSPECTIVE post-submit runtime. + // globalProvider/globalEnvVars: fallback for empty per-agent provider; keys satisfied globally don't block Save. const { requiredEnvKeys, fileSatisfiedEnvKeys, requiredEnvKeyMissing } = useRequiredCredentialState({ open, @@ -1199,6 +1197,8 @@ export function AgentInstanceEditDialog({ parallelism={parallelism} provider={effectiveProvider} requiredEnvKeys={advancedRequiredEnvKeys} + catalogStatus={runtimeCatalogStatus} + selectedRuntime={prospectiveRuntime} systemPrompt={systemPrompt} onAcpCommandChange={setAcpCommand} onAgentArgsChange={setAgentArgs} diff --git a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx index 0d01cbbcd1f..b72669e5f6b 100644 --- a/desktop/src/features/agents/ui/AgentManagementDialogs.tsx +++ b/desktop/src/features/agents/ui/AgentManagementDialogs.tsx @@ -22,7 +22,7 @@ export function AgentManagementDialogs() { }} onSubmitDefinition={management.submitCreate} runtimes={management.runtimes} - runtimesLoading={management.runtimesLoading} + runtimeCatalogStatus={management.runtimeCatalogStatus} /> ) : null} {management.createdAgent ? ( @@ -51,7 +51,7 @@ export function AgentManagementDialogs() { onSubmit={management.submitUpdate} open runtimes={management.runtimes} - runtimesLoading={management.runtimesLoading} + runtimeCatalogStatus={management.runtimeCatalogStatus} submitLabel="Save changes" title="Edit agent" /> diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 3d1673c365c..720d6e62ade 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -319,7 +319,13 @@ export function AgentsView() { }} onSubmitDefinition={personas.handleSubmit} runtimes={personas.acpRuntimesQuery.data ?? []} - runtimesLoading={personas.acpRuntimesQuery.isLoading} + runtimeCatalogStatus={ + personas.acpRuntimesQuery.isLoading + ? "loading" + : personas.acpRuntimesQuery.isError + ? "error" + : "ready" + } /> ) : null} {agents.agentToAddToChannel ? ( @@ -368,7 +374,13 @@ export function AgentsView() { isPending={personas.isPending} mode="definition-edit" runtimes={personas.acpRuntimesQuery.data ?? []} - runtimesLoading={personas.acpRuntimesQuery.isLoading} + runtimeCatalogStatus={ + personas.acpRuntimesQuery.isLoading + ? "loading" + : personas.acpRuntimesQuery.isError + ? "error" + : "ready" + } onOpenChange={(open) => { if (!open) { personas.setPersonaDialogState(null); diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index 972c4e287e3..56685fd6c4a 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; @@ -9,9 +10,21 @@ import { PERSONA_LABEL_OPTIONAL_CLASS, } from "./agentConfigOptions"; import type { AgentPersona } from "@/shared/api/types"; -import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; -import { isBuzzAgentRuntime } from "./buzzAgentConfig"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { + BuzzAgentModelTuningFields, + NumericTuningFields, +} from "./buzzAgentModelTuningFields"; +import { + isBuzzAgentRuntime, + BUZZ_AGENT_THINKING_EFFORT, +} from "./buzzAgentConfig"; import { EDIT_AGENT_PARALLELISM_HELP } from "../lib/agentParallelism"; +import { + deriveNumericDescriptors, + structuredEnvKeys, + type RuntimeCatalogStatus, +} from "../lib/agentConfigCore"; export function EditAgentAdvancedFields({ acpCommand, @@ -30,6 +43,8 @@ export function EditAgentAdvancedFields({ parallelism, provider, requiredEnvKeys, + catalogStatus = "ready", + selectedRuntime, systemPrompt, onAcpCommandChange, onAgentArgsChange, @@ -55,7 +70,7 @@ export function EditAgentAdvancedFields({ model?: string; /** * The actual/prospective runtime id used to decide whether to show the - * buzz-agent model-tuning fields. Uses `prospectiveRuntimeId` from + * buzz-agent effort-tuning field. Uses `prospectiveRuntimeId` from * EditAgentDialog — the resolved runtime, not the "inherit"/"custom" sentinel. */ modelTuningRuntimeId: string; @@ -63,6 +78,24 @@ export function EditAgentAdvancedFields({ /** Active LLM provider id — forwarded to BuzzAgentModelTuningFields for effort filtering. */ provider?: string; requiredEnvKeys: readonly string[]; + /** + * Lifecycle status of the runtime catalog query. Controls the numeric-tuning + * gate and hidden-key behaviour: + * - `loading` or `error`: no structured controls; keys not hidden — saved + * values stay visible as generic rows. + * - `ready`: descriptors derived from `selectedRuntime` (empty when the + * runtime has no numeric env-var fields). + * + * Defaults to `"ready"` so existing callers without the catalog query do not + * need to change. + */ + catalogStatus?: RuntimeCatalogStatus; + /** + * The catalog entry for the prospective runtime. Drives descriptor-based + * numeric tuning fields (max output tokens / context limit / max rounds). + * When undefined after the catalog has settled, no numeric controls render. + */ + selectedRuntime?: AcpRuntimeCatalogEntry; systemPrompt: string; onAcpCommandChange: (value: string) => void; onAgentArgsChange: (value: string) => void; @@ -72,6 +105,29 @@ export function EditAgentAdvancedFields({ onAutoRestartChange: (value: boolean) => void; onSystemPromptChange: (value: string) => void; }) { + // Numeric tuning descriptors — gate on catalog status so that loading/error + // never collapses to "no controls": keys stay visible as generic rows. + const numericDescriptors = React.useMemo( + () => + catalogStatus === "ready" + ? deriveNumericDescriptors(selectedRuntime) + : [], + [catalogStatus, selectedRuntime], + ); + + // Build the effective hidden-key list: caller's secrets + effort key (when + // rendered by BuzzAgentModelTuningFields) + numeric keys via structuredEnvKeys. + const effectiveHiddenKeys = React.useMemo( + () => [ + ...hiddenEnvKeys, + ...(isBuzzAgentRuntime(modelTuningRuntimeId) + ? [BUZZ_AGENT_THINKING_EFFORT] + : []), + ...structuredEnvKeys(numericDescriptors), + ], + [hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors], + ); + return (
{/* Inherit runtime from template */} @@ -248,7 +304,7 @@ export function EditAgentAdvancedFields({ - {/* Tier-1 buzz-agent model-tuning knobs — only shown for buzz-agent. */} + {/* Descriptor-driven numeric tuning knobs — shown when the catalog has settled + and the runtime exposes numeric env-var fields. */} + {numericDescriptors.length > 0 ? ( + { + const next = { ...envVars }; + if (value === "") { + delete next[key]; + } else { + next[key] = value; + } + onEnvVarsChange(next); + }} + /> + ) : null} + + {/* Effort-tuning knob — only shown for buzz-agent. */} {isBuzzAgentRuntime(modelTuningRuntimeId) ? ( { "annotation must not appear when its key is not in the env map", ); }); + +// ── buildRecord with hiddenKeys: structured-field preservation ──────────── +// +// These tests exercise the exported buildRecord(nextRows, value, requiredKeys, +// hiddenKeys) using the real implementation. hiddenKeys are structured-field +// env vars (e.g. BUZZ_AGENT_MAX_ROUNDS) that are owned by first-class controls +// outside the editor — they must survive onChange cycles even though they +// never appear as generic rows. +// +// Four scenarios from rev 4: +// 1. Edit an unrelated generic row → hidden (tuning) key is unchanged. +// 2. Runtime switch then generic edit: after switching to a new runtime, +// the new runtime's hidden keys survive; old runtime keys appear as +// generic rows and survive via toRecord, not hidden-key preservation. +// 3. Baked numeric key excluded via real descriptor/helper path: uses the +// production deriveAgentConfigFieldModel + structuredEnvKeys helpers to +// derive the hidden set, then verifies toRows excludes the numeric key. +// 4. Clearing a structured override → placeholder returns: after the user +// clears a structured field (key absent from value), buildRecord must +// not reintroduce it, leaving the structured field free to show the +// Inherit placeholder. + +test("buildRecord_hidden_tuning_key_unchanged_when_generic_row_edited", () => { + // Structured field set BUZZ_AGENT_MAX_ROUNDS to "50"; it lives in value + // as a hiddenKey. User then edits a generic env var via the row editor. + // The tuning key must survive the buildRecord emit cycle unchanged. + const value = { BUZZ_AGENT_MAX_ROUNDS: "50", MY_VAR: "old" }; + const nextRows = [{ id: "r1", key: "MY_VAR", value: "new" }]; + const record = buildRecordUtil( + nextRows, + value, + [], + ["BUZZ_AGENT_MAX_ROUNDS"], + ); + + assert.equal( + record.BUZZ_AGENT_MAX_ROUNDS, + "50", + "hidden tuning key must survive when an unrelated generic row is edited", + ); + assert.equal(record.MY_VAR, "new", "generic row edit applied"); +}); + +test("buildRecord_runtime_switch_new_hiddenKeys_then_generic_edit", () => { + // Scenario 2: runtime switch then generic edit. + // + // Before switch: agent is buzz-agent with BUZZ_AGENT_MAX_ROUNDS = "50" stored + // in value (set via the numeric tuning control). After switching to Goose, + // the buzz-agent key is no longer hidden — it becomes a visible generic row. + // The test verifies: + // (a) After the switch, the old buzz-agent key appears as a generic row + // (toRows with the new Goose hidden set projects it). + // (b) After a generic-row edit, buildRecord preserves BOTH the old-runtime + // key (now a generic row) and the new-runtime hidden key. + // (c) An unset new-runtime hidden key is not introduced. + + // Derive both descriptor sets from real runtime objects. + const buzzAgentRuntime = { + id: "buzz-agent", + label: "Buzz Agent", + avatarUrl: "", + availability: "available", + command: "buzz-agent", + binaryPath: "buzz-agent", + defaultArgs: [], + mcpCommand: null, + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: "BUZZ_AGENT_CONTEXT_LIMIT", + maxRoundsEnvVar: "BUZZ_AGENT_MAX_ROUNDS", + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + }; + const gooseRuntime = { + id: "goose", + label: "Goose", + avatarUrl: "", + availability: "available", + command: "goose", + binaryPath: "goose", + defaultArgs: ["acp"], + mcpCommand: null, + modelEnvVar: null, + providerEnvVar: null, + thinkingEnvVar: null, + maxTokensEnvVar: "GOOSE_MAX_TOKENS", + contextLimitEnvVar: "GOOSE_CONTEXT_LIMIT", + maxRoundsEnvVar: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: true, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + }; + + const buzzDescriptors = deriveNumericDescriptors(buzzAgentRuntime); + const gooseDescriptors = deriveNumericDescriptors(gooseRuntime); + const buzzHiddenKeys = structuredEnvKeys(buzzDescriptors); + const gooseHiddenKeys = structuredEnvKeys(gooseDescriptors); + + // Sanity-check that BUZZ_AGENT_MAX_ROUNDS is hidden under buzz-agent but not + // under Goose — that contrast is what makes it become a generic row. + assert.ok( + buzzHiddenKeys.includes("BUZZ_AGENT_MAX_ROUNDS"), + "BUZZ_AGENT_MAX_ROUNDS must be hidden under buzz-agent descriptors", + ); + assert.equal( + gooseHiddenKeys.includes("BUZZ_AGENT_MAX_ROUNDS"), + false, + "BUZZ_AGENT_MAX_ROUNDS must not be hidden under Goose descriptors", + ); + + // Pre-switch value: buzz-agent max-rounds was set, GOOSE_MAX_TOKENS was + // already set (e.g. user configured it before switching back), plus a + // generic user var. GOOSE_MAX_TOKENS is a hidden key under the Goose + // descriptor set, so it must survive buildRecord() via hiddenKeys. + const valueBeforeSwitch = { + BUZZ_AGENT_MAX_ROUNDS: "50", + GOOSE_MAX_TOKENS: "16384", + USER_VAR: "original", + }; + + // After the switch to Goose, toRows is reproj with the new (Goose) hidden + // set. BUZZ_AGENT_MAX_ROUNDS is no longer hidden → appears as a generic row. + // GOOSE_MAX_TOKENS IS hidden under Goose → must not appear in generic rows. + const rowsAfterSwitch = toRows(valueBeforeSwitch, new Set(gooseHiddenKeys)); + assert.ok( + rowsAfterSwitch.some((r) => r.key === "BUZZ_AGENT_MAX_ROUNDS"), + "old-runtime key must become a generic row after the switch", + ); + assert.equal( + rowsAfterSwitch.some((r) => r.key === "GOOSE_MAX_TOKENS"), + false, + "new-runtime hidden key must not appear as a generic row after the switch", + ); + + // User edits the generic USER_VAR row. + const editedRows = rowsAfterSwitch.map((r) => + r.key === "USER_VAR" ? { ...r, value: "updated" } : r, + ); + + // buildRecord: old-runtime key survives via toRecord (it's now a generic + // row); new-runtime Goose hidden key survives via hiddenKeys (carried + // through from value). An unset Goose key must not be introduced. + const record = buildRecordUtil( + editedRows, + valueBeforeSwitch, + [], + gooseHiddenKeys, + ); + + assert.equal( + record.BUZZ_AGENT_MAX_ROUNDS, + "50", + "old-runtime key must survive as a generic row value after switch", + ); + assert.equal( + record.GOOSE_MAX_TOKENS, + "16384", + "new-runtime hidden key must survive buildRecord via hiddenKeys", + ); + assert.equal(record.USER_VAR, "updated", "generic row edit applied"); + assert.equal( + "GOOSE_CONTEXT_LIMIT" in record, + false, + "unset new-runtime hidden key must not be introduced", + ); +}); + +test("filterBakedGenericRows_numeric_baked_key_excluded_and_placeholder_shown", () => { + // Scenario 3: baked numeric key excluded via the real production helper. + // + // The global baked env contains BUZZ_AGENT_MAX_OUTPUT_TOKENS = "4096" + // (the baked value shipped with the agent). The production + // filterBakedGenericRows path must exclude this key from the generic + // baked-row display so it isn't editable twice, while the structured + // numeric input shows the inherited placeholder via numericTuningPlaceholder. + const buzzAgentRuntime = { + id: "buzz-agent", + label: "Buzz Agent", + avatarUrl: "", + availability: "available", + command: "buzz-agent", + binaryPath: "buzz-agent", + defaultArgs: [], + mcpCommand: null, + modelEnvVar: "BUZZ_AGENT_MODEL", + providerEnvVar: "BUZZ_AGENT_PROVIDER", + thinkingEnvVar: "BUZZ_AGENT_THINKING_EFFORT", + maxTokensEnvVar: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + contextLimitEnvVar: null, + maxRoundsEnvVar: null, + installHint: "", + installInstructionsUrl: "", + canAutoInstall: false, + underlyingCliPath: null, + nodeRequired: false, + authStatus: { status: "not_applicable" }, + loginHint: null, + }; + + const numericDescriptors = deriveNumericDescriptors(buzzAgentRuntime); + const numericStructuredKeys = structuredEnvKeys(numericDescriptors); + + assert.ok( + numericStructuredKeys.includes("BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + "numeric key must appear in structured keys via production helpers", + ); + + // Simulate the baked env: BUZZ_AGENT_MAX_OUTPUT_TOKENS is baked, plus a + // non-structured baked var. + const bakedEnv = [ + { key: "BUZZ_AGENT_MAX_OUTPUT_TOKENS", value: "4096" }, + { key: "SOME_OTHER_BAKED_VAR", value: "hello" }, + ]; + + // filterBakedGenericRows must exclude the numeric key. + const genericRows = filterBakedGenericRows(bakedEnv, numericStructuredKeys); + + assert.equal( + genericRows.some((r) => r.key === "BUZZ_AGENT_MAX_OUTPUT_TOKENS"), + false, + "baked numeric key must be excluded from generic baked rows", + ); + assert.ok( + genericRows.some((r) => r.key === "SOME_OTHER_BAKED_VAR"), + "non-structured baked var must remain in generic rows", + ); + + // The structured numeric input shows the inherited placeholder for the + // baked value via numericTuningPlaceholder. + const bakedValue = "4096"; + assert.equal( + numericTuningPlaceholder(bakedValue), + "Inherit (4096)", + "structured placeholder must reflect the baked value", + ); + assert.equal( + numericTuningPlaceholder(undefined), + "Inherit (agent default)", + "structured placeholder without baked value shows agent-default text", + ); +}); + +test("buildRecord_clearing_structured_field_allows_placeholder_to_return", () => { + // Scenario 4: clearing a structured override → placeholder returns. + // + // Step 1: value has BUZZ_AGENT_MAX_ROUNDS = "50" (user set it via the + // structured field). BUZZ_AGENT_MAX_ROUNDS is in hiddenKeys. + // Step 2: user clears the structured field → onEnvVarChange(key, "") + // removes the key from value (value no longer contains it). + // Step 3: after the clear, buildRecord must not reintroduce the key. + // Step 4: with the key absent from value, numericTuningPlaceholder over + // the (now-empty) inheritedEnvVars shows "Inherit (agent default)" + // — the numeric field's empty-state placeholder. + + // After the clear, value no longer contains BUZZ_AGENT_MAX_ROUNDS. + const valueAfterClear = { MY_VAR: "foo" }; + const nextRows = [{ id: "r1", key: "MY_VAR", value: "updated" }]; + + const record = buildRecordUtil( + nextRows, + valueAfterClear, + [], + ["BUZZ_AGENT_MAX_ROUNDS"], + ); + + assert.equal( + "BUZZ_AGENT_MAX_ROUNDS" in record, + false, + "cleared structured key must not be reintroduced by buildRecord", + ); + assert.equal(record.MY_VAR, "updated"); + + // With the key cleared, the inherited value is also absent (not set + // globally). numericTuningPlaceholder returns the agent-default text — + // the placeholder that renders in the structured input. + const inheritedAfterClear = undefined; + assert.equal( + numericTuningPlaceholder(inheritedAfterClear), + "Inherit (agent default)", + "numeric input must show Inherit (agent default) after clear when no global override", + ); + + // If a global override IS set, the placeholder shows that value instead. + const inheritedGlobal = "25"; + assert.equal( + numericTuningPlaceholder(inheritedGlobal), + "Inherit (25)", + "numeric input must show Inherit () when a global override exists", + ); +}); diff --git a/desktop/src/features/agents/ui/EnvVarsEditor.tsx b/desktop/src/features/agents/ui/EnvVarsEditor.tsx index 91e5b076220..08496eece59 100644 --- a/desktop/src/features/agents/ui/EnvVarsEditor.tsx +++ b/desktop/src/features/agents/ui/EnvVarsEditor.tsx @@ -44,7 +44,9 @@ export function toRows( * Collapse an ordered row list back to a record, skipping rows with empty * keys. Exported for unit tests. */ -export function toRecord(rows: Row[]): EnvVarsValue { +export function toRecord( + rows: readonly { key: string; value: string }[], +): EnvVarsValue { const out: EnvVarsValue = {}; for (const row of rows) { // Empty key = user is mid-edit; skip it so we don't poison the record. @@ -177,6 +179,27 @@ type EnvVarsEditorProps = { type Row = { id: string; key: string; value: string }; +/** + * Pure record builder: merges `toRecord(nextRows)` with the current values of + * `requiredKeys` and `hiddenKeys` from `value`. Required and hidden keys are + * excluded from the row state (`skipKeys`), so this merge is the only place + * their current values survive an `onChange` emit cycle. + * + * Exported for unit testing. `EnvVarsEditor` calls this internally. + */ +export function buildRecord( + nextRows: readonly { key: string; value: string }[], + value: EnvVarsValue, + requiredKeys: readonly string[], + hiddenKeys: readonly string[], +): EnvVarsValue { + const base: EnvVarsValue = {}; + for (const key of [...requiredKeys, ...hiddenKeys]) { + if (key in value) base[key] = value[key]; + } + return { ...base, ...toRecord(nextRows) }; +} + /** * A flat key/value editor for environment variables. * @@ -241,18 +264,6 @@ export function EnvVarsEditor({ } }, [value, skipKeys]); - // Build the emitted record: normal rows + required-key values preserved - // from `value`. Required keys are never in `rows`, so `toRecord(rows)` - // would silently drop any required secret the user just typed unless we - // merge them back explicitly. - function buildRecord(nextRows: Row[]): EnvVarsValue { - const base: EnvVarsValue = {}; - for (const key of [...requiredKeys, ...hiddenKeys]) { - if (key in value) base[key] = value[key]; - } - return { ...base, ...toRecord(nextRows) }; - } - // Ref map: key → required-value Input element. Populated via callback refs // on each required-key row's value Input so focus can be dispatched directly // without any DOM walking through presentation classes. @@ -294,7 +305,7 @@ export function EnvVarsEditor({ function emit(next: Row[]) { setRows(next); - const record = buildRecord(next); + const record = buildRecord(next, value, requiredKeys, hiddenKeys); lastEmitted.current = record; onChange(record); } diff --git a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx index b7d19037846..1ecd98b83fb 100644 --- a/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/PersonaAdvancedFields.tsx @@ -1,20 +1,33 @@ +import * as React from "react"; import { Input } from "@/shared/ui/input"; import { cn } from "@/shared/lib/cn"; import { EnvVarsEditor, type EnvVarsValue } from "./EnvVarsEditor"; import { CreateAgentRespondToField } from "./RespondToField"; import type { PersonaBehaviorDraft } from "./personaBehaviorDraft"; -import { isBuzzAgentRuntime } from "./buzzAgentConfig"; +import { + isBuzzAgentRuntime, + BUZZ_AGENT_THINKING_EFFORT, +} from "./buzzAgentConfig"; import { AGENT_PARALLELISM_HELP, AGENT_PARALLELISM_PLACEHOLDER, } from "../lib/agentParallelism"; -import { BuzzAgentModelTuningFields } from "./buzzAgentModelTuningFields"; +import { + BuzzAgentModelTuningFields, + NumericTuningFields, +} from "./buzzAgentModelTuningFields"; import { CARD_MINT_KEY_ANNOTATIONS, PERSONA_FIELD_CONTROL_CLASS, PERSONA_FIELD_SHELL_CLASS, PERSONA_LABEL_OPTIONAL_CLASS, } from "./agentConfigOptions"; +import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; +import { + deriveNumericDescriptors, + structuredEnvKeys, + type RuntimeCatalogStatus, +} from "../lib/agentConfigCore"; export function PersonaAdvancedFields({ behaviorDraft, @@ -31,6 +44,8 @@ export function PersonaAdvancedFields({ requiredEnvKeys = [], fileSatisfiedEnvKeys = [], hiddenEnvKeys = [], + catalogStatus = "ready" as RuntimeCatalogStatus, + selectedRuntime, }: { behaviorDraft: PersonaBehaviorDraft; disabled: boolean; @@ -40,7 +55,7 @@ export function PersonaAdvancedFields({ inheritedEnvVars?: EnvVarsValue; /** Active LLM model — forwarded to BuzzAgentModelTuningFields for effort filtering. */ model?: string; - /** Runtime id for the buzz-agent tuning knobs visibility gate. */ + /** Runtime id for the buzz-agent effort-tuning knob visibility gate. */ modelTuningRuntimeId?: string; namePoolText: string; onBehaviorDraftChange: (value: PersonaBehaviorDraft) => void; @@ -51,7 +66,42 @@ export function PersonaAdvancedFields({ requiredEnvKeys?: readonly string[]; fileSatisfiedEnvKeys?: readonly string[]; hiddenEnvKeys?: readonly string[]; + /** + * Lifecycle status of the runtime catalog query. Controls the numeric-tuning + * gate and hidden-key behaviour: + * - `loading` or `error`: no structured controls; keys not hidden — saved + * values stay visible as generic rows. + * - `ready`: descriptors derived from `selectedRuntime` (empty when the + * runtime has no numeric env-var fields). + */ + catalogStatus?: RuntimeCatalogStatus; + /** + * The catalog entry for the selected runtime. Drives descriptor-based + * numeric tuning fields. When undefined after the catalog has settled, + * no numeric controls render. + */ + selectedRuntime?: AcpRuntimeCatalogEntry; }) { + // Numeric tuning descriptors — gate on catalog status so that loading/error + // never collapses to "no controls": keys stay visible as generic rows. + const numericDescriptors = React.useMemo( + () => + catalogStatus === "ready" + ? deriveNumericDescriptors(selectedRuntime) + : [], + [catalogStatus, selectedRuntime], + ); + + const effectiveHiddenKeys = React.useMemo( + () => [ + ...hiddenEnvKeys, + ...(isBuzzAgentRuntime(modelTuningRuntimeId) + ? [BUZZ_AGENT_THINKING_EFFORT] + : []), + ...structuredEnvKeys(numericDescriptors), + ], + [hiddenEnvKeys, modelTuningRuntimeId, numericDescriptors], + ); return (
- {/* Tier-1 buzz-agent model-tuning knobs — only shown for buzz-agent. */} + {/* Descriptor-driven numeric tuning knobs — shown when catalog has settled + and the runtime exposes numeric env-var fields. */} + {numericDescriptors.length > 0 ? ( + { + const next = { ...envVars }; + if (value === "") { + delete next[key]; + } else { + next[key] = value; + } + onEnvVarsChange(next); + }} + /> + ) : null} + + {/* Effort-tuning knob — only shown for buzz-agent. */} {isBuzzAgentRuntime(modelTuningRuntimeId) ? ( ) : null} {personas.createdAgent ? ( diff --git a/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx b/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx index 938d5edf5b9..7fa87c4e6b1 100644 --- a/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx +++ b/desktop/src/features/agents/ui/buzzAgentModelTuningFields.tsx @@ -9,14 +9,13 @@ import * as React from "react"; import { Input } from "@/shared/ui/input"; import { cn } from "@/shared/lib/cn"; import type { EnvVarsValue } from "./EnvVarsEditor"; +import type { NumericDescriptor } from "../lib/agentConfigCore"; +import { numericTuningPlaceholder } from "../lib/agentConfigCore"; import { AgentDropdownSelect, type AgentDropdownOption, } from "./agentConfigControls"; import { - BUZZ_AGENT_MAX_CONTEXT_TOKENS, - BUZZ_AGENT_MAX_OUTPUT_TOKENS, - BUZZ_AGENT_MAX_ROUNDS, BUZZ_AGENT_THINKING_EFFORT, BUZZ_AGENT_THINKING_EFFORT_VALUES, getProviderEffortConfig, @@ -201,6 +200,98 @@ export function useEffortAutoClear({ }, [effortValid, currentEffort]); } +export type { NumericDescriptor }; + +const NUMERIC_KIND_LABELS: Record = { + maxOutputTokens: "Max output tokens", + contextLimit: "Context limit", + maxRounds: "Max rounds", +}; + +const NUMERIC_KIND_DESCRIPTIONS: Record = { + maxOutputTokens: + "Maximum tokens the LLM may generate per response. Leave blank to inherit.", + contextLimit: + "Maximum context window tokens tracked before a handoff. Leave blank to inherit.", + maxRounds: + "Maximum LLM + tool-call rounds per turn. 0 = unlimited. Leave blank to inherit.", +}; + +const NUMERIC_KIND_TEST_IDS: Record = { + maxOutputTokens: "numeric-max-output-tokens-input", + contextLimit: "numeric-context-limit-input", + maxRounds: "numeric-max-rounds-input", +}; + +/** + * Input `min` attribute per numeric kind. + * + * - `maxOutputTokens` / `contextLimit`: minimum 1 — the buzz-agent runtime + * rejects 0 for these fields (crates/buzz-agent/src/config.rs:921-928). + * - `maxRounds`: 0 is valid (means unlimited). + */ +export const NUMERIC_KIND_MIN: Record = { + maxOutputTokens: 1, + contextLimit: 1, + maxRounds: 0, +}; + +/** + * Descriptor-driven numeric tuning inputs. + * + * Renders a grid of number inputs for every numeric descriptor in `descriptors`. + * Label and help text are keyed by descriptor kind — the same copy renders on + * both the global defaults surface and per-agent dialogs. + */ +export function NumericTuningFields({ + descriptors, + envVars, + inheritedEnvVars, + onEnvVarChange, +}: { + /** Numeric descriptors to render. Empty array → renders nothing. */ + descriptors: NumericDescriptor[]; + envVars: EnvVarsValue; + inheritedEnvVars: EnvVarsValue; + onEnvVarChange: (key: string, value: string) => void; +}) { + if (descriptors.length === 0) return null; + return ( +
+ {descriptors.map((d) => { + const key = d.currentPersistence.key; + const label = NUMERIC_KIND_LABELS[d.kind]; + const description = NUMERIC_KIND_DESCRIPTIONS[d.kind]; + const testId = NUMERIC_KIND_TEST_IDS[d.kind]; + const inheritedVal = inheritedEnvVars[key]; + return ( +
+ + onEnvVarChange(key, event.target.value)} + placeholder={numericTuningPlaceholder(inheritedVal)} + step="1" + type="number" + value={envVars[key] ?? ""} + /> +

+ {description} +

+
+ ); + })} +
+ ); +} + export function BuzzAgentModelTuningFields({ envVars, inheritedEnvVars, @@ -257,105 +348,6 @@ export function BuzzAgentModelTuningFields({ blank to inherit from the global or persona default.

- - {/* Max Rounds */} -
- - - onEnvVarChange(BUZZ_AGENT_MAX_ROUNDS, event.target.value) - } - placeholder={ - inheritedEnvVars[BUZZ_AGENT_MAX_ROUNDS] - ? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_ROUNDS]})` - : "Inherit (agent default)" - } - step="1" - type="number" - value={envVars[BUZZ_AGENT_MAX_ROUNDS] ?? ""} - /> -

- Maximum LLM + tool-call rounds per turn. 0 = unlimited. Leave blank - to inherit. -

-
- - {/* Max Output Tokens */} -
- - - onEnvVarChange(BUZZ_AGENT_MAX_OUTPUT_TOKENS, event.target.value) - } - placeholder={ - inheritedEnvVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS] - ? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS]})` - : "Inherit (agent default)" - } - step="1" - type="number" - value={envVars[BUZZ_AGENT_MAX_OUTPUT_TOKENS] ?? ""} - /> -

- Maximum tokens the LLM may generate per response. Leave blank to - inherit. -

-
- - {/* Context Limit */} -
- - - onEnvVarChange(BUZZ_AGENT_MAX_CONTEXT_TOKENS, event.target.value) - } - placeholder={ - inheritedEnvVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS] - ? `Inherit (${inheritedEnvVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS]})` - : "Inherit (agent default)" - } - step="1" - type="number" - value={envVars[BUZZ_AGENT_MAX_CONTEXT_TOKENS] ?? ""} - /> -

- Maximum context window tokens buzz-agent tracks before a handoff. - Leave blank to inherit. -

-
); diff --git a/desktop/src/features/agents/useAgentManagement.ts b/desktop/src/features/agents/useAgentManagement.ts index f4cdb895ef2..066f7949a9a 100644 --- a/desktop/src/features/agents/useAgentManagement.ts +++ b/desktop/src/features/agents/useAgentManagement.ts @@ -301,7 +301,11 @@ export function useAgentManagement() { ...createdAgentAttachment, isPending, runtimes: runtimesQuery.data ?? [], - runtimesLoading: runtimesQuery.isLoading, + runtimeCatalogStatus: runtimesQuery.isLoading + ? ("loading" as const) + : runtimesQuery.isError + ? ("error" as const) + : ("ready" as const), submitCreate, submitUpdate, dismiss, diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index af30728d6a7..cb188dd0089 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -957,6 +957,7 @@ export function UserProfilePanel({ personaToExportSnapshot={personaToExportSnapshot} resolvedPersona={resolvedPersona} runtimes={acpRuntimesQuery.data ?? []} + runtimesError={acpRuntimesQuery.isError} runtimesLoading={acpRuntimesQuery.isLoading} updateError={ updatePersonaMutation.error instanceof Error diff --git a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx index 2de2e2f7162..9fae1bd889c 100644 --- a/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePersonaDialogs.tsx @@ -29,6 +29,7 @@ export function UserProfilePersonaDialogs({ resolvedPersona, runtimes, runtimesLoading, + runtimesError = false, updateError, onCloseCardMint, onCloseDelete, @@ -50,6 +51,7 @@ export function UserProfilePersonaDialogs({ resolvedPersona: AgentPersona | undefined; runtimes: AcpRuntimeCatalogEntry[]; runtimesLoading: boolean; + runtimesError?: boolean; updateError: Error | null; onCloseCardMint: () => void; onCloseDelete: () => void; @@ -59,6 +61,11 @@ export function UserProfilePersonaDialogs({ onExportSnapshot: (persona: AgentPersona) => void; onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; }) { + const runtimeCatalogStatus = runtimesLoading + ? "loading" + : runtimesError + ? "error" + : ("ready" as const); return ( <> { if (!open) { onCloseDialog(); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 69e2e455ec6..bb56bc18e52 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -188,6 +188,9 @@ export type RawAcpRuntimeCatalogEntry = { model_env_var?: string | null; provider_env_var?: string | null; thinking_env_var?: string | null; + max_tokens_env_var?: string | null; + context_limit_env_var?: string | null; + max_rounds_env_var?: string | null; install_hint: string; install_instructions_url: string; can_auto_install: boolean; @@ -199,10 +202,7 @@ export type RawAcpRuntimeCatalogEntry = { auth_status: AuthStatus; login_hint?: string; source: "builtin" | "preset" | "custom"; - /** - * Definition-level env vars for `source: custom` entries. - * Omitted/absent for builtin and preset — skipped in Rust serialization when empty. - */ + /** Definition-level env vars for `source: custom` entries; absent for builtin/preset. */ definition_env?: Record; }; @@ -749,6 +749,9 @@ export function fromRawAcpRuntimeCatalogEntry( modelEnvVar: entry.model_env_var ?? null, providerEnvVar: entry.provider_env_var ?? null, thinkingEnvVar: entry.thinking_env_var ?? null, + maxTokensEnvVar: entry.max_tokens_env_var ?? null, + contextLimitEnvVar: entry.context_limit_env_var ?? null, + maxRoundsEnvVar: entry.max_rounds_env_var ?? null, installHint: entry.install_hint, installInstructionsUrl: entry.install_instructions_url, canAutoInstall: entry.can_auto_install, @@ -1024,9 +1027,8 @@ export type RuntimeFileConfigSubset = { }; /** - * Get the file-layer config for a runtime so dialogs can show - * "Set in goose config" instead of surfacing a false required-field marker. - * Returns `null` when the runtime has no config file or it cannot be parsed. + * Get the file-layer config for a runtime so dialogs can show "Set in goose config" instead of + * surfacing a false required-field marker. Returns `null` when unavailable or unparseable. */ export async function getRuntimeFileConfig( runtimeId: string, @@ -1040,13 +1042,9 @@ export async function getRuntimeFileConfig( } /** - * Return the key names of all non-empty baked build env vars. - * - * Internal (Block) builds bake provider credentials into the binary at compile - * time. This returns the *key names only* — never the values — so dialogs can - * treat them as satisfied without exposing secrets to the frontend. - * - * OSS builds return an empty array (no baked env). + * Return the key names of all non-empty baked build env vars. Internal (Block) builds bake + * provider credentials into the binary at compile time; this returns *key names only* (never + * values) so dialogs treat them as satisfied without exposing secrets. OSS builds return []. */ export async function getBakedBuildEnvKeys(): Promise { return invokeTauri("get_baked_build_env_keys"); diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d0e8ee0047f..fd2c71bcedb 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -517,6 +517,9 @@ export type AcpRuntimeCatalogEntry = { providerEnvVar: string | null; /** Environment variable used to apply thinking effort, when supported. */ thinkingEnvVar: string | null; + maxTokensEnvVar: string | null; + contextLimitEnvVar: string | null; + maxRoundsEnvVar: string | null; installHint: string; installInstructionsUrl: string; canAutoInstall: boolean; @@ -529,12 +532,7 @@ export type AcpRuntimeCatalogEntry = { authStatus: AuthStatus; /** Hint for completing authentication; null when not applicable or already logged in. */ loginHint: string | null; - /** - * Whether this entry is compiled into the app ("builtin"), a bundled preset - * ("preset" — PATH-probed, not editable/deletable), or loaded from a user - * JSON file in `custom_harnesses/` ("custom"). Controls editability in the - * UI — only "custom" entries can be edited or deleted. - */ + /** "builtin" (compiled in), "preset" (PATH-probed, not editable), or "custom" (user JSON). Controls UI editability. */ source: "builtin" | "preset" | "custom"; /** * Definition-level environment variables for `source: custom` entries. diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 6520dd760d4..9c6618c83a4 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -74,7 +74,7 @@ type MockCommandAvailability = { resolvedPath?: string | null; }; -type MockManagedAgentSeed = { +export type MockManagedAgentSeed = { pubkey: string; name: string; avatarUrl?: string | null; @@ -91,6 +91,8 @@ type MockManagedAgentSeed = { autoRestartOnConfigChange?: boolean; respondTo?: RawManagedAgent["respond_to"]; respondToAllowlist?: string[]; + /** Per-agent env vars seeded into the mock store. */ + envVars?: Record; }; type MockManagedAgentRuntimeSeed = { @@ -214,6 +216,8 @@ type E2eConfig = { /** Catalog responses for successive discovery calls. The final response repeats. */ acpRuntimesCatalogSequence?: RawAcpRuntimeCatalogEntry[][]; acpRuntimesDelayMs?: number; + /** When true, the catalog discovery call throws — simulates a failed query. */ + acpRuntimesError?: boolean; acpAuthMethods?: Record; acpAuthMethodsErrors?: Record; acpAuthMethodsError?: string; @@ -2086,6 +2090,24 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { const now = new Date().toISOString(); const status = seed.status ?? "stopped"; + // Resolve agent_command and agent_args from the well-known default catalog + // so the fixture mirrors real wire shape. Hardcoding ["acp"] for all runtimes + // is incorrect: buzz-agent ships with no default args. + const DEFAULT_RUNTIME_COMMAND: Record< + string, + { command: string; args: string[] } + > = { + goose: { command: "goose", args: ["acp"] }, + "buzz-agent": { command: "buzz-agent", args: [] }, + claude: { command: "claude", args: [] }, + codex: { command: "codex", args: [] }, + }; + const catalogEntry = seed.runtime + ? DEFAULT_RUNTIME_COMMAND[seed.runtime] + : undefined; + const agentCommand = catalogEntry?.command ?? seed.runtime ?? "goose"; + const agentArgs = catalogEntry?.args ?? ["acp"]; + return { pubkey: seed.pubkey, name: seed.name, @@ -2095,8 +2117,8 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { runtime: seed.runtime ?? null, relay_url: DEFAULT_RELAY_WS_URL, acp_command: "buzz-acp", - agent_command: "goose", - agent_args: ["acp"], + agent_command: agentCommand, + agent_args: agentArgs, mcp_command: "", turn_timeout_seconds: 320, idle_timeout_seconds: null, @@ -2105,7 +2127,7 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { system_prompt: null, avatar_url: seed.avatarUrl ?? null, model: null, - env_vars: {}, + env_vars: { ...(seed.envVars ?? {}) }, status, pid: status === "running" ? 42000 + mockManagedAgents.length : null, created_at: now, @@ -7165,6 +7187,28 @@ function withMockRuntimeConfigMetadata( : runtime.id === "goose" ? "GOOSE_THINKING_EFFORT" : null, + max_tokens_env_var: + "max_tokens_env_var" in runtime + ? runtime.max_tokens_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_MAX_OUTPUT_TOKENS" + : runtime.id === "goose" + ? "GOOSE_MAX_TOKENS" + : null, + context_limit_env_var: + "context_limit_env_var" in runtime + ? runtime.context_limit_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_MAX_CONTEXT_TOKENS" + : runtime.id === "goose" + ? "GOOSE_CONTEXT_LIMIT" + : null, + max_rounds_env_var: + "max_rounds_env_var" in runtime + ? runtime.max_rounds_env_var + : runtime.id === "buzz-agent" + ? "BUZZ_AGENT_MAX_ROUNDS" + : null, }; } @@ -7182,6 +7226,10 @@ async function handleDiscoverAcpRuntimes( }); } + if (config?.mock?.acpRuntimesError) { + throw new Error("Mocked catalog discovery failure"); + } + const afterInstallSequence = config?.mock?.acpRuntimesCatalogAfterInstallSequence; if (mockInstallCompleted && afterInstallSequence?.length) { diff --git a/desktop/tests/e2e/agent-numeric-tuning.spec.ts b/desktop/tests/e2e/agent-numeric-tuning.spec.ts new file mode 100644 index 00000000000..dc70c8b12e8 --- /dev/null +++ b/desktop/tests/e2e/agent-numeric-tuning.spec.ts @@ -0,0 +1,372 @@ +/** + * Playwright regression tests for the numeric tuning fields (max output tokens, + * context limit, max rounds) on both the global Agent Defaults surface and the + * per-agent Advanced section. + * + * Covers: + * 1. Global defaults Advanced shows numeric inputs for buzz-agent. + * 2. Global defaults Advanced hides numeric inputs for non-capable runtimes. + * 3. Per-agent Goose: saving a max-tokens value globally surfaces as + * Inherit () placeholder in the per-agent edit dialog. + * 4. Delayed catalog: while loading, saved tuning env vars stay visible + * as generic rows (not silently dropped); structured controls appear + * once the catalog settles. + * 5. Failed catalog: when discovery errors, saved tuning env vars remain + * visible as generic rows (never the "unsupported" empty state). + */ + +import { expect, test } from "@playwright/test"; +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +async function openAiDefaultsSettings(page: import("@playwright/test").Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-settings").click(); + await page.getByTestId("profile-popover-settings").click(); + await expect(page.getByTestId("settings-view")).toBeVisible(); + await page.getByTestId("settings-nav-agents").click(); + await expect(page.getByTestId("settings-global-agent-config")).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator(".animate-spin").first()).not.toBeVisible({ + timeout: 5_000, + }); +} + +async function openEditAgentDialog( + page: import("@playwright/test").Page, + agentName: string, +) { + await page.goto("/"); + await page.getByTestId("open-agents-view").click(); + + const agentButton = page.getByRole("button", { + name: `${agentName} agent profile`, + }); + await expect(agentButton).toBeVisible({ timeout: 10_000 }); + await agentButton.click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({ + timeout: 10_000, + }); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +test("global_advanced_buzz_agent_shows_all_numeric_controls", async ({ + page, +}) => { + // The mock bridge's withMockRuntimeConfigMetadata injects the numeric env var + // fields for buzz-agent. When buzz-agent is selected and Advanced is opened, + // all three numeric inputs must be visible. + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + id: "buzz-agent", + label: "Buzz Agent", + avatar_url: "", + availability: "available", + command: "buzz-agent", + binary_path: "/usr/local/bin/buzz-agent", + default_args: [], + mcp_command: null, + install_hint: "Ships with the Buzz desktop app.", + install_instructions_url: "https://github.com/block/buzz", + can_auto_install: false, + underlying_cli_path: null, + auth_status: { status: "not_applicable" }, + }, + ], + globalAgentConfig: { + env_vars: {}, + provider: "anthropic", + model: null, + preferred_runtime: "buzz-agent", + }, + }); + + await openAiDefaultsSettings(page); + + // Open the Advanced section. The settings card uses disclosure="full" (no + // animation wrapper), so we click the toggle and wait for content directly. + await page.getByTestId("global-agent-advanced-toggle").click(); + + // All three numeric inputs must be present for buzz-agent. + await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible( + { timeout: 5_000 }, + ); + await expect(page.getByTestId("numeric-context-limit-input")).toBeVisible(); + await expect(page.getByTestId("numeric-max-rounds-input")).toBeVisible(); +}); + +test("global_advanced_non_capable_runtime_hides_numeric_controls", async ({ + page, +}) => { + // Claude has no numeric tuning env vars (contextLimitEnvVar = null etc.). + // After selecting Claude, the Advanced section must show no numeric inputs. + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + id: "claude", + label: "Claude Code", + avatar_url: "", + availability: "available", + command: "/usr/local/bin/claude-agent", + binary_path: "/usr/local/bin/claude-agent", + default_args: ["acp"], + mcp_command: null, + install_hint: "Install via npm.", + install_instructions_url: "https://example.com", + can_auto_install: true, + underlying_cli_path: "/usr/local/bin/claude", + auth_status: { status: "logged_in" }, + }, + ], + globalAgentConfig: { + env_vars: {}, + provider: null, + model: null, + preferred_runtime: "claude", + }, + }); + + await openAiDefaultsSettings(page); + + await page.getByTestId("global-agent-advanced-toggle").click(); + + // No numeric inputs must render for a non-capable runtime. + await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount( + 0, + ); + await expect(page.getByTestId("numeric-context-limit-input")).toHaveCount(0); + await expect(page.getByTestId("numeric-max-rounds-input")).toHaveCount(0); +}); + +test("goose_per_agent_advanced_max_tokens_shows_inherited_global_placeholder", async ({ + page, +}) => { + // Save GOOSE_MAX_TOKENS = 16384 in the global Agent Defaults settings via + // the UI, then open a Goose agent's edit dialog. The max-output-tokens input + // must show "Inherit (16384)" — the globally-saved value surfaced via the + // inherited placeholder. + await installMockBridge(page, { + globalAgentConfig: { + env_vars: { ANTHROPIC_API_KEY: "sk-ant-test-key" }, + provider: "anthropic", + model: "claude-opus-4-5", + preferred_runtime: "goose", + }, + managedAgents: [ + { + pubkey: TEST_IDENTITIES.tyler.pubkey, + name: "Tyler Agent", + runtime: "goose", + status: "stopped", + channelNames: ["agents"], + }, + ], + }); + + // Step 1: open global defaults, expand Advanced, enter the max-tokens value. + await openAiDefaultsSettings(page); + await page.getByTestId("global-agent-advanced-toggle").click(); + await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible( + { timeout: 5_000 }, + ); + await page.getByTestId("numeric-max-output-tokens-input").click(); + await page + .getByTestId("numeric-max-output-tokens-input") + .pressSequentially("16384"); + // Blur to ensure React's change event fires for the number input. + await page.keyboard.press("Tab"); + + // Step 2: save the global defaults. + await expect(page.getByRole("button", { name: "Save defaults" })).toBeEnabled( + { timeout: 5_000 }, + ); + await page.getByRole("button", { name: "Save defaults" }).click(); + // Wait for the save to complete: the button returns to disabled (dirty resets). + await expect( + page.getByRole("button", { name: "Save defaults" }), + ).toBeDisabled({ timeout: 5_000 }); + + // Step 3: navigate back and open the per-agent edit dialog for the Goose + // agent. We use the app's Back link rather than page.goto("/") to preserve + // the in-memory mock state (page.goto causes a full reload that resets it). + await page.getByRole("button", { name: "Back to app" }).click(); + await page.getByTestId("open-agents-view").click(); + const agentButton = page.getByRole("button", { + name: "Tyler Agent agent profile", + }); + await expect(agentButton).toBeVisible({ timeout: 10_000 }); + await agentButton.click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible({ + timeout: 10_000, + }); + await page.getByTestId("user-profile-edit-agent").click(); + await expect(page.getByTestId("edit-agent-dialog")).toBeVisible({ + timeout: 10_000, + }); + + // Wait for the provider field — signals the catalog and dialog have settled. + await expect(page.locator("#edit-agent-llm-provider")).toBeVisible({ + timeout: 10_000, + }); + + // Open the Advanced section. + await page.getByRole("button", { name: "Advanced", exact: true }).click(); + await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible( + { timeout: 5_000 }, + ); + + // The placeholder must reflect the globally-saved value. + await expect( + page.getByTestId("numeric-max-output-tokens-input"), + ).toHaveAttribute("placeholder", "Inherit (16384)"); +}); + +test("delayed_catalog_per_agent_saved_tuning_values_visible_then_structured_controls_appear", async ({ + page, +}) => { + // Scenario: catalog takes 5 seconds to respond (simulates slow discovery). + // The per-agent edit dialog opens. While the catalog is still in flight, + // saved tuning env vars must not be dropped from view — they appear as + // generic env rows (no hiddenKeys applied yet). Once the catalog settles, + // the structured numeric controls replace the generic rows. + await installMockBridge(page, { + acpRuntimesCatalog: [ + { + id: "buzz-agent", + label: "Buzz Agent", + avatar_url: "", + availability: "available", + command: "buzz-agent", + binary_path: "/usr/local/bin/buzz-agent", + default_args: [], + mcp_command: null, + install_hint: "Ships with the Buzz desktop app.", + install_instructions_url: "https://github.com/block/buzz", + can_auto_install: false, + underlying_cli_path: null, + auth_status: { status: "not_applicable" }, + }, + ], + // 5-second delay: generous enough that the dialog opens and Advanced is + // expanded while the catalog query is still in-flight (navigation takes + // ~1-2 s), but short enough to keep the test under 30 s. + acpRuntimesDelayMs: 5000, + globalAgentConfig: { + env_vars: {}, + provider: "anthropic", + model: null, + preferred_runtime: "buzz-agent", + }, + managedAgents: [ + { + pubkey: TEST_IDENTITIES.tyler.pubkey, + name: "Tyler Agent", + runtime: "buzz-agent", + status: "stopped", + channelNames: ["agents"], + envVars: { + BUZZ_AGENT_MAX_OUTPUT_TOKENS: "4096", + BUZZ_AGENT_MAX_ROUNDS: "25", + }, + }, + ], + }); + + await openEditAgentDialog(page, "Tyler Agent"); + + // Open Advanced before the catalog has settled (the dialog opens quickly; + // the catalog query fires when the dialog opens and takes ~5 seconds). + await page.getByRole("button", { name: "Advanced", exact: true }).click(); + + // While loading: structured numeric controls must NOT be visible yet — + // the catalog-settling gate withholds them. + await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount( + 0, + ); + + // The saved tuning env vars must be visible as generic rows (not hidden) + // while the catalog hasn't settled: BUZZ_AGENT_MAX_OUTPUT_TOKENS and + // BUZZ_AGENT_MAX_ROUNDS should appear in the env-vars editor. + await expect( + page.locator( + 'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_OUTPUT_TOKENS"]', + ), + ).toBeVisible(); + await expect( + page.locator( + 'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_ROUNDS"]', + ), + ).toBeVisible(); + + // After the catalog settles (allow up to 8 s — 5 s delay + margin): + // structured controls appear, replacing the generic rows. + await expect(page.getByTestId("numeric-max-output-tokens-input")).toBeVisible( + { timeout: 8_000 }, + ); + await expect(page.getByTestId("numeric-max-rounds-input")).toBeVisible({ + timeout: 8_000, + }); +}); + +test("failed_catalog_per_agent_saved_tuning_values_remain_visible_as_generic_rows", async ({ + page, +}) => { + // Scenario: catalog discovery fails (network error / IPC rejection). + // The per-agent edit dialog opens. The saved tuning env vars must remain + // visible as generic rows — the error state must never produce the + // "unsupported" no-controls state that would hide persisted values. + await installMockBridge(page, { + acpRuntimesError: true, + globalAgentConfig: { + env_vars: {}, + provider: "anthropic", + model: null, + preferred_runtime: "buzz-agent", + }, + managedAgents: [ + { + pubkey: TEST_IDENTITIES.tyler.pubkey, + name: "Tyler Agent", + runtime: "buzz-agent", + status: "stopped", + channelNames: ["agents"], + envVars: { + BUZZ_AGENT_MAX_OUTPUT_TOKENS: "8192", + BUZZ_AGENT_MAX_ROUNDS: "10", + }, + }, + ], + }); + + await openEditAgentDialog(page, "Tyler Agent"); + + // Open Advanced after a brief wait (query has had time to fail). + await page.waitForTimeout(500); + await page.getByRole("button", { name: "Advanced", exact: true }).click(); + + // Structured numeric controls must NOT render (catalog errored — no runtime). + await expect(page.getByTestId("numeric-max-output-tokens-input")).toHaveCount( + 0, + ); + + // Saved tuning values must still be visible as generic env rows — the error + // state must never hide persisted values with no editor to replace them. + await expect( + page.locator( + 'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_OUTPUT_TOKENS"]', + ), + ).toBeVisible({ timeout: 5_000 }); + await expect( + page.locator( + 'input[data-testid="env-vars-key"][value="BUZZ_AGENT_MAX_ROUNDS"]', + ), + ).toBeVisible(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 830a82879a2..45766d25b6d 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -1,5 +1,6 @@ import type { Page } from "@playwright/test"; import type { ChannelTemplate, RelayEvent } from "../../src/shared/api/types"; +import type { MockManagedAgentSeed } from "../../src/testing/e2eBridge"; import { FEATURE_OVERRIDES_STORAGE_KEY, PREVIEW_FEATURE_IDS } from "./features"; export const TEST_IDENTITIES = { @@ -43,24 +44,6 @@ type MockCommandAvailability = { resolvedPath?: string | null; }; -type MockManagedAgentSeed = { - pubkey: string; - name: string; - personaId?: string | null; - status?: "running" | "stopped" | "deployed" | "not_deployed"; - channelNames?: string[]; - channelIds?: string[]; - backend?: - | { type: "local" } - | { type: "provider"; id: string; config: Record }; - lastError?: string | null; - lastErrorCode?: number | null; - needsRestart?: boolean; - autoRestartOnConfigChange?: boolean; - respondTo?: "owner-only" | "allowlist" | "anyone"; - respondToAllowlist?: string[]; -}; - type MockSearchProfileSeed = { pubkey: string; displayName: string | null; @@ -199,6 +182,8 @@ type MockBridgeOptions = { /** Catalog responses for successive discovery calls. The final response repeats. */ acpRuntimesCatalogSequence?: Record[][]; acpRuntimesDelayMs?: number; + /** When true, the mock catalog discovery command throws an error. */ + acpRuntimesError?: boolean; acpAuthMethods?: Record[] }>; acpAuthMethodsError?: string; /** When set, the `delete_custom_harness` mock command throws with this message. */ From ede8d22dd5b336f146e0a6d760fd9dff78a42613 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Mon, 3 Aug 2026 16:23:55 -0700 Subject: [PATCH 032/163] feat(mobile): bring channel menus to desktop parity (#3940) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Overview **Category:** improvement **User Impact:** Mobile users can now access consistent channel and DM actions from both the channel list and conversation header. **Problem:** Mobile channel menus exposed a narrower, inconsistent set of actions than desktop, and the available actions differed by entry point. **Solution:** This change introduces one reusable action sheet with a clear quick-action hierarchy, role-aware lifecycle controls, confirmations for consequential actions, and a deliberately narrower DM menu. ## Changes
File changes **mobile/lib/features/channels/channel_actions_sheet.dart** Adds the shared channel and DM action-sheet experience used by both entry points, including Star/Unstar and Read/Unread quick actions for channels, section movement, mute, management, inline copy actions, guarded lifecycle actions, confirmations, and a compact DM menu without quick actions. **mobile/lib/features/channels/channel_detail_page.dart** Routes the header ellipsis through the shared action sheet so the in-channel menu matches the channel-list experience, including for DMs. **mobile/lib/features/channels/channel_management_provider.dart** Adds archive and delete operations using the desktop-compatible relay event kinds and refreshes channel state after completion. **mobile/lib/features/channels/channels_page.dart** Makes the shared channel action-sheet entry point available to the channel-list implementation. **mobile/lib/features/channels/channels_page/channel_tile.dart** Replaces the tile-specific long-press menu with the reusable action sheet while preserving read state and section context. **mobile/test/features/channels/channel_actions_sheet_test.dart** Covers action hierarchy, owner/admin/member capability guards, loading and failure states, DM narrowing with no quick-action row, and inline copy actions. **mobile/test/features/channels/channel_detail_page_test.dart** Updates channel-header flows to exercise management through the new shared action sheet. **mobile/test/features/channels/channel_management_provider_test.dart** Verifies archive and delete event tags stay compatible with desktop behavior.
## Reproduction Steps 1. Run the mobile app and open a populated channel list. 2. Long-press a regular channel and verify the Star/Unstar and Read/Unread quick actions appear above Move to section…, Mute, Manage, Copy channel name, and Copy channel ID. 3. Choose either copy action and verify it copies the expected value. 4. Open a channel, tap the header ellipsis, and verify the same action sheet appears. 5. As an admin or owner, verify Archive appears; as an owner, verify Delete also appears. Confirm that lifecycle actions require confirmation. 6. Long-press or open the header menu for a DM and verify it has no quick-action row and starts with Mute, followed by Copy channel name and Copy channel ID. ## Screenshots ### Channel menu | Regular channel — Mark Unread | DM — no quick actions | Archive confirmation | |---|---|---| | ![Regular channel actions with Mark Unread](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-regular-channel-mark-unread.png) | ![DM actions without quick actions](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-dm-no-quick-actions.png) | ![Archive confirmation](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3940/final-archive-confirmation.png) | --------- Signed-off-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- .../channels/channel_actions_sheet.dart | 559 ++++++++++++++++++ .../channels/channel_detail_page.dart | 43 +- .../channels/channel_management_provider.dart | 44 ++ .../lib/features/channels/channels_page.dart | 1 + .../channels/channels_page/channel_tile.dart | 210 +------ .../channels/channel_actions_sheet_test.dart | 390 ++++++++++++ .../channels/channel_detail_page_test.dart | 45 +- .../channel_management_provider_test.dart | 22 + 8 files changed, 1082 insertions(+), 232 deletions(-) create mode 100644 mobile/lib/features/channels/channel_actions_sheet.dart create mode 100644 mobile/test/features/channels/channel_actions_sheet_test.dart diff --git a/mobile/lib/features/channels/channel_actions_sheet.dart b/mobile/lib/features/channels/channel_actions_sheet.dart new file mode 100644 index 00000000000..843ee992e6a --- /dev/null +++ b/mobile/lib/features/channels/channel_actions_sheet.dart @@ -0,0 +1,559 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/clipboard_utils.dart'; +import '../../shared/mentions/agent_identity_provider.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/buzz_loading_indicator.dart'; +import '../../shared/widgets/sheet_divider.dart'; +import 'channel.dart'; +import 'channel_management_provider.dart'; +import 'channel_mutes/channel_mutes_provider.dart'; +import 'channel_sections/channel_sections_provider.dart'; +import 'channel_stars/channel_stars_provider.dart'; +import 'channels_provider.dart'; +import 'manage_channel_sheet.dart'; +import 'read_state/read_state_provider.dart'; +import 'read_state/read_state_time.dart'; + +/// Opens the mobile channel actions sheet and returns whether its parent page +/// should close after a successful lifecycle action. +Future showChannelActionsSheet({ + required BuildContext context, + required Channel channel, + required bool isUnread, + VoidCallback? onMarkRead, + String? sectionId, +}) => showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + builder: (_) => ChannelActionsSheet( + channel: channel, + isUnread: isUnread, + onMarkRead: onMarkRead, + sectionId: sectionId, + ), +); + +/// Mobile action sheet for channel-level read, organization, and lifecycle +/// operations. +class ChannelActionsSheet extends ConsumerWidget { + const ChannelActionsSheet({ + super.key, + required this.channel, + required this.isUnread, + this.onMarkRead, + this.sectionId, + }); + + final Channel channel; + final bool isUnread; + final VoidCallback? onMarkRead; + final String? sectionId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMuted = + ref.watch(channelMutesProvider).store.channels[channel.id]?.muted == + true; + final isStarred = + !channel.isDm && + ref.watch(channelStarsProvider).store.channels[channel.id]?.starred == + true; + final membersAsync = channel.isDm + ? const AsyncValue>.data([]) + : ref.watch(channelMembersProvider(channel.id)); + final agentOwnersAsync = channel.isDm + ? const AsyncValue>.data({}) + : ref.watch(agentOwnersProvider); + final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase(); + final currentMember = membersAsync.value?.cast().firstWhere( + (member) => member?.pubkey.toLowerCase() == currentPubkey, + orElse: () => null, + ); + final ownsOwnerAgent = + currentPubkey != null && + membersAsync.value?.any( + (member) => + member.isOwner && + agentOwnersAsync.value?[member.pubkey.toLowerCase()] + ?.toLowerCase() == + currentPubkey, + ) == + true; + final canManageLifecycle = + currentMember?.isElevated == true || ownsOwnerAgent; + final canArchive = !channel.isArchived && canManageLifecycle; + final canUnarchive = channel.isArchived && canManageLifecycle; + final canDelete = + !channel.isArchived && + (currentMember?.isOwner == true || ownsOwnerAgent); + final lifecycleCapabilitiesLoading = + membersAsync.isLoading || agentOwnersAsync.isLoading; + final lifecycleCapabilitiesUnavailable = + membersAsync.hasError || agentOwnersAsync.hasError; + + void close() => Navigator.of(context).pop(); + + return SafeArea( + top: false, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!channel.isDm) ...[ + _ChannelQuickActionsRow( + isStarred: isStarred, + isUnread: isUnread, + onToggleStar: () { + close(); + final notifier = ref.read(channelStarsProvider.notifier); + isStarred + ? notifier.unstarChannel(channel.id) + : notifier.starChannel(channel.id); + }, + onToggleRead: () { + close(); + final timestamp = dateTimeToUnixSeconds( + channel.lastMessageAt, + ); + if (isUnread) { + onMarkRead?.call(); + if (timestamp != null) { + ref + .read(readStateProvider.notifier) + .markContextRead( + channel.id, + timestamp, + clearForcedMessages: true, + ); + ref + .read(channelsProvider.notifier) + .clearObservedUnreadCoveredByRead( + channel.id, + timestamp, + ); + } + } else { + ref + .read(readStateProvider.notifier) + .markContextUnread(channel.id, channelId: channel.id); + } + }, + ), + const SizedBox(height: Grid.xs), + ], + if (!channel.isDm) + ListTile( + leading: const Icon(LucideIcons.folderInput), + title: const Text('Move to section…'), + onTap: () async { + final pageContext = Navigator.of( + context, + rootNavigator: true, + ).context; + close(); + await _showMoveSectionSheet( + pageContext, + ref, + channel: channel, + sectionId: sectionId, + ); + }, + ), + ListTile( + leading: Icon(isMuted ? LucideIcons.bell : LucideIcons.bellOff), + title: Text(isMuted ? 'Unmute channel' : 'Mute channel'), + onTap: () { + close(); + final notifier = ref.read(channelMutesProvider.notifier); + isMuted + ? notifier.unmuteChannel(channel.id) + : notifier.muteChannel(channel.id); + }, + ), + if (!channel.isDm) + ListTile( + leading: const Icon(LucideIcons.settings), + title: const Text('Manage channel'), + onTap: () async { + final shouldClose = await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + constraints: BoxConstraints( + maxWidth: 640, + maxHeight: MediaQuery.sizeOf(context).height * 0.9, + ), + builder: (_) => ManageChannelSheet(channel: channel), + ); + if (shouldClose == true && context.mounted) { + Navigator.of(context).pop(true); + } + }, + ), + ListTile( + leading: const Icon(LucideIcons.copy), + title: const Text('Copy channel name'), + onTap: () { + close(); + copyToClipboard( + context, + channel.name, + message: 'Channel name copied to clipboard', + ); + }, + ), + ListTile( + leading: const Icon(LucideIcons.hash), + title: const Text('Copy channel ID'), + onTap: () { + close(); + copyToClipboard( + context, + channel.id, + message: 'Channel ID copied to clipboard', + ); + }, + ), + if (!channel.isDm) ...[ + const SheetDivider(), + if (channel.isMember && !channel.isArchived) + _ActionTile( + icon: LucideIcons.logOut, + label: 'Leave channel', + destructive: true, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Leave #${channel.name}?', + body: 'You’ll stop receiving messages from this channel.', + confirmLabel: 'Leave', + action: () => ref + .read(channelActionsProvider) + .leaveChannel(channel.id), + ), + ), + if (lifecycleCapabilitiesLoading) + const ListTile( + enabled: false, + leading: BuzzLoadingIndicator( + size: 20, + semanticLabel: 'Loading channel actions', + ), + title: Text('Loading channel actions…'), + ) + else if (lifecycleCapabilitiesUnavailable) + const ListTile( + enabled: false, + leading: Icon(LucideIcons.triangleAlert), + title: Text('Channel actions unavailable'), + ) + else ...[ + if (canArchive) + _ActionTile( + icon: LucideIcons.archive, + label: 'Archive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Archive #${channel.name}?', + body: 'The channel will become read-only.', + confirmLabel: 'Archive', + action: () => ref + .read(channelActionsProvider) + .archiveChannel(channel.id), + ), + ), + if (canUnarchive) + _ActionTile( + icon: LucideIcons.archiveRestore, + label: 'Unarchive channel', + onTap: () => _confirmAndRun( + context, + ref, + title: 'Unarchive #${channel.name}?', + body: 'The channel will become active again.', + confirmLabel: 'Unarchive', + action: () => ref + .read(channelActionsProvider) + .unarchiveChannel(channel.id), + ), + ), + if (canDelete) + _ActionTile( + icon: LucideIcons.trash2, + label: 'Delete channel', + destructive: true, + onTap: () => _confirmAndRun( + context, + ref, + title: 'Delete #${channel.name}?', + body: + 'This permanently deletes the channel and cannot be undone.', + confirmLabel: 'Delete', + action: () => ref + .read(channelActionsProvider) + .deleteChannel(channel.id), + ), + ), + ], + ], + ], + ), + ), + ); + } +} + +class _ChannelQuickActionsRow extends StatelessWidget { + const _ChannelQuickActionsRow({ + required this.isStarred, + required this.isUnread, + required this.onToggleStar, + required this.onToggleRead, + }); + + final bool isStarred; + final bool isUnread; + final VoidCallback onToggleStar; + final VoidCallback onToggleRead; + + @override + Widget build(BuildContext context) => Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ChannelQuickAction( + icon: isStarred ? LucideIcons.starOff : LucideIcons.star, + label: isStarred ? 'Unstar' : 'Star', + onTap: onToggleStar, + ), + _ChannelQuickAction( + icon: isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot, + label: isUnread ? 'Mark Read' : 'Mark Unread', + onTap: onToggleRead, + ), + ], + ); +} + +class _ChannelQuickAction extends StatelessWidget { + const _ChannelQuickAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 76, + height: 56, + alignment: Alignment.center, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + ), + child: Icon(icon, size: 24, color: context.colors.onSurface), + ), + const SizedBox(height: Grid.xxs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ); +} + +class _ActionTile extends StatelessWidget { + const _ActionTile({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + final bool destructive; + + @override + Widget build(BuildContext context) => ListTile( + leading: Icon(icon, color: destructive ? context.colors.error : null), + title: Text( + label, + style: destructive ? TextStyle(color: context.colors.error) : null, + ), + onTap: onTap, + ); +} + +Future _confirmAndRun( + BuildContext sheetContext, + WidgetRef ref, { + required String title, + required String body, + required String confirmLabel, + required Future Function() action, +}) async { + final pageContext = Navigator.of(sheetContext, rootNavigator: true).context; + final confirmed = await showDialog( + context: pageContext, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(body), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: Text(confirmLabel), + ), + ], + ), + ); + if (confirmed != true) return; + try { + await action(); + if (pageContext.mounted) Navigator.of(pageContext).pop(true); + } catch (error) { + if (!pageContext.mounted) return; + ScaffoldMessenger.of(pageContext).showSnackBar( + SnackBar( + content: Text( + 'Couldn’t ${confirmLabel.toLowerCase()} channel. Try again.', + ), + ), + ); + } +} + +Future _showMoveSectionSheet( + BuildContext context, + WidgetRef ref, { + required Channel channel, + required String? sectionId, +}) async { + final sections = [...ref.read(channelSectionsProvider).store.sections] + ..sort((a, b) => a.order.compareTo(b.order)); + await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final section in sections) + ListTile( + leading: const Icon(LucideIcons.folder), + title: Text(section.name), + trailing: sectionId == section.id + ? Icon( + LucideIcons.check, + color: sheetContext.colors.primary, + ) + : null, + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelSectionsProvider.notifier) + .assignChannel(channel.id, section.id); + }, + ), + ListTile( + leading: const Icon(LucideIcons.folderPlus), + title: const Text('New section…'), + onTap: () async { + Navigator.of(sheetContext).pop(); + final name = await _showSectionNameDialog(context); + if (name == null || name.isEmpty) return; + final notifier = ref.read(channelSectionsProvider.notifier); + notifier.createSection(name); + final created = ref + .read(channelSectionsProvider) + .store + .sections + .where((section) => section.name == name.trim()) + .lastOrNull; + if (created != null) { + notifier.assignChannel(channel.id, created.id); + } + }, + ), + if (sectionId != null) + ListTile( + leading: const Icon(LucideIcons.folderMinus), + title: const Text('Remove from section'), + onTap: () { + Navigator.of(sheetContext).pop(); + ref + .read(channelSectionsProvider.notifier) + .unassignChannel(channel.id); + }, + ), + ], + ), + ), + ), + ); +} + +Future _showSectionNameDialog(BuildContext context) async { + final controller = TextEditingController(); + final result = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('New Section'), + content: TextField(controller: controller, autofocus: true), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => + Navigator.of(dialogContext).pop(controller.text.trim()), + child: const Text('Create'), + ), + ], + ), + ); + controller.dispose(); + return result; +} diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f1efb1557f3..690e50905f8 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -25,9 +25,11 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../forum/forum_posts_view.dart'; import 'channel.dart'; +import 'channel_actions_sheet.dart'; import 'channel_link_navigation.dart'; import 'agent_activity/working_bots_provider.dart'; import 'channel_management_provider.dart'; +import 'channel_sections/channel_sections_provider.dart'; import 'channel_messages_provider.dart'; import 'channel_typing_provider.dart'; import 'channel_typing_indicator.dart'; @@ -38,7 +40,6 @@ import 'date_formatters.dart'; import 'day_divider.dart'; import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; -import 'manage_channel_sheet.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; @@ -277,27 +278,25 @@ class ChannelDetailPage extends HookConsumerWidget { channel: resolvedChannel, currentPubkey: currentPubkey, ), - if (!resolvedChannel.isDm) - IconButton( - color: context.colors.primary, - onPressed: () async { - final shouldClose = await showModalBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - constraints: BoxConstraints( - maxWidth: 640, - maxHeight: MediaQuery.sizeOf(context).height * 0.9, - ), - builder: (_) => ManageChannelSheet(channel: resolvedChannel), - ); - if (shouldClose == true && context.mounted) { - Navigator.of(context).pop(); - } - }, - tooltip: 'Manage channel', - icon: const Icon(LucideIcons.ellipsisVertical, size: 22), - ), + IconButton( + color: context.colors.primary, + onPressed: () async { + final shouldClose = await showChannelActionsSheet( + context: context, + channel: resolvedChannel, + isUnread: false, + sectionId: ref + .read(channelSectionsProvider) + .store + .assignments[resolvedChannel.id], + ); + if (shouldClose == true && context.mounted) { + Navigator.of(context).pop(); + } + }, + tooltip: 'Channel actions', + icon: const Icon(LucideIcons.ellipsisVertical, size: 22), + ), ], ), body: Stack( diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index b990194d159..ed9f6842f7b 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -479,6 +479,20 @@ List> buildCreateChannelTags({ ]; } +/// Builds the relay tags for setting the archived state of [channelId]. +List> buildSetChannelArchivedTags( + String channelId, { + required bool archived, +}) => [ + ['h', channelId], + ['archived', archived.toString()], +]; + +/// Builds the relay tags for deleting [channelId]. +List> buildDeleteChannelTags(String channelId) => [ + ['h', channelId], +]; + class ChannelActions { final Ref _ref; final RelaySessionNotifier _session; @@ -584,6 +598,36 @@ class ChannelActions { await _refreshChannelState(channelId); } + /// Archives the channel and refreshes its cached state. + Future archiveChannel(String channelId) => + _setChannelArchived(channelId, archived: true); + + /// Unarchives the channel and refreshes its cached state. + Future unarchiveChannel(String channelId) => + _setChannelArchived(channelId, archived: false); + + Future _setChannelArchived( + String channelId, { + required bool archived, + }) async { + await _signedEventRelay.submit( + kind: 9002, + content: '', + tags: buildSetChannelArchivedTags(channelId, archived: archived), + ); + await _refreshChannelState(channelId); + } + + /// Deletes the channel and refreshes its cached state. + Future deleteChannel(String channelId) async { + await _signedEventRelay.submit( + kind: 9008, + content: '', + tags: buildDeleteChannelTags(channelId), + ); + await _refreshChannelState(channelId); + } + Future setCanvas({ required String channelId, required String content, diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index ba5d4ebf9dc..607667e284d 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -29,6 +29,7 @@ import '../profile/user_cache_provider.dart'; import '../pairing/pairing_page.dart'; import '../pairing/pairing_provider.dart'; import 'channel.dart'; +import 'channel_actions_sheet.dart'; import 'channel_detail_page.dart'; import 'channel_management_provider.dart'; import 'dm_channel_labels.dart'; diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 551e3f8dd69..95d0b2c0be3 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -118,212 +118,12 @@ class _ChannelTile extends ConsumerWidget { } void _showChannelActions(BuildContext context, WidgetRef ref) { - showModalBottomSheet( + showChannelActionsSheet( context: context, - showDragHandle: true, - builder: (sheetContext) { - final sections = ref.read(channelSectionsProvider).store.sections - ..sort((a, b) => a.order.compareTo(b.order)); - final isStarred = - ref - .read(channelStarsProvider) - .store - .channels[channel.id] - ?.starred == - true; - - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon( - isStarred ? LucideIcons.starOff : LucideIcons.star, - ), - title: Text(isStarred ? 'Unstar channel' : 'Star channel'), - onTap: () { - Navigator.of(sheetContext).pop(); - if (isStarred) { - ref - .read(channelStarsProvider.notifier) - .unstarChannel(channel.id); - } else { - ref - .read(channelStarsProvider.notifier) - .starChannel(channel.id); - } - }, - ), - ListTile( - leading: const Icon(LucideIcons.folderInput), - title: const Text('Move to section'), - onTap: () async { - Navigator.of(sheetContext).pop(); - await _showMoveSectionSheet(context, ref, sections); - }, - ), - ListTile( - leading: Icon( - isMuted ? LucideIcons.bell : LucideIcons.bellOff, - ), - title: Text(isMuted ? 'Unmute channel' : 'Mute channel'), - onTap: () { - Navigator.of(sheetContext).pop(); - if (isMuted) { - ref - .read(channelMutesProvider.notifier) - .unmuteChannel(channel.id); - } else { - ref - .read(channelMutesProvider.notifier) - .muteChannel(channel.id); - } - }, - ), - ListTile( - leading: Icon( - isUnread ? LucideIcons.checkCheck : LucideIcons.circleDot, - ), - title: Text(isUnread ? 'Mark as read' : 'Mark as unread'), - onTap: () { - Navigator.of(sheetContext).pop(); - final ts = dateTimeToUnixSeconds(channel.lastMessageAt); - if (ts != null) { - if (isUnread) { - onMarkRead?.call(); - ref - .read(readStateProvider.notifier) - .markContextRead( - channel.id, - ts, - clearForcedMessages: true, - ); - ref - .read(channelsProvider.notifier) - .clearObservedUnreadCoveredByRead(channel.id, ts); - } else { - ref - .read(readStateProvider.notifier) - .markContextUnread( - channel.id, - channelId: channel.id, - ); - } - } - }, - ), - ], - ), - ), - ); - }, - ); - } - - Future _showMoveSectionSheet( - BuildContext context, - WidgetRef ref, - List sections, - ) async { - await showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - 0, - Grid.gutter, - Grid.xs, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final section in sections) - ListTile( - leading: Icon( - LucideIcons.folder, - color: sectionId == section.id - ? sheetContext.colors.primary - : null, - ), - title: Text(section.name), - trailing: sectionId == section.id - ? Icon( - LucideIcons.check, - color: sheetContext.colors.primary, - ) - : null, - onTap: () { - Navigator.of(sheetContext).pop(); - ref - .read(channelSectionsProvider.notifier) - .assignChannel(channel.id, section.id); - }, - ), - ListTile( - leading: const Icon(LucideIcons.folderPlus), - title: const Text('New section…'), - onTap: () async { - Navigator.of(sheetContext).pop(); - if (!context.mounted) return; - final name = await showDialog( - context: context, - builder: (_) => const _SectionNameDialog( - title: 'New Section', - confirmLabel: 'Create', - ), - ); - if (name != null && name.isNotEmpty) { - ref - .read(channelSectionsProvider.notifier) - .createSection(name); - // Assign after create — sections list has been mutated, - // re-read to find the new section by name. - final newSection = ref - .read(channelSectionsProvider) - .store - .sections - .lastWhere( - (s) => s.name == name.trim(), - orElse: () => const ChannelSection( - id: '', - name: '', - order: -1, - ), - ); - if (newSection.id.isNotEmpty) { - ref - .read(channelSectionsProvider.notifier) - .assignChannel(channel.id, newSection.id); - } - } - }, - ), - if (sectionId != null) - ListTile( - leading: const Icon(LucideIcons.folderMinus), - title: const Text('Remove from section'), - onTap: () { - Navigator.of(sheetContext).pop(); - ref - .read(channelSectionsProvider.notifier) - .unassignChannel(channel.id); - }, - ), - ], - ), - ), - ); - }, + channel: channel, + isUnread: isUnread, + onMarkRead: onMarkRead, + sectionId: sectionId, ); } } diff --git a/mobile/test/features/channels/channel_actions_sheet_test.dart b/mobile/test/features/channels/channel_actions_sheet_test.dart new file mode 100644 index 00000000000..e38a40d9a55 --- /dev/null +++ b/mobile/test/features/channels/channel_actions_sheet_test.dart @@ -0,0 +1,390 @@ +import 'dart:async'; + +import 'package:buzz/features/channels/channel.dart'; +import 'package:buzz/features/channels/channel_actions_sheet.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'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const _currentPubkey = 'me'; + +Channel _channel({String type = 'stream', bool isArchived = false}) => Channel( + id: 'channel-id', + name: type == 'dm' ? 'Alice' : 'general', + channelType: type, + visibility: 'open', + description: '', + createdBy: 'owner', + createdAt: DateTime(2025), + memberCount: 2, + isMember: true, + archivedAt: isArchived ? DateTime(2025, 1, 2) : null, +); + +Widget _app({ + required Channel channel, + required Future> Function() loadMembers, + bool isUnread = false, + AsyncValue> agentOwners = const AsyncValue.data( + {}, + ), + ChannelActions Function(Ref ref)? createChannelActions, + String? currentPubkey = _currentPubkey, +}) => ProviderScope( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => currentPubkey), + channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()), + agentOwnersProvider.overrideWithValue(agentOwners), + if (createChannelActions != null) + channelActionsProvider.overrideWith(createChannelActions), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: ChannelActionsSheet(channel: channel, isUnread: isUnread), + ), + ), +); + +Widget _modalApp({ + required Channel channel, + required Future> Function() loadMembers, + required ChannelActions Function(Ref ref) createChannelActions, +}) => ProviderScope( + overrides: [ + currentPubkeyProvider.overrideWith((ref) => _currentPubkey), + channelMembersProvider(channel.id).overrideWith((ref) => loadMembers()), + channelActionsProvider.overrideWith(createChannelActions), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => showChannelActionsSheet( + context: context, + channel: channel, + isUnread: false, + ), + child: const Text('Open actions'), + ), + ), + ), + ), +); + +void main() { + testWidgets('owner sees the complete regular-channel action set', ( + tester, + ) async { + await tester.pumpWidget( + _app( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + for (final label in [ + 'Star', + 'Mark Unread', + 'Move to section…', + 'Mute channel', + 'Manage channel', + 'Copy channel name', + 'Copy channel ID', + 'Leave channel', + 'Archive channel', + 'Delete channel', + ]) { + expect(find.text(label), findsOneWidget, reason: label); + } + + final moveTop = tester.getTopLeft(find.text('Move to section…')).dy; + final muteTop = tester.getTopLeft(find.text('Mute channel')).dy; + final manageTop = tester.getTopLeft(find.text('Manage channel')).dy; + final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy; + final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy; + expect(moveTop, lessThan(muteTop)); + expect(muteTop, lessThan(manageTop)); + expect(manageTop, lessThan(copyNameTop)); + expect(copyNameTop, lessThan(copyIdTop)); + }); + + testWidgets('unread channel uses the Mark Read label', (tester) async { + await tester.pumpWidget( + _app( + channel: _channel(), + isUnread: true, + loadMembers: () async => const [], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Mark Read'), findsOneWidget); + expect(find.text('Mark Unread'), findsNothing); + }); + + testWidgets('admin can archive but cannot delete', (tester) async { + await tester.pumpWidget( + _app( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'admin', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsOneWidget); + expect(find.text('Delete channel'), findsNothing); + }); + + testWidgets('verified owner agent grants archive and delete', (tester) async { + const agentPubkey = 'agent'; + await tester.pumpWidget( + _app( + channel: _channel(), + agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}), + loadMembers: () async => [ + ChannelMember( + pubkey: agentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsOneWidget); + expect(find.text('Delete channel'), findsOneWidget); + }); + + testWidgets('unresolved identity grants no lifecycle actions', ( + tester, + ) async { + await tester.pumpWidget( + _app( + channel: _channel(), + currentPubkey: null, + loadMembers: () async => [ + ChannelMember( + pubkey: 'ordinary-owner', + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Delete channel'), findsNothing); + }); + + testWidgets('archived owner can unarchive but cannot delete', (tester) async { + late _FakeChannelActions actions; + await tester.pumpWidget( + _app( + channel: _channel(isArchived: true), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'owner', + joinedAt: DateTime(2025), + ), + ], + createChannelActions: (ref) => actions = _FakeChannelActions(ref), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Unarchive channel'), findsOneWidget); + expect(find.text('Delete channel'), findsNothing); + + await tester.tap(find.text('Unarchive channel')); + await tester.pumpAndSettle(); + expect(find.text('Unarchive #general?'), findsOneWidget); + await tester.tap(find.widgetWithText(FilledButton, 'Unarchive')); + await tester.pumpAndSettle(); + + expect(actions.unarchivedChannelId, 'channel-id'); + }); + + testWidgets('owned non-owner agent grants no lifecycle actions', ( + tester, + ) async { + const agentPubkey = 'agent'; + await tester.pumpWidget( + _app( + channel: _channel(), + agentOwners: const AsyncValue.data({agentPubkey: _currentPubkey}), + loadMembers: () async => [ + ChannelMember( + pubkey: agentPubkey, + role: 'bot', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Delete channel'), findsNothing); + }); + + testWidgets('agent ownership loading keeps lifecycle actions pending', ( + tester, + ) async { + await tester.pumpWidget( + _app( + channel: _channel(), + agentOwners: const AsyncValue.loading(), + loadMembers: () async => const [], + ), + ); + await tester.pump(); + + expect(find.text('Loading channel actions…'), findsOneWidget); + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Delete channel'), findsNothing); + }); + + testWidgets('member sees neither owner action', (tester) async { + await tester.pumpWidget( + _app( + channel: _channel(), + loadMembers: () async => [ + ChannelMember( + pubkey: _currentPubkey, + role: 'member', + joinedAt: DateTime(2025), + ), + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Archive channel'), findsNothing); + expect(find.text('Delete channel'), findsNothing); + expect(find.text('Leave channel'), findsOneWidget); + }); + + testWidgets('shows loading and unavailable capability states', ( + tester, + ) async { + final pending = Completer>(); + await tester.pumpWidget( + _app(channel: _channel(), loadMembers: () => pending.future), + ); + await tester.pump(); + expect(find.text('Loading channel actions…'), findsOneWidget); + + pending.completeError(Exception('relay unavailable')); + await tester.pumpAndSettle(); + expect(find.text('Channel actions unavailable'), findsOneWidget); + }); + + testWidgets( + 'manage leave closes both nested sheets without popping the page', + (tester) async { + await tester.pumpWidget( + _modalApp( + channel: _channel(), + loadMembers: () async => const [], + createChannelActions: (ref) => _FakeChannelActions(ref), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open actions')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Manage channel')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Leave channel').last); + await tester.pumpAndSettle(); + + expect(find.byType(ChannelActionsSheet), findsNothing); + expect(find.byType(Scaffold), findsOneWidget); + }, + ); + + testWidgets('DM omits quick actions, then shows mute and copy rows', ( + tester, + ) async { + await tester.pumpWidget( + _app( + channel: _channel(type: 'dm'), + loadMembers: () async => const [], + ), + ); + await tester.pumpAndSettle(); + + for (final label in [ + 'Mute channel', + 'Copy channel name', + 'Copy channel ID', + ]) { + expect(find.text(label), findsOneWidget, reason: label); + } + for (final label in [ + 'Star', + 'Unstar', + 'Mark Unread', + 'Mark Read', + 'Move to section…', + 'Manage channel', + 'Leave channel', + 'Archive channel', + 'Delete channel', + ]) { + expect(find.text(label), findsNothing, reason: label); + } + + final muteTop = tester.getTopLeft(find.text('Mute channel')).dy; + final copyNameTop = tester.getTopLeft(find.text('Copy channel name')).dy; + final copyIdTop = tester.getTopLeft(find.text('Copy channel ID')).dy; + expect(muteTop, lessThan(copyNameTop)); + expect(copyNameTop, lessThan(copyIdTop)); + }); +} + +class _FakeChannelActions extends ChannelActions { + _FakeChannelActions(Ref ref) + : super( + ref: ref, + session: ref.read(relaySessionProvider.notifier), + signedEventRelay: SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: null, + ), + currentPubkey: _currentPubkey, + ); + + String? unarchivedChannelId; + + @override + Future leaveChannel(String channelId) async {} + + @override + Future unarchiveChannel(String channelId) async { + unarchivedChannelId = channelId; + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7f8d0774c35..4abf00f1e47 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -828,7 +828,14 @@ void main() { ); expect(find.text('Message…'), findsNothing); - await tester.tap(find.byTooltip('Manage channel')); + await tester.tap(find.byTooltip('Channel actions')); + await tester.pumpAndSettle(); + await tester.drag( + find.byType(SingleChildScrollView).last, + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel').last); await tester.pumpAndSettle(); await tester.tap(find.text('Join channel')); await tester.pumpAndSettle(); @@ -841,6 +848,27 @@ void main() { expect(find.text('Message #general'), findsOneWidget); }); + testWidgets('detail-header manage leave closes the detail page', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: const [], + createChannelActions: (ref) => _FakeChannelActions(ref), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byTooltip('Channel actions')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel').last); + await tester.pumpAndSettle(); + await tester.tap(find.text('Leave channel').last); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Channel actions'), findsNothing); + }); + testWidgets('keeps manage sheet dismissible with a long canvas', ( tester, ) async { @@ -860,11 +888,18 @@ void main() { ); await tester.pumpAndSettle(); - await tester.tap(find.byTooltip('Manage channel')); + await tester.tap(find.byTooltip('Channel actions')); + await tester.pumpAndSettle(); + await tester.drag( + find.byType(SingleChildScrollView).last, + const Offset(0, -300), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Manage channel').last); await tester.pumpAndSettle(); - final sheet = find.byType(BottomSheet); - expect(sheet, findsOneWidget); + final sheet = find.byType(BottomSheet).last; + expect(find.byType(BottomSheet), findsNWidgets(2)); expect(tester.getSize(sheet).height, lessThanOrEqualTo(720)); final sheetTop = tester.getTopLeft(sheet).dy; @@ -874,7 +909,7 @@ void main() { ); await tester.pumpAndSettle(); - expect(sheet, findsNothing); + expect(find.text('Manage channel'), findsOneWidget); }); testWidgets('shows empty state when no messages', (tester) async { diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index 4b31de18e84..89659f37e4d 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -200,6 +200,28 @@ void main() { }); }); + group('build channel lifecycle tags', () { + test('archive matches kind 9002 tags', () { + expect(buildSetChannelArchivedTags('channel-id', archived: true), [ + ['h', 'channel-id'], + ['archived', 'true'], + ]); + }); + + test('unarchive matches kind 9002 tags', () { + expect(buildSetChannelArchivedTags('channel-id', archived: false), [ + ['h', 'channel-id'], + ['archived', 'false'], + ]); + }); + + test('delete matches desktop kind 9008 tags', () { + expect(buildDeleteChannelTags('channel-id'), [ + ['h', 'channel-id'], + ]); + }); + }); + group('directory providers relay-config invalidation', () { NostrEvent profile(String pubkey, String name) => NostrEvent( id: '$pubkey-profile', From b29c8cdaa456307ecdd63e565de4beb14402128e Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 4 Aug 2026 01:16:09 +0100 Subject: [PATCH 033/163] feat(desktop): redesign the Huddle experience (#4281) ## Summary - open Huddles in a focused companion window with a clean handoff back to the in-app drawer and backing channel - redesign the participant film strip, sidebar control, transcript surface, and themed shell treatment - preserve microphone and device control across windows, start agent voice on the first reply, and show agent speaking activity in the film strip - give each agent a distinct session voice, beginning with the configured default, plus compact per-agent text-to-speech and voice controls - enroll only agents explicitly mentioned or deliberately added through an agent panel into the live Huddle roster - keep temporary Huddle channels out of the sidebar unless the user explicitly brings one into the main app - remove Huddle-only avatar policy badges and filter short silence or noise segments before speech-to-text posts ## Why The previous flow exposed the temporary channel as product UI, obscured who was present or speaking, and split transcript and audio state between the main and companion windows. This keeps backing channels as implementation details unless a user explicitly brings a Huddle into the app, while sharing the live conversation and audio lifecycle across both surfaces. Agent participants now join only after an explicit invitation, distinct voices make multi-agent Huddles easier to follow, and short microphone noise no longer becomes stray transcript messages. ## Validation - `pnpm check` - `pnpm build:e2e` - `pnpm exec playwright test tests/e2e/huddle-transcription.spec.ts --project=smoke` (13 passed) - Huddle sidebar visibility unit coverage (4 passed) - focused managed-agent and persona-mention E2E coverage (2 passed) - `pnpm test` (3,910 passed) - `cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings` - `cargo test --manifest-path desktop/src-tauri/Cargo.toml` (2,093 passed, 14 ignored; 3 diagnostics passed) --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- desktop/src-tauri/capabilities/default.json | 4 +- desktop/src-tauri/src/huddle/agent_voice.rs | 310 +++++ desktop/src-tauri/src/huddle/agents.rs | 177 ++- desktop/src-tauri/src/huddle/mod.rs | 77 +- desktop/src-tauri/src/huddle/pipeline.rs | 82 +- desktop/src-tauri/src/huddle/playout.rs | 121 +- desktop/src-tauri/src/huddle/state.rs | 16 +- desktop/src-tauri/src/huddle/stt.rs | 121 +- desktop/src-tauri/src/huddle/tts.rs | 123 +- desktop/src-tauri/src/huddle/tts_activity.rs | 45 + desktop/src-tauri/src/huddle/tts_settings.rs | 3 +- desktop/src-tauri/src/huddle/tts_tests.rs | 31 +- .../src/huddle/tts_voice_selection_tests.rs | 20 +- .../src/huddle/tts_voice_transition.rs | 58 +- desktop/src-tauri/src/huddle/window.rs | 67 ++ desktop/src-tauri/src/initial_window.rs | 67 ++ desktop/src-tauri/src/lib.rs | 111 +- desktop/src/app/App.tsx | 14 +- desktop/src/app/AppHuddleBar.tsx | 6 +- desktop/src/app/AppHuddleShell.tsx | 76 ++ desktop/src/app/AppShell.tsx | 580 ++++----- desktop/src/app/AppShellChannelSurface.tsx | 43 + desktop/src/app/BuzzThemeSurfaces.tsx | 17 +- desktop/src/app/LazySettingsScreen.tsx | 6 + .../app/huddleBackingChannelStorage.test.mjs | 33 + .../src/app/huddleBackingChannelStorage.ts | 36 + .../src/app/huddleChannelVisibility.test.mjs | 65 + desktop/src/app/huddleChannelVisibility.ts | 19 + .../src/app/navigation/useAppNavigation.ts | 3 + desktop/src/app/routes/ChannelRouteScreen.tsx | 6 + .../src/app/routes/channels.$channelId.tsx | 11 +- .../app/useAppShellDesktopNotifications.ts | 8 +- .../src/app/useAppShellLifecycleEffects.ts | 7 + desktop/src/app/useHuddlePresentation.ts | 444 +++++++ desktop/src/app/useSettingsShortcuts.ts | 4 +- desktop/src/features/agents/hooks.ts | 7 + desktop/src/features/channels/hooks.ts | 16 + .../channels/ui/ChannelMembersBar.tsx | 4 +- .../channels/ui/ChannelPane.helpers.ts | 14 + .../src/features/channels/ui/ChannelPane.tsx | 80 +- .../features/channels/ui/ChannelPane.types.ts | 2 + .../features/channels/ui/ChannelScreen.tsx | 82 +- .../ui/ChannelScreenLoadingFallback.tsx | 14 + .../channels/ui/useChannelPaneMessages.ts | 50 + .../channels/ui/useHuddleChannelMessages.ts | 72 ++ .../channels/ui/useHuddleReadMarker.ts | 89 ++ .../channels/ui/useHuddleThreadIsolation.ts | 24 + desktop/src/features/huddle/HuddleContext.tsx | 549 ++++++--- .../features/huddle/HuddleContext.types.ts | 40 + .../huddle/components/AgentVoiceMenu.tsx | 152 +++ .../huddle/components/HuddleAttachment.tsx | 26 +- .../features/huddle/components/HuddleBar.tsx | 346 +++--- .../components/HuddleProfileControl.tsx | 176 +++ .../huddle/components/HuddleRoomHeader.tsx | 126 ++ .../huddle/components/HuddleStartingView.tsx | 18 + .../components/HuddleTranscriptIntro.tsx | 22 + .../huddle/components/MicControls.tsx | 35 +- .../huddle/components/ParticipantList.tsx | 492 ++++++-- desktop/src/features/huddle/index.ts | 4 + .../src/features/huddle/lib/huddleWindow.ts | 23 + .../features/huddle/lib/ttsLiveMessages.ts | 16 +- .../features/huddle/lib/useAudioDevices.ts | 18 +- .../features/huddle/lib/useHuddlePttState.ts | 73 ++ .../huddle/lib/useHuddleSpeakerActivity.ts | 99 ++ .../features/huddle/lib/useTtsSubscription.ts | 138 ++- .../messages/lib/virtualizedTimelineItems.ts | 3 +- .../src/features/messages/ui/MessageRow.tsx | 11 +- .../messages/ui/MessageThreadPanel.tsx | 137 ++- .../features/messages/ui/MessageTimeline.tsx | 43 +- .../features/messages/ui/MessageTimestamp.tsx | 1 + .../messages/ui/TimelineMessageList.tsx | 46 +- .../messages/ui/useMentionSendFlow.ts | 18 + .../src/features/messages/useThreadReplies.ts | 94 +- desktop/src/features/notifications/hooks.ts | 2 + .../use-feed-desktop-notifications.ts | 4 +- .../onboarding/communityOnboarding.tsx | 15 +- .../src/features/sidebar/ui/AppSidebar.tsx | 24 +- .../features/sidebar/ui/AppSidebar.types.ts | 7 + .../src/features/sidebar/ui/CommunityRail.tsx | 2 +- desktop/src/main.tsx | 3 +- .../shared/api/relayChannelFilters.test.mjs | 7 +- desktop/src/shared/api/relayChannelFilters.ts | 13 +- .../src/shared/styles/globals/components.css | 65 +- desktop/src/shared/styles/globals/theme.css | 46 +- desktop/src/shared/useMessageDeepLinks.ts | 6 +- desktop/src/testing/e2eBridge.ts | 351 +++++- desktop/tests/e2e/community-rail.spec.ts | 9 + .../tests/e2e/huddle-transcription.spec.ts | 1053 +++++++++++++++-- desktop/tests/helpers/bridge.ts | 9 + 89 files changed, 6260 insertions(+), 1327 deletions(-) create mode 100644 desktop/src-tauri/src/huddle/agent_voice.rs create mode 100644 desktop/src-tauri/src/huddle/tts_activity.rs create mode 100644 desktop/src-tauri/src/huddle/window.rs create mode 100644 desktop/src-tauri/src/initial_window.rs create mode 100644 desktop/src/app/AppHuddleShell.tsx create mode 100644 desktop/src/app/AppShellChannelSurface.tsx create mode 100644 desktop/src/app/LazySettingsScreen.tsx create mode 100644 desktop/src/app/huddleBackingChannelStorage.test.mjs create mode 100644 desktop/src/app/huddleBackingChannelStorage.ts create mode 100644 desktop/src/app/huddleChannelVisibility.test.mjs create mode 100644 desktop/src/app/huddleChannelVisibility.ts create mode 100644 desktop/src/app/useHuddlePresentation.ts create mode 100644 desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx create mode 100644 desktop/src/features/channels/ui/useChannelPaneMessages.ts create mode 100644 desktop/src/features/channels/ui/useHuddleChannelMessages.ts create mode 100644 desktop/src/features/channels/ui/useHuddleReadMarker.ts create mode 100644 desktop/src/features/channels/ui/useHuddleThreadIsolation.ts create mode 100644 desktop/src/features/huddle/HuddleContext.types.ts create mode 100644 desktop/src/features/huddle/components/AgentVoiceMenu.tsx create mode 100644 desktop/src/features/huddle/components/HuddleProfileControl.tsx create mode 100644 desktop/src/features/huddle/components/HuddleRoomHeader.tsx create mode 100644 desktop/src/features/huddle/components/HuddleStartingView.tsx create mode 100644 desktop/src/features/huddle/components/HuddleTranscriptIntro.tsx create mode 100644 desktop/src/features/huddle/lib/huddleWindow.ts create mode 100644 desktop/src/features/huddle/lib/useHuddlePttState.ts create mode 100644 desktop/src/features/huddle/lib/useHuddleSpeakerActivity.ts create mode 100644 desktop/src/features/sidebar/ui/AppSidebar.types.ts diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 8835b29dec8..a2e09bcb333 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window", - "windows": ["main"], + "description": "Capability for the main window and trusted huddle companions", + "windows": ["main", "huddle-*"], "permissions": [ "core:default", "core:webview:allow-set-webview-zoom", diff --git a/desktop/src-tauri/src/huddle/agent_voice.rs b/desktop/src-tauri/src/huddle/agent_voice.rs new file mode 100644 index 00000000000..5232287d756 --- /dev/null +++ b/desktop/src-tauri/src/huddle/agent_voice.rs @@ -0,0 +1,310 @@ +//! Per-agent text-to-speech choices for one local huddle session. + +use std::collections::{BTreeMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +use crate::app_state::AppState; + +use super::{ + tts_settings::{ + pocket_voice_reference, resolve_voice_for_backend_in_registry, voice_registry, + VoiceRegistryEntry, POCKET_BACKEND_ID, + }, + HuddlePhase, HuddleState, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AgentVoiceSettings { + pub enabled: bool, + pub voice_key: String, +} + +struct AgentVoiceCatalog { + default_voice_key: String, + voices: Vec, +} + +fn catalog(app: &AppHandle, state: &AppState) -> Result { + let registry = voice_registry(app); + let settings = state + .huddle_audio + .tts + .lock() + .map_err(|error| format!("text-to-speech settings lock poisoned: {error}"))? + .clone(); + let voices: Vec<_> = registry + .iter() + .filter(|voice| { + voice.backend == POCKET_BACKEND_ID + && matches!(voice.availability.as_str(), "bundled" | "installed") + }) + .cloned() + .collect(); + let default_voice_key = resolve_voice_for_backend_in_registry( + &settings.voice_preferences, + POCKET_BACKEND_ID, + &voices, + )? + .key; + Ok(AgentVoiceCatalog { + default_voice_key, + voices, + }) +} + +fn stable_voice_index(agent_pubkey: &str, huddle_generation: u64, len: usize) -> usize { + let hash = agent_pubkey.bytes().fold( + 0xcbf2_9ce4_8422_2325_u64 ^ huddle_generation, + |hash, byte| hash.wrapping_mul(0x0000_0100_0000_01b3) ^ u64::from(byte), + ); + (hash as usize) % len +} + +pub(crate) fn sync_agent_voice_assignments( + huddle: &mut HuddleState, + agent_pubkeys: &[String], + default_voice_key: &str, + voices: &[VoiceRegistryEntry], +) -> bool { + let previous = huddle.agent_voice_settings.clone(); + let available_keys: Vec<_> = voices.iter().map(|voice| voice.key.clone()).collect(); + let available: HashSet<_> = available_keys.iter().cloned().collect(); + let agents: HashSet<_> = agent_pubkeys.iter().cloned().collect(); + huddle.agent_voice_settings.retain(|pubkey, settings| { + agents.contains(pubkey) && available.contains(&settings.voice_key) + }); + + let mut used: HashSet<_> = huddle + .agent_voice_settings + .values() + .map(|settings| settings.voice_key.clone()) + .collect(); + for (index, pubkey) in agent_pubkeys.iter().enumerate() { + if huddle.agent_voice_settings.contains_key(pubkey) { + continue; + } + let preferred = if index == 0 && !used.contains(default_voice_key) { + Some(default_voice_key.to_owned()) + } else { + let unused_alternates: Vec<_> = available_keys + .iter() + .filter(|key| key.as_str() != default_voice_key && !used.contains(*key)) + .cloned() + .collect(); + let unused: Vec<_> = available_keys + .iter() + .filter(|key| !used.contains(*key)) + .cloned() + .collect(); + let candidates = if unused_alternates.is_empty() { + if unused.is_empty() { + &available_keys + } else { + &unused + } + } else { + &unused_alternates + }; + (!candidates.is_empty()).then(|| { + candidates[stable_voice_index(pubkey, huddle.huddle_generation, candidates.len())] + .clone() + }) + }; + if let Some(voice_key) = preferred { + used.insert(voice_key.clone()); + huddle.agent_voice_settings.insert( + pubkey.clone(), + AgentVoiceSettings { + enabled: true, + voice_key, + }, + ); + } + } + huddle.agent_voice_settings != previous +} + +fn ensure_with_catalog( + huddle: &mut HuddleState, + catalog: &AgentVoiceCatalog, + extra_agent: Option<&str>, +) -> bool { + let mut agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + if let Some(pubkey) = extra_agent { + if !agents.iter().any(|agent| agent == pubkey) { + agents.push(pubkey.to_owned()); + } + } + sync_agent_voice_assignments(huddle, &agents, &catalog.default_voice_key, &catalog.voices) +} + +fn require_active_huddle(huddle: &HuddleState) -> Result<(), String> { + matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) + .then_some(()) + .ok_or_else(|| "No active huddle".to_owned()) +} + +#[tauri::command] +pub fn ensure_huddle_agent_voice_settings( + app: AppHandle, + state: State<'_, AppState>, +) -> Result, String> { + let catalog = catalog(&app, &state)?; + let (changed, settings) = { + let mut huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Ok(BTreeMap::new()); + } + let changed = ensure_with_catalog(&mut huddle, &catalog, None); + (changed, huddle.agent_voice_settings.clone()) + }; + if changed { + state.emit_huddle_state_changed(); + } + Ok(settings) +} + +#[tauri::command] +pub fn set_huddle_agent_tts_enabled( + agent_pubkey: String, + enabled: bool, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let catalog = catalog(&app, &state)?; + let settings = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey)); + let settings = huddle + .agent_voice_settings + .get_mut(&agent_pubkey) + .ok_or("Agent is not in the active huddle")?; + settings.enabled = enabled; + settings.clone() + }; + state.emit_huddle_state_changed(); + Ok(settings) +} + +#[tauri::command] +pub fn set_huddle_agent_voice( + agent_pubkey: String, + voice_key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let catalog = catalog(&app, &state)?; + if !catalog.voices.iter().any(|voice| voice.key == voice_key) { + return Err("The selected Pocket voice is not available on this device".to_owned()); + } + let settings = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + ensure_with_catalog(&mut huddle, &catalog, Some(&agent_pubkey)); + let settings = huddle + .agent_voice_settings + .get_mut(&agent_pubkey) + .ok_or("Agent is not in the active huddle")?; + settings.voice_key = voice_key; + settings.clone() + }; + state.emit_huddle_state_changed(); + Ok(settings) +} + +pub(crate) fn voice_reference_for_agent( + app: &AppHandle, + state: &AppState, + agent_pubkey: &str, +) -> Result, String> { + let catalog = catalog(app, state)?; + let (changed, settings) = { + let mut huddle = state.huddle()?; + require_active_huddle(&huddle)?; + let changed = ensure_with_catalog(&mut huddle, &catalog, Some(agent_pubkey)); + let settings = huddle.agent_voice_settings.get(agent_pubkey).cloned(); + (changed, settings) + }; + if changed { + state.emit_huddle_state_changed(); + } + let Some(settings) = settings else { + return Err("Agent is not in the active huddle".to_owned()); + }; + if !settings.enabled { + return Ok(None); + } + pocket_voice_reference(app, &[settings.voice_key]).map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::huddle::tts_settings::bundled_voice_registry; + + #[test] + fn first_agent_uses_default_and_additional_agents_are_distinct() { + let agents = vec!["first".to_owned(), "second".to_owned(), "third".to_owned()]; + let mut huddle = HuddleState { + huddle_generation: 9, + ..HuddleState::default() + }; + + assert!(sync_agent_voice_assignments( + &mut huddle, + &agents, + "pocket:vera", + &bundled_voice_registry(), + )); + + assert_eq!( + huddle.agent_voice_settings["first"].voice_key, + "pocket:vera" + ); + let distinct: HashSet<_> = huddle + .agent_voice_settings + .values() + .map(|settings| settings.voice_key.as_str()) + .collect(); + assert_eq!(distinct.len(), 3); + } + + #[test] + fn explicit_session_choices_survive_roster_resync() { + let agents = vec!["first".to_owned(), "second".to_owned()]; + let voices = bundled_voice_registry(); + let mut huddle = HuddleState::default(); + sync_agent_voice_assignments(&mut huddle, &agents, "pocket:mary", &voices); + huddle + .agent_voice_settings + .get_mut("second") + .unwrap() + .enabled = false; + huddle + .agent_voice_settings + .get_mut("second") + .unwrap() + .voice_key = "pocket:jane".into(); + + assert!(!sync_agent_voice_assignments( + &mut huddle, + &agents, + "pocket:mary", + &voices, + )); + assert_eq!( + huddle.agent_voice_settings["second"], + AgentVoiceSettings { + enabled: false, + voice_key: "pocket:jane".into(), + } + ); + } +} diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 2de22f99d8a..41a348d8889 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -9,14 +9,24 @@ //! when it receives the kind:9000 membership notification. Huddle-specific //! env vars (interrupt mode, custom system prompt) are a post-MVP enhancement. +use std::collections::HashSet; + use serde::Serialize; +use tauri::State; use uuid::Uuid; use crate::{ - app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles, + app_state::AppState, + events, + huddle::relay_api::{ + fetch_channel_members, fetch_channel_members_with_roles, validate_pubkey_hex, + MAX_HUDDLE_AGENTS, + }, relay::submit_event, }; +use super::{pipeline::start_auto_enabled_transcription, HuddlePhase}; + // ── Constants ───────────────────────────────────────────────────────────────── /// Voice-mode guidelines posted as kind:48106 (huddle guidelines) to the @@ -78,6 +88,21 @@ pub struct AgentAddResult { pub parent_error: Option, } +/// Result of reconciling channel agent additions into the active Huddle. +#[derive(Debug, Serialize)] +pub struct AgentHuddleSyncResult { + /// Whether `channel_id` belonged to the active Huddle. + pub matched_active_huddle: bool, + /// Agents newly enrolled in the Huddle's ephemeral channel. + pub added: Vec, +} + +// Multiple frontend mutation paths can observe the same membership addition +// (for example, the member hook and the mention send flow). Serialize native +// reconciliation so they share the first result instead of racing duplicate +// membership events through a relay read that has not caught up yet. +static AGENT_SYNC_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Add an agent to both the ephemeral and parent huddle channels. /// /// Returns `Err` only if the ephemeral-channel add fails (policy rejection or @@ -134,6 +159,156 @@ pub async fn add_agent_to_huddle( }) } +/// Reconcile explicitly added channel agents into the active Huddle. +/// +/// The source channel may be either the Huddle's parent or its ephemeral chat. +/// Existing ephemeral membership is hydrated first so a mention sent from the +/// Huddle chat does not publish a duplicate membership event. Missing agents +/// are added through the same parent + ephemeral path as the Add agent picker. +pub(crate) async fn sync_agents_for_active_huddle( + channel_id: &str, + agent_pubkeys: Vec, + state: &AppState, +) -> Result { + let mut seen = HashSet::new(); + let mut requested = Vec::new(); + for pubkey in agent_pubkeys { + let normalized = pubkey.to_ascii_lowercase(); + validate_pubkey_hex(&normalized)?; + if seen.insert(normalized.clone()) { + requested.push(normalized); + } + } + if requested.is_empty() { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let _sync_guard = AGENT_SYNC_LOCK.lock().await; + + let (ephemeral_channel_id, parent_channel_id, huddle_generation, state_agents) = { + let huddle = state.huddle()?; + if !matches!(huddle.phase, HuddlePhase::Connected | HuddlePhase::Active) { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let ephemeral_channel_id = huddle + .ephemeral_channel_id + .clone() + .ok_or("no ephemeral channel")?; + let parent_channel_id = huddle + .parent_channel_id + .clone() + .ok_or("no parent channel")?; + if channel_id != ephemeral_channel_id && channel_id != parent_channel_id { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: false, + added: Vec::new(), + }); + } + let state_agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone(); + ( + ephemeral_channel_id, + parent_channel_id, + huddle.huddle_generation, + state_agents, + ) + }; + + // Membership reads can lag a just-accepted write, so merge the relay view + // with local state instead of allowing a stale snapshot to remove agents. + let fresh_agents = fetch_channel_members(&ephemeral_channel_id, Some("bot"), state) + .await + .unwrap_or_default(); + let mut known_agents = HashSet::new(); + let mut merged_agents = Vec::new(); + for pubkey in state_agents.into_iter().chain(fresh_agents) { + let normalized = pubkey.to_ascii_lowercase(); + if known_agents.insert(normalized.clone()) { + merged_agents.push(normalized); + } + } + let missing: Vec = requested + .into_iter() + .filter(|pubkey| !known_agents.contains(pubkey)) + .collect(); + if known_agents.len() + missing.len() > MAX_HUDDLE_AGENTS { + return Err(format!( + "agent limit reached: {} requested with {} already present (max {})", + missing.len(), + known_agents.len(), + MAX_HUDDLE_AGENTS + )); + } + + let ephemeral_uuid = Uuid::parse_str(&ephemeral_channel_id).map_err(|e| e.to_string())?; + let parent_uuid = Uuid::parse_str(&parent_channel_id).map_err(|e| e.to_string())?; + let mut added = Vec::new(); + for pubkey in missing { + add_agent_to_huddle(ephemeral_uuid, parent_uuid, &pubkey, state).await?; + merged_agents.push(pubkey.clone()); + added.push(pubkey); + } + + let (roster_changed, transcription_auto_enabled) = { + let mut huddle = state.huddle()?; + if !huddle.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + return Ok(AgentHuddleSyncResult { + matched_active_huddle: true, + added, + }); + } + let mut roster_changed = false; + { + let mut current_agents = huddle + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()); + if *current_agents != merged_agents { + *current_agents = merged_agents.clone(); + roster_changed = true; + } + } + for pubkey in &merged_agents { + if !huddle.participants.contains(pubkey) { + huddle.participants.push(pubkey.clone()); + roster_changed = true; + } + } + ( + roster_changed, + huddle.maybe_auto_enable_transcription_for_agents(), + ) + }; + + if transcription_auto_enabled { + start_auto_enabled_transcription(state, &ephemeral_channel_id).await; + } else if roster_changed { + state.emit_huddle_state_changed(); + } + + Ok(AgentHuddleSyncResult { + matched_active_huddle: true, + added, + }) +} + +#[tauri::command] +pub async fn sync_agents_to_active_huddle( + channel_id: String, + agent_pubkeys: Vec, + state: State<'_, AppState>, +) -> Result { + sync_agents_for_active_huddle(&channel_id, agent_pubkeys, &state).await +} + fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool { members .iter() diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index 03264f80f4c..99337400c91 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -24,6 +24,7 @@ //! and drops them outside the lock (thread joins can block ~200ms). mod agent_tts_routing; +pub mod agent_voice; pub mod agents; pub mod audio_output; pub mod jitter; @@ -41,6 +42,7 @@ pub mod tts; pub mod tts_settings; mod tts_voice_import; mod tts_voice_registry; +mod window; pub mod wire; // ── Shared utilities ────────────────────────────────────────────────────────── @@ -68,6 +70,7 @@ pub(super) fn drain_until_shutdown( pub use state::{HuddleJoinInfo, HuddlePhase, HuddleState, VoiceInputMode}; pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; pub use tts_settings::set_tts_enabled; +pub use window::{close_huddle_companion, open_huddle_window}; // ── Imports ─────────────────────────────────────────────────────────────────── @@ -90,6 +93,7 @@ use relay_api::{ count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex, MAX_HUDDLE_AGENTS, }; +use window::close_huddle_window; fn normalize_huddle_channel_name(candidate: Option, fallback: &str) -> String { let normalized = candidate @@ -175,6 +179,7 @@ pub async fn start_huddle( parent_channel_id: String, member_pubkeys: Vec, channel_name: Option, + app: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { // Validate inputs at the Tauri boundary. @@ -198,6 +203,15 @@ pub async fn start_huddle( deduped }; + // Allocate the backing channel ID before the relay work starts. Publishing + // it with the Creating state lets the main webview open an immediate + // companion window while the channel and audio session are being prepared. + let ephemeral_uuid = Uuid::new_v4(); + let ephemeral_channel_id = ephemeral_uuid.to_string(); + let short_id = &ephemeral_channel_id[..8]; + let fallback_channel_name = format!("huddle-{short_id}"); + let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); + // Transition to Creating. let huddle_generation = { let mut hs = state.huddle()?; @@ -210,20 +224,16 @@ pub async fn start_huddle( let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Creating; hs.parent_channel_id = Some(parent_channel_id.clone()); + hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); generation }; - - let ephemeral_uuid = Uuid::new_v4(); - let ephemeral_channel_id = ephemeral_uuid.to_string(); - let short_id = &ephemeral_channel_id[..8]; - let fallback_channel_name = format!("huddle-{short_id}"); - let channel_name = normalize_huddle_channel_name(channel_name, &fallback_channel_name); + state.emit_huddle_state_changed(); // All steps wrapped so we can roll back on ANY failure, including step 1. // channel_was_created tracks whether we need to archive on rollback. let mut channel_was_created = false; - let result: Result, String> = async { + let result: Result<(Vec, String), String> = async { // 1. Create ephemeral channel. let create_builder = events::build_create_channel( ephemeral_uuid, @@ -265,14 +275,14 @@ pub async fn start_huddle( // 4. Emit HUDDLE_STARTED to parent channel. let started_builder = events::build_huddle_started(&parent_channel_id, &ephemeral_channel_id)?; - submit_event(started_builder, &state).await?; + let started_event = submit_event(started_builder, &state).await?; - Ok(successful_agents) + Ok((successful_agents, started_event.event_id)) } .await; match result { - Ok(successful_agents) => { + Ok((successful_agents, huddle_thread_event_id)) => { // 5. Store active state. let committed = { let mut hs = state.huddle()?; @@ -282,6 +292,7 @@ pub async fn start_huddle( hs.phase = HuddlePhase::Connected; hs.is_creator = true; hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + hs.huddle_thread_event_id = Some(huddle_thread_event_id); *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = successful_agents.clone(); hs.maybe_auto_enable_transcription_for_agents(); @@ -300,6 +311,7 @@ pub async fn start_huddle( }; if !committed { emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; + close_huddle_window(&app, &ephemeral_channel_id); return Err("huddle start was superseded".to_owned()); } @@ -311,6 +323,7 @@ pub async fn start_huddle( match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { Ok(PostConnectOutcome::Ready) => {} Ok(PostConnectOutcome::Stale) => { + close_huddle_window(&app, &ephemeral_channel_id); return Err("huddle start was superseded".to_owned()); } Err(e) => { @@ -330,6 +343,7 @@ pub async fn start_huddle( } state.emit_huddle_state_changed(); } + close_huddle_window(&app, &ephemeral_channel_id); return Err(e); } } @@ -350,10 +364,19 @@ pub async fn start_huddle( } } // Reset only if this failed attempt still owns the Creating state. - if let Ok(mut hs) = state.huddle_state.lock() { + let reset = if let Ok(mut hs) = state.huddle_state.lock() { if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { hs.reset_preserving_generation(); + true + } else { + false } + } else { + false + }; + if reset { + state.emit_huddle_state_changed(); + close_huddle_window(&app, &ephemeral_channel_id); } Err(e) } @@ -372,6 +395,7 @@ pub async fn start_huddle( pub async fn join_huddle( parent_channel_id: String, ephemeral_channel_id: String, + huddle_thread_event_id: Option, state: State<'_, AppState>, ) -> Result { // Transition to Connecting. @@ -387,6 +411,7 @@ pub async fn join_huddle( hs.phase = HuddlePhase::Connecting; hs.parent_channel_id = Some(parent_channel_id.clone()); hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + hs.huddle_thread_event_id = huddle_thread_event_id; generation }; @@ -557,7 +582,7 @@ async fn remove_huddle_agents(ephemeral_channel_id: &str, state: &AppState) { /// /// The relay emits kind:48102 (participant left) when the audio WS disconnects. #[tauri::command] -pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { +pub async fn leave_huddle(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> { let (parent_channel_id, ephemeral_channel_id) = { let mut hs = state.huddle()?; if hs.phase == HuddlePhase::Idle { @@ -606,6 +631,7 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { } teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -618,7 +644,11 @@ pub async fn leave_huddle(state: State<'_, AppState>) -> Result<(), String> { /// 3. Shut down the STT pipeline (Fix 5). /// 4. Clear local huddle state. #[tauri::command] -pub async fn end_huddle(force: Option, state: State<'_, AppState>) -> Result<(), String> { +pub async fn end_huddle( + force: Option, + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { let (parent_channel_id, ephemeral_channel_id) = { let mut hs = state.huddle()?; if hs.phase == HuddlePhase::Idle { @@ -641,6 +671,7 @@ pub async fn end_huddle(force: Option, state: State<'_, AppState>) -> Resu emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; teardown_huddle(&state)?; + close_huddle_window(&app, &ephemeral_channel_id); Ok(()) } @@ -767,6 +798,8 @@ pub fn get_model_status(_state: State<'_, AppState>) -> Result, ) -> Result<(), String> { eprintln!("buzz-desktop: tts stage=invoke status=started route_id={route_id}"); @@ -774,6 +807,22 @@ pub async fn speak_agent_message( // Use char count (not byte length) to avoid panicking on multi-byte UTF-8. let text = normalize_agent_tts_text(text); + if !state.huddle()?.tts_enabled { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=disabled route_id={route_id}" + ); + return Ok(()); + } + + let Some(voice_reference) = + agent_voice::voice_reference_for_agent(&app, &state, &speaker_pubkey)? + else { + eprintln!( + "buzz-desktop: tts stage=invoke status=no_op reason=agent_disabled route_id={route_id}" + ); + return Ok(()); + }; + let needs_pipeline = { let mut hs = state.huddle()?; if hs @@ -831,7 +880,7 @@ pub async fn speak_agent_message( }; enqueue_agent_tts_text(route_id, text, move |route_id, text| { sender - .send(route_id, text) + .send(route_id, speaker_pubkey, voice_reference, text) .map_err(|error| format!("TTS queue closed while waiting to enqueue: {error}")) }) .await diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index fba5464a69f..9572ac25bf0 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -82,7 +82,7 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S .map(|m| m.take_tts_ready()) .unwrap_or(false); - // Start TTS first (so STT can capture tts_cancel). + // Start TTS first so STT can observe its active-playback gate. 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}"); @@ -130,25 +130,45 @@ pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), S .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 - }; + let (roster_changed, 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(()); + } + let mut roster_changed = false; + if let Some(agents) = fresh_agents { + let mut current_agents = + hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); + if *current_agents != agents { + *current_agents = agents; + roster_changed = true; + } + } + if let Some(members) = fresh_members { + if hs.participants != members { + hs.participants = members; + roster_changed = true; + } + } + hs.last_agent_refresh = Some(std::time::Instant::now()); + ( + roster_changed, + hs.maybe_auto_enable_transcription_for_agents(), + ) + } else { + (false, false) + }; if transcription_auto_enabled { start_auto_enabled_transcription(&state, eph_id).await; } + // Audio authentication auto-adds a joining human to the ephemeral + // channel. Emit whenever that authoritative roster changes so the + // desktop participant strip updates immediately instead of waiting + // for its slow fallback IPC read. + if roster_changed || transcription_auto_enabled { + state.emit_huddle_state_changed(); + } } } @@ -173,23 +193,32 @@ pub(crate) async fn post_connect_setup( fetch_channel_members(ephemeral_channel_id, Some("bot"), state), fetch_channel_members(ephemeral_channel_id, None, state), ); - let transcription_auto_enabled = { + let (roster_changed, transcription_auto_enabled) = { let mut hs = state.huddle()?; if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { return Ok(PostConnectOutcome::Stale); } + let mut roster_changed = false; if let Ok(agents) = agents_result { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + let mut current_agents = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); + if *current_agents != agents { + *current_agents = agents; + roster_changed = true; + } } if let Ok(all_members) = all_members_result { - if !all_members.is_empty() { + if !all_members.is_empty() && hs.participants != all_members { hs.participants = all_members; + roster_changed = true; } } - hs.maybe_auto_enable_transcription_for_agents() + ( + roster_changed, + hs.maybe_auto_enable_transcription_for_agents(), + ) }; - if transcription_auto_enabled { + if roster_changed || transcription_auto_enabled { state.emit_huddle_state_changed(); } @@ -281,7 +310,6 @@ pub(crate) async fn maybe_start_stt_pipeline( // the worker thread (~200ms) and must not block under the mutex. let ( tts_active, - tts_cancel, agent_pubkeys_arc, session_gen, expected_generation, @@ -312,7 +340,6 @@ pub(crate) async fn maybe_start_stt_pipeline( }; ( Arc::clone(&hs.tts_active), - Some(Arc::clone(&hs.tts_cancel)), Arc::clone(&hs.agent_pubkeys), Arc::clone(&hs.session_generation), hs.session_generation.load(Ordering::Acquire), @@ -325,7 +352,7 @@ pub(crate) async fn maybe_start_stt_pipeline( drop(old_stt); let constructed = tokio::task::spawn_blocking(move || { - stt::SttPipeline::new(model_dir, tts_active, tts_cancel, ptt_active_for_stt) + stt::SttPipeline::new(model_dir, tts_active, ptt_active_for_stt) }) .await; let (pipeline, text_rx) = match constructed { @@ -421,8 +448,8 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result super::tts_settings::pocket_voice_reference(&app, &voice_preferences)?, + let initial_voice = match app.as_ref() { + Some(app) => super::tts_settings::pocket_voice_reference(app, &voice_preferences)?, None => super::tts_settings::bundled_pocket_voice_reference(&voice_preferences), }; @@ -458,6 +485,7 @@ pub(crate) async fn maybe_start_tts_pipeline(state: &AppState) -> Result f32 { + ((f32::from(level_dbov) + 60.0) / 48.0).clamp(0.0, 1.0) +} + +fn should_recover_playout(depth: usize, currently_recovering: bool) -> bool { + if currently_recovering { + depth > PLAYOUT_QUEUE_RECOVERY_END + } else { + depth >= PLAYOUT_QUEUE_RECOVERY_START + } +} /// One remote peer's slot: jitter buffer + dedicated rodio Player. /// @@ -87,6 +105,7 @@ struct PeerSlot { /// by the playout tick to decide whether to keep draining NetEq into the /// Player. Updated on every successful `insert_packet`. last_packet_at: tokio::time::Instant, + recovering_playout: bool, } impl PeerSlot { @@ -96,6 +115,7 @@ impl PeerSlot { jitter, player: rodio::Player::connect_new(sink_mixer), last_packet_at: tokio::time::Instant::now(), + recovering_playout: false, }), Err(e) => { eprintln!("buzz-desktop: jitter buffer init peer {peer_idx}: {e}"); @@ -121,6 +141,19 @@ impl PeerSlot { fn is_active(&self) -> bool { self.last_packet_at.elapsed() < IDLE_PEER_GRACE || !self.jitter.is_empty() } + + fn update_playout_recovery(&mut self) { + let should_recover = should_recover_playout(self.player.len(), self.recovering_playout); + if should_recover == self.recovering_playout { + return; + } + self.recovering_playout = should_recover; + self.player.set_speed(if should_recover { + PLAYOUT_RECOVERY_SPEED + } else { + 1.0 + }); + } } /// Drive the receive loop until cancelled or the WS closes. @@ -149,12 +182,16 @@ pub(crate) async fn run_playout_recv_loop( let mut index_to_pubkey: std::collections::HashMap = initial_peers.into_iter().collect(); let mut active_indices: std::collections::HashSet = std::collections::HashSet::new(); + let mut speaker_levels: std::collections::HashMap = std::collections::HashMap::new(); let mut frame_counts: std::collections::HashMap = std::collections::HashMap::new(); let mut last_frame_reset = tokio::time::Instant::now(); let mut tts_was_active = false; let mut speaker_tick = tokio::time::interval(std::time::Duration::from_millis(SPEAKER_TICK_MS)); speaker_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut speaker_level_tick = + tokio::time::interval(std::time::Duration::from_millis(SPEAKER_LEVEL_TICK_MS)); + speaker_level_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let mut playout_tick = tokio::time::interval(std::time::Duration::from_millis(PLAYOUT_TICK_MS)); // `Delay` (not `Skip`) so a brief stall in another select arm — e.g. the // ws_tx_for_pongs mutex contending with the encode-side task on a Ping — @@ -187,15 +224,14 @@ pub(crate) async fn run_playout_recv_loop( } match slot.jitter.get_audio() { Ok((samples, _vad)) => { - // Bound producer-vs-device-clock drift. If our - // tokio tick has gotten ahead of the audio - // callback's actual consumption rate, drop the - // oldest queued frame rather than letting the - // queue grow without bound. - if slot.player.len() >= PLAYOUT_QUEUE_HIGH_WATER { + // Smooth out producer-vs-device clock drift. A + // shallow hard drop used to remove entire 10 ms + // chunks and create audible discontinuities. + slot.update_playout_recovery(); + if slot.player.len() >= PLAYOUT_QUEUE_EMERGENCY_HIGH_WATER { eprintln!( - "buzz-desktop: playout queue high-water for peer {peer_idx} \ - (depth={}) — dropping oldest frame", + "buzz-desktop: playout queue emergency high-water for peer \ + {peer_idx} (depth={}) — dropping oldest frame", slot.player.len(), ); slot.player.skip_one(); @@ -221,6 +257,22 @@ pub(crate) async fn run_playout_recv_loop( } active_indices.clear(); } + _ = speaker_level_tick.tick() => { + if let Some(ref app) = app_handle { + use tauri::Emitter; + let levels: std::collections::HashMap = speaker_levels + .iter() + .filter_map(|(idx, level)| { + index_to_pubkey.get(idx).cloned().map(|pubkey| (pubkey, *level)) + }) + .collect(); + let _ = app.emit("huddle-speaker-levels", &levels); + } + for level in speaker_levels.values_mut() { + *level *= 0.55; + } + speaker_levels.retain(|_, level| *level > 0.015); + } msg = ws_rx.next() => { match msg { Some(Ok(WsMsg::Binary(data))) => { @@ -253,6 +305,11 @@ pub(crate) async fn run_playout_recv_loop( // make their tile flash for the 500 ms speaker tick. if !is_dtx { active_indices.insert(peer_idx); + let level = normalized_speaker_level(header.level_dbov); + speaker_levels + .entry(peer_idx) + .and_modify(|current| *current = current.max(level)) + .or_insert(level); } // TTS interrupt frame counter — reset on TTS rising edge. @@ -328,6 +385,7 @@ pub(crate) async fn run_playout_recv_loop( peers.remove(&key); frame_counts.remove(&key); active_indices.remove(&key); + speaker_levels.remove(&key); } index_to_pubkey.insert(key, pk.to_string()); } @@ -351,6 +409,7 @@ pub(crate) async fn run_playout_recv_loop( peers.retain(|idx, _| identity_unchanged(idx)); frame_counts.retain(|idx, _| identity_unchanged(idx)); active_indices.retain(identity_unchanged); + speaker_levels.retain(|idx, _| identity_unchanged(idx)); index_to_pubkey = replacement; } } @@ -359,6 +418,8 @@ pub(crate) async fn run_playout_recv_loop( let key = idx as u8; index_to_pubkey.remove(&key); frame_counts.remove(&key); + active_indices.remove(&key); + speaker_levels.remove(&key); // Dropping Player detaches its queue from the // device mixer, freeing the per-peer slot. peers.remove(&key); @@ -379,4 +440,34 @@ pub(crate) async fn run_playout_recv_loop( } } } + + if let Some(ref app) = app_handle { + use tauri::Emitter; + let _ = app.emit( + "huddle-speaker-levels", + &std::collections::HashMap::::new(), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn speaker_level_maps_conversational_range() { + assert_eq!(normalized_speaker_level(-127), 0.0); + assert_eq!(normalized_speaker_level(-60), 0.0); + assert!((normalized_speaker_level(-36) - 0.5).abs() < f32::EPSILON); + assert_eq!(normalized_speaker_level(-12), 1.0); + assert_eq!(normalized_speaker_level(0), 1.0); + } + + #[test] + fn playout_recovery_uses_hysteresis() { + assert!(!should_recover_playout(9, false)); + assert!(should_recover_playout(10, false)); + assert!(should_recover_playout(5, true)); + assert!(!should_recover_playout(4, true)); + } } diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 37eb3533f6b..0fe3a46f5aa 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -4,11 +4,13 @@ //! phase enum, voice input mode, and response types. use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, }; +use super::agent_voice::AgentVoiceSettings; use super::{stt, tts}; /// Voice input mode: push-to-talk (PTT) or voice-activity detection (VAD). @@ -18,8 +20,9 @@ use super::{stt, tts}; /// (after a 200 ms delay) stops mic capture and flushes the utterance. /// /// VAD (default): the earshot VAD runs continuously and speech is accumulated -/// whenever the probability exceeds the threshold. Barge-in is enabled in this -/// mode. +/// whenever the probability exceeds the threshold. While local TTS is playing, +/// mic frames are discarded because VAD has no echo reference with which to +/// distinguish the app's own playback from a human interruption. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "snake_case")] pub enum VoiceInputMode { @@ -44,6 +47,9 @@ pub struct HuddleState { pub phase: HuddlePhase, pub parent_channel_id: Option, pub ephemeral_channel_id: Option, + /// Root event for the huddle's visible parent-channel thread. Transcript + /// messages reply here while audio coordination stays ephemeral. + pub huddle_thread_event_id: Option, /// Cancellation token for the audio relay WS task. #[serde(skip)] pub audio_ws_cancel: Option, @@ -67,6 +73,8 @@ pub struct HuddleState { deserialize_with = "deserialize_agent_pubkeys" )] pub agent_pubkeys: Arc>>, + /// Local, huddle-scoped playback choices for each participating agent. + pub agent_voice_settings: BTreeMap, /// Active STT pipeline — not serialized, not cloned. #[serde(skip)] pub stt_pipeline: Option>, @@ -161,10 +169,12 @@ impl Clone for HuddleState { phase: self.phase.clone(), parent_channel_id: self.parent_channel_id.clone(), ephemeral_channel_id: self.ephemeral_channel_id.clone(), + huddle_thread_event_id: self.huddle_thread_event_id.clone(), audio_ws_cancel: None, // Never clone handles. audio_relay_pcm_tx: None, // Never clone handles. participants: self.participants.clone(), agent_pubkeys: Arc::new(Mutex::new(agent_pubkeys_snapshot)), + agent_voice_settings: self.agent_voice_settings.clone(), stt_pipeline: None, // Never clone the pipeline handle. tts_pipeline: None, // Never clone the pipeline handle. is_creator: self.is_creator, @@ -190,10 +200,12 @@ impl Default for HuddleState { phase: HuddlePhase::Idle, parent_channel_id: None, ephemeral_channel_id: None, + huddle_thread_event_id: None, audio_ws_cancel: None, audio_relay_pcm_tx: None, participants: Vec::new(), agent_pubkeys: Arc::new(Mutex::new(Vec::new())), + agent_voice_settings: BTreeMap::new(), stt_pipeline: None, tts_pipeline: None, is_creator: false, diff --git a/desktop/src-tauri/src/huddle/stt.rs b/desktop/src-tauri/src/huddle/stt.rs index 6f502ca72cb..30a47f449a7 100644 --- a/desktop/src-tauri/src/huddle/stt.rs +++ b/desktop/src-tauri/src/huddle/stt.rs @@ -63,13 +63,13 @@ impl SttPipeline { /// /// `tts_active` is a shared flag set by the TTS pipeline while audio is /// playing. The STT worker uses it to: - /// - discard accumulated speech (echo prevention / barge-in gating) - /// - apply a 200 ms cooldown after TTS stops before re-enabling STT - /// - detect barge-in: speech onset during TTS → set `tts_cancel` + /// - discard accumulated speech so local playback cannot feed back into STT + /// - apply a cooldown after TTS stops before re-enabling STT /// - /// `tts_cancel` (optional) is the TTS pipeline's cancel flag. When the STT - /// worker detects speech onset while TTS is active, it sets this flag to - /// stop playback immediately (barge-in). Pass `None` if TTS is unavailable. + /// Open-mic VAD cannot distinguish a nearby human from the app's own native + /// TTS playback because it has no acoustic echo reference. Local mic frames + /// therefore never cancel TTS. Push-to-talk and remote participant speech + /// remain explicit, reliable barge-in paths. /// /// `ptt_active` (optional) is the push-to-talk flag. When `Some`, the STT /// pipeline only accumulates speech while the flag is true (key held). @@ -86,7 +86,6 @@ impl SttPipeline { pub fn new( model_dir: PathBuf, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, ) -> Result<(Self, tokio_mpsc::Receiver), String> { let (audio_tx, audio_rx) = mpsc::sync_channel::>(AUDIO_QUEUE_DEPTH); @@ -94,7 +93,6 @@ impl SttPipeline { let shutdown = Arc::new(AtomicBool::new(false)); let shutdown_worker = Arc::clone(&shutdown); - let tts_cancel_worker = tts_cancel.as_ref().map(Arc::clone); let ptt_active_worker = ptt_active.as_ref().map(Arc::clone); let handle = thread::Builder::new() .name("stt-worker".into()) @@ -105,7 +103,6 @@ impl SttPipeline { text_tx, shutdown_worker, tts_active, - tts_cancel_worker, ptt_active_worker, ) }) @@ -167,28 +164,26 @@ impl Drop for SttPipeline { /// Previous value (28 frames / 450 ms) felt sluggish in conversation. const SILENCE_FLUSH_FRAMES: usize = 19; -/// Consecutive VAD speech frames required before triggering barge-in during TTS. -/// 20 frames × 256 samples / 16 kHz ≈ 320 ms — must be long enough to filter -/// speaker-to-mic feedback (TTS audio bleeding through the mic) while still -/// catching real human interruptions. 80 ms (previous: 5 frames) was too -/// aggressive — laptop speakers without headphones triggered false barge-in -/// within the first word of TTS playback. -const BARGE_IN_DEBOUNCE_FRAMES: usize = 20; - /// earshot requires exactly 256 samples per frame at 16 kHz. const VAD_FRAME_SAMPLES: usize = 256; /// VAD probability threshold — above this is considered speech. const VAD_THRESHOLD: f32 = 0.5; +/// Minimum voiced audio needed before an utterance may be decoded. +/// One earshot false-positive frame is only 16 ms; requiring 192 ms prevents +/// silence/room-noise blips from reaching Parakeet and becoming hallucinated +/// transcript text while still preserving short replies such as "yes". +const MIN_VOICED_FRAMES: usize = 12; + /// How long the worker waits on the audio channel before checking the shutdown flag. const RECV_TIMEOUT: Duration = Duration::from_millis(50); -/// 50 ms cooldown after TTS stops before STT re-enables. +/// 150 ms cooldown after TTS stops before STT re-enables. /// Prevents the tail of TTS audio from being transcribed as speech. -/// Previous value (200 ms) was eating the first word when the user spoke -/// immediately after the agent finished. -const TTS_COOLDOWN: Duration = Duration::from_millis(50); +/// This remains shorter than the previous 200 ms gate that ate the first word, +/// but is long enough for speaker/AEC tail audio to leave the microphone path. +const TTS_COOLDOWN: Duration = Duration::from_millis(150); /// Number of ONNX Runtime intra-op threads used by the offline recognizer. /// @@ -207,7 +202,6 @@ fn stt_worker( text_tx: tokio_mpsc::Sender, shutdown: Arc, tts_active: Arc, - tts_cancel: Option>, ptt_active: Option>, ) { // ── 1. Initialise rubato resampler (48 kHz → 16 kHz, mono) ─────────────── @@ -274,9 +268,9 @@ fn stt_worker( let mut silence_frames: usize = 0; // Whether we're currently in a speech segment. let mut in_speech = false; - // Consecutive speech frames seen during TTS — used for barge-in debounce. - let mut barge_in_frames: usize = 0; - // Timestamp when TTS last stopped — used for the 200 ms cooldown. + // Number of frames earshot classified as voiced in the current segment. + let mut voiced_frames = 0; + // Timestamp when TTS last stopped — used for the playback-tail cooldown. let mut tts_stopped_at: Option = None; // ── 5. Main loop ────────────────────────────────────────────────────────── @@ -305,10 +299,11 @@ fn stt_worker( if let Some(ref ptt) = ptt_active { let ptt_now = ptt.load(Ordering::Acquire); if ptt_was_active && !ptt_now && in_speech && !speech_buf.is_empty() { - flush_to_stt(&speech_buf, &recognizer, &text_tx); + flush_to_stt(&speech_buf, voiced_frames, &recognizer, &text_tx); speech_buf.clear(); silence_frames = 0; in_speech = false; + voiced_frames = 0; } ptt_was_active = ptt_now; } @@ -342,11 +337,10 @@ fn stt_worker( &mut speech_buf, &mut silence_frames, &mut in_speech, - &mut barge_in_frames, + &mut voiced_frames, &recognizer, &text_tx, &tts_active, - tts_cancel.as_deref(), &mut tts_stopped_at, ptt_active.as_ref(), ); @@ -387,8 +381,8 @@ fn resample_chunk(resampler: &mut rubato::Fft, chunk_48k: &[f32]) -> Vec, silence_frames: &mut usize, in_speech: &mut bool, - barge_in_frames: &mut usize, + voiced_frames: &mut usize, recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, tts_active: &Arc, - tts_cancel: Option<&AtomicBool>, tts_stopped_at: &mut Option, ptt_active: Option<&Arc>, ) { @@ -433,38 +426,16 @@ fn process_16k_samples( let tts_playing = tts_active.load(Ordering::Acquire); - // While TTS is playing: skip accumulation (echo prevention). + // While TTS is playing, discard local mic input. The native TTS output + // is not available as an echo-cancellation reference to this worker, so + // VAD cannot reliably tell speaker feedback from a human interruption. + // Push-to-talk and remote participant audio provide the intentional + // cancellation paths instead. if tts_playing { - if ptt_active.is_some() { - // PTT mode — PTT press handles TTS cancellation directly - // (via the global shortcut handler). Just skip accumulation. - *in_speech = false; - *barge_in_frames = 0; - speech_buf.clear(); - *silence_frames = 0; - continue; - } - - // VAD mode — barge-in detection. - // Without acoustic echo cancellation, this requires a longer - // debounce (BARGE_IN_DEBOUNCE_FRAMES ≈ 320 ms) to filter - // speaker-to-mic feedback. - if is_speech { - *barge_in_frames += 1; - if *barge_in_frames >= BARGE_IN_DEBOUNCE_FRAMES { - // Real speech detected during TTS — trigger barge-in. - if let Some(cancel) = tts_cancel { - cancel.store(true, Ordering::Release); - } - *barge_in_frames = 0; - } - } else { - *barge_in_frames = 0; - } - // Don't accumulate speech during TTS (echo prevention). *in_speech = false; speech_buf.clear(); *silence_frames = 0; + *voiced_frames = 0; continue; } @@ -477,28 +448,30 @@ fn process_16k_samples( } speech_buf.clear(); *silence_frames = 0; - *barge_in_frames = 0; + *voiced_frames = 0; continue; } else { // Cooldown expired — clear the timer and reset all segment state. *tts_stopped_at = None; *in_speech = false; *silence_frames = 0; - *barge_in_frames = 0; + *voiced_frames = 0; } } if is_speech { *silence_frames = 0; *in_speech = true; + *voiced_frames += 1; speech_buf.extend_from_slice(&frame); // OOM guard: flush and reset if the buffer exceeds 30 s of audio. if speech_buf.len() >= MAX_SPEECH_SAMPLES { - flush_to_stt(speech_buf, recognizer, text_tx); + flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); *silence_frames = 0; *in_speech = false; + *voiced_frames = 0; } } else if *in_speech { // Still accumulate during brief silence gaps. @@ -511,10 +484,11 @@ fn process_16k_samples( // threshold so each natural pause becomes a separate message. if ptt_active.is_none() && *silence_frames >= SILENCE_FLUSH_FRAMES { // End of utterance — transcribe. - flush_to_stt(speech_buf, recognizer, text_tx); + flush_to_stt(speech_buf, *voiced_frames, recognizer, text_tx); speech_buf.clear(); *silence_frames = 0; *in_speech = false; + *voiced_frames = 0; } } // If not in speech and not accumulating, just discard the frame. @@ -527,10 +501,11 @@ fn process_16k_samples( /// The tokio channel's `blocking_send` is safe to call from sync contexts. fn flush_to_stt( speech_buf: &[f32], + voiced_frames: usize, recognizer: &sherpa_onnx::OfflineRecognizer, text_tx: &tokio_mpsc::Sender, ) { - if speech_buf.is_empty() { + if speech_buf.is_empty() || !has_enough_voiced_audio(voiced_frames) { return; } @@ -550,6 +525,10 @@ fn flush_to_stt( } } +fn has_enough_voiced_audio(voiced_frames: usize) -> bool { + voiced_frames >= MIN_VOICED_FRAMES +} + /// Convert raw bytes (f32 LE) to f32 samples. /// Caller should ensure `bytes.len() % 4 == 0`; extra bytes are silently truncated. /// @@ -565,3 +544,15 @@ fn bytes_to_f32(bytes: &[u8]) -> Vec { // drain_until_shutdown lives in super (huddle/mod.rs) — shared with tts.rs. use super::drain_until_shutdown; + +#[cfg(test)] +mod tests { + use super::{has_enough_voiced_audio, MIN_VOICED_FRAMES}; + + #[test] + fn short_vad_blips_do_not_reach_the_recognizer() { + assert!(!has_enough_voiced_audio(1)); + assert!(!has_enough_voiced_audio(MIN_VOICED_FRAMES - 1)); + assert!(has_enough_voiced_audio(MIN_VOICED_FRAMES)); + } +} diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index c03589f9fe4..1901bb3d2ea 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -35,7 +35,7 @@ //! can gate microphone input while the agent is speaking. use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, num::NonZero, path::PathBuf, sync::{ @@ -44,7 +44,7 @@ use std::{ Arc, Mutex, MutexGuard, PoisonError, }, thread, - time::Duration, + time::{Duration, Instant}, }; use super::pocket::{ @@ -61,6 +61,9 @@ use startup::await_worker_startup; #[path = "tts_audio.rs"] mod audio; use audio::*; +#[path = "tts_activity.rs"] +mod activity; +use activity::*; // ── Constants ───────────────────────────────────────────────────────────────── @@ -77,6 +80,7 @@ const RECV_TIMEOUT: Duration = Duration::from_millis(100); /// ~5 ms — so playing audio dies ~15 ms after the flag is set, even while /// the worker is blocked inside `synth_chunk`. const MONITOR_TICK: Duration = Duration::from_millis(10); +const SPEAKER_ACTIVITY_TICK: Duration = Duration::from_millis(50); const AUDIO_PRIME_TIMEOUT: Duration = Duration::from_secs(2); /// Pocket TTS is a one-step consistency model, not diffusion. Kept for API compat. @@ -167,10 +171,12 @@ impl TtsPipeline { cancel: Arc, voice: &str, output_device: Option, + activity_app: Option, ) -> Result { let (text_tx, text_rx) = mpsc::sync_channel::(TEXT_QUEUE_DEPTH); let shutdown = Arc::new(AtomicBool::new(false)); - // cancel is passed in from HuddleState.tts_cancel — shared with STT for barge-in. + // cancel is passed in from HuddleState.tts_cancel — shared with remote + // participant interruption and the push-to-talk shortcut. let shutdown_worker = Arc::clone(&shutdown); let cancel_worker = Arc::clone(&cancel); @@ -203,6 +209,7 @@ impl TtsPipeline { (cancel_worker, worker_voice_cancel), ), output_device, + activity_app, startup_tx, ) }) @@ -231,6 +238,8 @@ impl TtsPipeline { .try_send(QueuedText { generation: self.voice_generation.load(Ordering::Acquire), route_id: 0, + speaker_pubkey: None, + voice_reference: None, text, }) .map_err(|e| { @@ -309,6 +318,7 @@ fn tts_worker( text_rx: mpsc::Receiver, control_state: WorkerControlState, output_device: Option, + activity_app: Option, startup_tx: mpsc::SyncSender>, ) { let (selected_voice, voice_generation, voice_change_ack) = voice_state; @@ -351,6 +361,7 @@ fn tts_worker( )); return; } + let mut style_cache = HashMap::from([(voice_name.clone(), style.clone())]); // ── 2b. Warmup inference ───────────────────────────────────────────────── // The first ONNX inference on any session is significantly slower than @@ -454,6 +465,7 @@ fn tts_worker( // `cancel == false` and no-ops. The lock is uncontended except during an // actual barge-in, so the hot path is unaffected. let player_ops = Arc::new(Mutex::new(())); + let activity_frames = Arc::new(Mutex::new(VecDeque::::new())); let monitor_stop = Arc::new(AtomicBool::new(false)); let monitor = { let player = Arc::clone(&player); @@ -462,9 +474,12 @@ fn tts_worker( let tts_active = Arc::clone(&tts_active); let stop = Arc::clone(&monitor_stop); let player_ops = Arc::clone(&player_ops); + let activity_frames = Arc::clone(&activity_frames); thread::Builder::new() .name("tts-barge-in-monitor".into()) .spawn(move || { + let mut last_activity_pubkey: Option = None; + let mut next_activity_tick = Instant::now(); while !stop.load(Ordering::Acquire) { if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) { let _ops = lock_player_ops(&player_ops); @@ -481,6 +496,46 @@ fn tts_worker( tts_active.store(false, Ordering::Release); } } + if let Some(ref app) = activity_app { + if tts_active.load(Ordering::Acquire) { + let now = Instant::now(); + if now >= next_activity_tick { + let frame = activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .pop_front(); + if let Some(frame) = frame { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: Some(frame.pubkey.clone()), + level: frame.level, + }, + ); + last_activity_pubkey = Some(frame.pubkey); + } + next_activity_tick = now + SPEAKER_ACTIVITY_TICK; + } + } else { + let had_activity = last_activity_pubkey.take().is_some(); + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); + if had_activity { + use tauri::Emitter; + let _ = app.emit( + "huddle-tts-speaker-level", + TtsSpeakerActivityPayload { + pubkey: None, + level: 0.0, + }, + ); + } + next_activity_tick = Instant::now(); + } + } thread::sleep(MONITOR_TICK); } }) @@ -507,7 +562,9 @@ fn tts_worker( let mut first_append = true; let mut last_route_id = 0; let mut deferred_text = VecDeque::new(); - let append_audio = |prepared: PreparedModelAudio, route_id: u64| { + let append_audio = |prepared: PreparedModelAudio, + route_id: u64, + speaker_pubkey: Option<&str>| { let _ops = lock_player_ops(&player_ops); if cancel.load(Ordering::Acquire) || voice_cancel.load(Ordering::Acquire) @@ -525,6 +582,16 @@ fn tts_worker( ); return false; } + if let Some(pubkey) = speaker_pubkey { + activity_frames + .lock() + .unwrap_or_else(|error| error.into_inner()) + .extend(build_tts_speaker_activity_frames( + &prepared.buffer, + pubkey, + SAMPLE_RATE as usize, + )); + } player.append(SamplesBuffer::new(channels, rate, prepared.buffer)); eprintln!( "buzz-desktop: tts stage=player status=append_accepted route_id={route_id} chunk_index={} sample_count={}", @@ -555,14 +622,19 @@ fn tts_worker( continue; } - // Voice changes cancel the old utterance/queue and are observed here, - // before receiving subsequent text. A bad bundled asset falls back to - // Mary without discarding the already-warmed Pocket engine. - let voice_ready = - reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); - acknowledge_voice_change(&voice_change_ack, &voice_cancel); - if !voice_ready { - continue; + // A global Settings voice change cancels the old utterance and is + // acknowledged before receiving subsequent text. Per-agent voice + // changes are carried by each queue item and never drain other agents. + if has_pending_voice_change(&voice_change_ack) { + let voice_ready = + reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style); + if voice_ready { + style_cache.insert(voice_name.clone(), style.clone()); + } + acknowledge_voice_change(&voice_change_ack, &voice_cancel); + if !voice_ready { + continue; + } } let mut queued_text = Some(match deferred_text.pop_front() { @@ -614,15 +686,28 @@ fn tts_worker( ); continue; } + let requested_voice = queued_text.voice_reference.unwrap_or_else(|| { + selected_voice + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clone() + }); let raw_text = queued_text.text; + let speaker_pubkey = queued_text.speaker_pubkey; let route_id = queued_text.route_id; eprintln!("buzz-desktop: tts stage=synthesis status=started route_id={route_id}"); - // The selected voice can change while this worker is blocked in - // recv_timeout. Reconcile again after receipt so the first message - // queued after an unpublished pipeline is installed cannot use the - // voice captured when construction began. - if !reconcile_selected_voice(&model_dir, &selected_voice, &mut voice_name, &mut style) { + // The selected per-agent voice travels with the queue item, preserving + // message order while allowing one warmed Pocket engine to alternate + // between cached reference styles. + if !reconcile_queued_voice( + &model_dir, + &requested_voice, + &selected_voice, + &mut voice_name, + &mut style, + &mut style_cache, + ) { eprintln!( "buzz-desktop: tts stage=synthesis status=failed reason=voice_unavailable route_id={route_id}" ); @@ -761,7 +846,7 @@ fn tts_worker( silence_buf_len, player.empty(), ) { - if !append_audio(prepared, route_id) { + if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; @@ -787,7 +872,7 @@ fn tts_worker( if let Some(prepared) = playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) { - if !append_audio(prepared, route_id) { + if !append_audio(prepared, route_id, speaker_pubkey.as_deref()) { first_append = true; synthesis_outcome = "cancelled"; break 'playback_chunks; diff --git a/desktop/src-tauri/src/huddle/tts_activity.rs b/desktop/src-tauri/src/huddle/tts_activity.rs new file mode 100644 index 00000000000..8e69609186c --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_activity.rs @@ -0,0 +1,45 @@ +//! Agent TTS activity envelope shared with the participant film strip. + +#[derive(Clone, serde::Serialize)] +pub(super) struct TtsSpeakerActivityPayload { + pub(super) pubkey: Option, + pub(super) level: f32, +} + +pub(super) struct TtsSpeakerActivityFrame { + pub(super) pubkey: String, + pub(super) level: f32, +} + +/// Build a 50 ms RMS envelope from the exact audio queued for playback. +/// The UI consumes these frames at the same cadence as remote speaker levels, +/// so an agent uses the normal participant ring rather than a generic pulse. +pub(super) fn build_tts_speaker_activity_frames( + samples: &[f32], + pubkey: &str, + sample_rate: usize, +) -> Vec { + let samples_per_frame = (sample_rate / 20).max(1); + samples + .chunks(samples_per_frame) + .map(|frame| { + let mean_square = frame + .iter() + .map(|sample| f64::from(*sample) * f64::from(*sample)) + .sum::() + / frame.len().max(1) as f64; + let rms = mean_square.sqrt() as f32; + let level = if rms <= 0.000_5 { + 0.0 + } else { + // Map roughly -60 dB..-12 dB into the same normalized range + // used by remote Opus speaker levels. + ((20.0 * rms.log10() + 60.0) / 48.0).clamp(0.12, 1.0) + }; + TtsSpeakerActivityFrame { + pubkey: pubkey.to_string(), + level, + } + }) + .collect() +} diff --git a/desktop/src-tauri/src/huddle/tts_settings.rs b/desktop/src-tauri/src/huddle/tts_settings.rs index 1b378af8237..64fd6d8a945 100644 --- a/desktop/src-tauri/src/huddle/tts_settings.rs +++ b/desktop/src-tauri/src/huddle/tts_settings.rs @@ -175,7 +175,7 @@ pub fn resolve_voice_for_backend( resolve_voice_for_backend_in_registry(preferences, backend, &bundled_voice_registry()) } -fn resolve_voice_for_backend_in_registry( +pub(crate) fn resolve_voice_for_backend_in_registry( preferences: &[String], backend: &str, registry: &[VoiceRegistryEntry], @@ -624,6 +624,7 @@ pub async fn preview_pocket_voice( cancel, &voice_name, output_device, + None, )?; pipeline.speak("Hello! This is how I’ll read agent responses.".to_string())?; let started = std::time::Instant::now(); diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 1908b096b18..1dee4de90cc 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -32,6 +32,19 @@ mod token_split; // - Counters reset on the 500ms window (Instant-based in production, // on_tick() in tests — logically equivalent). // - Uses Acquire for tts_active reads, Release for tts_cancel writes. + +#[test] +fn tts_speaker_activity_uses_the_playback_waveform() { + let mut samples = vec![0.0; 1_200]; + samples.extend(vec![0.25; 1_200]); + + let frames = build_tts_speaker_activity_frames(&samples, "agent-pubkey", 24_000); + + assert_eq!(frames.len(), 2); + assert_eq!(frames[0].pubkey, "agent-pubkey"); + assert_eq!(frames[0].level, 0.0); + assert!(frames[1].level > 0.5); +} // use crate::huddle::relay_api::REMOTE_SPEECH_THRESHOLD; @@ -287,24 +300,6 @@ fn cancel_already_true_is_harmless() { ); } -// ── Regression: local-only interrupt still works ────────────────────────── - -/// The existing local barge-in path (STT detects speech → sets tts_cancel) -/// must continue to work independently of remote frame counting. -#[test] -fn local_barge_in_still_works_without_remote_frames() { - let _tts_active = AtomicBool::new(true); - let tts_cancel = AtomicBool::new(false); - - // Simulate local STT barge-in (stt.rs after BARGE_IN_DEBOUNCE_FRAMES). - tts_cancel.store(true, Ordering::Release); - - assert!( - tts_cancel.load(Ordering::Acquire), - "local barge-in should set tts_cancel", - ); -} - // ── Cancel consumption tests (TTS worker side) ──────────────────────────── /// TTS worker correctly resets both tts_cancel and tts_active after cancel. diff --git a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs index 45662c99212..044b1acf1e8 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_selection_tests.rs @@ -167,6 +167,8 @@ fn an_in_hand_post_change_message_survives_cancellation() { .send(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 1, + speaker_pubkey: None, + voice_reference: None, text: "new message".to_string(), }) .expect("new message"); @@ -178,11 +180,15 @@ fn an_in_hand_post_change_message_survives_cancellation() { QueuedText { generation: 1, route_id: 2, + speaker_pubkey: None, + voice_reference: None, text: "old message".to_string(), }, QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 3, + speaker_pubkey: None, + voice_reference: None, text: "later new message".to_string(), }, ]); @@ -239,6 +245,8 @@ fn superseding_voice_change_removes_earlier_deferred_messages() { deferred_text.push_back(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 4, + speaker_pubkey: None, + voice_reference: None, text: "message for Eve".to_string(), }); assert!(handle_cancel_or_shutdown( @@ -285,6 +293,8 @@ fn barge_in_clears_deferred_voice_change_messages() { let mut deferred_text = VecDeque::from([QueuedText { generation: 2, route_id: 5, + speaker_pubkey: None, + voice_reference: None, text: "deferred message".to_string(), }]); let mut current_text = None; @@ -326,6 +336,8 @@ fn barge_in_during_a_voice_change_clears_post_change_messages() { deferred_text.push_back(QueuedText { generation: voice_generation.load(Ordering::Acquire), route_id: 6, + speaker_pubkey: None, + voice_reference: None, text: "post-change message".to_string(), }); barge_in.store(true, Ordering::Release); @@ -377,9 +389,15 @@ fn a_sender_captured_before_voice_change_is_stale_even_if_it_sends_after_drain() None, )); old_sender - .send(7, "late old message".to_string()) + .send( + 7, + "agent".to_string(), + "reference_sample".to_string(), + "late old message".to_string(), + ) .expect("late send"); let late = text_rx.recv().expect("late queued text"); assert!(late.generation < voice_generation.load(Ordering::Acquire)); + assert_eq!(late.voice_reference.as_deref(), Some("reference_sample")); } diff --git a/desktop/src-tauri/src/huddle/tts_voice_transition.rs b/desktop/src-tauri/src/huddle/tts_voice_transition.rs index 81b33672d34..3a655537561 100644 --- a/desktop/src-tauri/src/huddle/tts_voice_transition.rs +++ b/desktop/src-tauri/src/huddle/tts_voice_transition.rs @@ -1,5 +1,5 @@ use std::{ - collections::VecDeque, + collections::{HashMap, VecDeque}, path::Path, sync::{ atomic::{AtomicBool, AtomicU64, Ordering}, @@ -30,6 +30,8 @@ pub(super) type CancelSignals<'a> = (&'a AtomicBool, &'a AtomicBool); pub(super) struct QueuedText { pub(super) generation: u64, pub(super) route_id: u64, + pub(super) speaker_pubkey: Option, + pub(super) voice_reference: Option, pub(super) text: String, } @@ -40,17 +42,32 @@ pub(crate) struct TtsTextSender { } impl TtsTextSender { - pub(crate) fn send(&self, route_id: u64, text: String) -> Result<(), String> { + pub(crate) fn send( + &self, + route_id: u64, + speaker_pubkey: String, + voice_reference: String, + text: String, + ) -> Result<(), String> { self.text_tx .send(QueuedText { generation: self.generation, route_id, + speaker_pubkey: Some(speaker_pubkey), + voice_reference: Some(voice_reference), text, }) .map_err(|error| error.to_string()) } } +pub(super) fn has_pending_voice_change(voice_change_ack: &VoiceChangeAck) -> bool { + voice_change_ack + .lock() + .unwrap_or_else(|error| error.into_inner()) + .is_some() +} + pub(super) fn begin_voice_change( selected_voice: &Mutex, voice_generation: &AtomicU64, @@ -151,6 +168,43 @@ pub(super) fn reconcile_selected_voice( } } +pub(super) fn reconcile_queued_voice( + model_dir: &Path, + requested_voice: &str, + selected_voice: &Mutex, + voice_name: &mut String, + style: &mut VoiceStyle, + style_cache: &mut HashMap, +) -> bool { + if requested_voice == voice_name.as_str() { + return true; + } + if let Some(cached) = style_cache.get(requested_voice) { + *style = cached.clone(); + *voice_name = requested_voice.to_owned(); + return true; + } + + match load_voice_style(&voice_path(model_dir, requested_voice)) { + Ok(requested_style) => { + style_cache.insert(requested_voice.to_owned(), requested_style.clone()); + *style = requested_style; + *voice_name = requested_voice.to_owned(); + true + } + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=agent_voice_switch status=fallback reason=voice_style" + ); + let ready = reconcile_selected_voice(model_dir, selected_voice, voice_name, style); + if ready { + style_cache.insert(voice_name.clone(), style.clone()); + } + ready + } + } +} + pub(super) fn voice_path(model_dir: &Path, voice: &str) -> std::path::PathBuf { let path = Path::new(voice); if path.is_absolute() { diff --git a/desktop/src-tauri/src/huddle/window.rs b/desktop/src-tauri/src/huddle/window.rs new file mode 100644 index 00000000000..cb3cfc8bfd8 --- /dev/null +++ b/desktop/src-tauri/src/huddle/window.rs @@ -0,0 +1,67 @@ +//! Native companion-window lifecycle for an active Huddle. + +use tauri::{Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; + +use crate::app_state::AppState; + +/// Close the companion belonging to an ended huddle. The native lifecycle is +/// authoritative here because a webview can be suspended while it is closing. +pub(super) fn close_huddle_window(app: &tauri::AppHandle, ephemeral_channel_id: &str) { + if ephemeral_channel_id.is_empty() { + return; + } + let label = format!("huddle-{ephemeral_channel_id}"); + if let Some(window) = app.get_webview_window(&label) { + if let Err(error) = window.close() { + eprintln!("buzz-desktop: failed to close huddle companion: {error}"); + } + } +} + +/// Close the active companion without leaving the huddle. The main window uses +/// this to restore its drawer presentation while retaining the audio session. +#[tauri::command] +pub fn close_huddle_companion( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let ephemeral_channel_id = state + .huddle()? + .ephemeral_channel_id + .clone() + .ok_or("no active huddle")?; + close_huddle_window(&app, &ephemeral_channel_id); + app.emit("huddle-companion-returned", ()) + .map_err(|error| error.to_string())?; + Ok(()) +} + +/// Open the active huddle's ephemeral channel in a focused companion window. +/// The main window remains the owner of microphone capture; closing this room +/// must never leave the shared huddle session. +#[tauri::command] +pub async fn open_huddle_window( + app: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let ephemeral_channel_id = state + .huddle()? + .ephemeral_channel_id + .clone() + .ok_or("no active huddle")?; + let label = format!("huddle-{ephemeral_channel_id}"); + + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; + return Ok(()); + } + + WebviewWindowBuilder::new(&app, label, WebviewUrl::App("index.html".into())) + .title("Huddle") + .inner_size(960.0, 720.0) + .min_inner_size(720.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs new file mode 100644 index 00000000000..b1245515125 --- /dev/null +++ b/desktop/src-tauri/src/initial_window.rs @@ -0,0 +1,67 @@ +//! First-frame window reveal helpers. + +#[cfg(target_os = "macos")] +pub(crate) const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; + +pub(crate) fn reveal_initial_window(window: &tauri::Window) { + if let Err(error) = window.show() { + eprintln!("buzz-desktop: failed to reveal main window: {error}"); + return; + } + if let Err(error) = window.set_focus() { + eprintln!("buzz-desktop: failed to focus main window: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) fn set_initial_window_backing(window: &tauri::Window) { + // The window remains transparent at runtime for vibrancy. Use an opaque + // native backing only across the first visible frames so the previous app + // cannot show through before WebKit has submitted its first surface. + if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { + eprintln!("buzz-desktop: failed to set initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn clear_initial_window_backing(window: &tauri::Window) { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + if let Err(error) = window.set_background_color(None) { + eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); + } +} + +#[cfg(target_os = "macos")] +pub(crate) async fn wait_for_stable_initial_window_geometry( + window: &tauri::Window, +) { + const MAX_POLLS: usize = 120; + const REQUIRED_STABLE_POLLS: usize = 4; + + let mut previous_bounds = None; + let mut stable_polls = 0; + + for _ in 0..MAX_POLLS { + // Accept whatever geometry the window-state plugin restores — maximized + // or a normal saved size. macOS applies the restore asynchronously, so + // consecutive identical outer bounds are enough to know it settled. + let bounds = match (window.outer_position(), window.outer_size()) { + (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), + _ => None, + }; + + if bounds.is_some() && bounds == previous_bounds { + stable_polls += 1; + if stable_polls >= REQUIRED_STABLE_POLLS { + return; + } + } else { + stable_polls = 0; + } + previous_bounds = bounds; + + tokio::time::sleep(std::time::Duration::from_millis(16)).await; + } + + eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c4b733e3e07..7c5530db743 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod event_sync; mod events; mod huddle; mod identity_storage; +mod initial_window; mod key_backup; mod linux_media; mod managed_agents; @@ -49,11 +50,13 @@ use huddle::audio_output::{ }; use huddle::reconnect::reconnect_huddle_audio; use huddle::{ - add_agent_to_huddle, check_pipeline_hotstart, confirm_huddle_active, download_voice_models, - end_huddle, get_huddle_agent_pubkeys, get_huddle_state, get_model_status, get_voice_input_mode, - join_huddle, leave_huddle, push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, - set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline, + add_agent_to_huddle, check_pipeline_hotstart, close_huddle_companion, confirm_huddle_active, + download_voice_models, end_huddle, get_huddle_agent_pubkeys, get_huddle_state, + get_model_status, get_voice_input_mode, join_huddle, leave_huddle, open_huddle_window, + push_audio_pcm, set_huddle_transcription_enabled, set_tts_enabled, set_voice_input_mode, + speak_agent_message, start_huddle, start_stt_pipeline, HuddlePhase, }; +use initial_window::*; use managed_agents::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, @@ -66,80 +69,13 @@ use mesh_llm_stubs::*; use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; use shutdown::{is_restart_request, shut_down_app}; use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; -use tauri::{Emitter, Manager, RunEvent}; #[cfg(target_os = "macos")] -use tauri::{Listener, WindowEvent}; +use tauri::Listener; +use tauri::{Emitter, Manager, RunEvent, WindowEvent}; use tauri_plugin_window_state::StateFlags; #[cfg(target_os = "macos")] use tray_menu::show_main_window; -#[cfg(target_os = "macos")] -const INITIAL_RENDER_READY_EVENT: &str = "initial-render-ready"; - -fn reveal_initial_window(window: &tauri::Window) { - if let Err(error) = window.show() { - eprintln!("buzz-desktop: failed to reveal main window: {error}"); - return; - } - if let Err(error) = window.set_focus() { - eprintln!("buzz-desktop: failed to focus main window: {error}"); - } -} - -#[cfg(target_os = "macos")] -fn set_initial_window_backing(window: &tauri::Window) { - // The window remains transparent at runtime for vibrancy. Use an opaque - // native backing only across the first visible frames so the previous app - // cannot show through before WebKit has submitted its first surface. - if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { - eprintln!("buzz-desktop: failed to set initial window backing: {error}"); - } -} - -#[cfg(target_os = "macos")] -async fn clear_initial_window_backing(window: &tauri::Window) { - tokio::time::sleep(std::time::Duration::from_millis(250)).await; - if let Err(error) = window.set_background_color(None) { - eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); - } -} - -#[cfg(target_os = "macos")] -async fn wait_for_stable_initial_window_geometry(window: &tauri::Window) { - const MAX_POLLS: usize = 120; - const REQUIRED_STABLE_POLLS: usize = 4; - - let mut previous_bounds = None; - let mut stable_polls = 0; - - for _ in 0..MAX_POLLS { - // Accept whatever geometry the window-state plugin restores — maximized - // or a normal saved size. macOS applies the restore asynchronously, so - // we only need consecutive identical outer bounds to know it settled. - // Gating on `is_maximized()` here would leave `bounds` permanently - // `None` for restored non-maximized windows and stall the reveal until - // the poll timeout. - let bounds = match (window.outer_position(), window.outer_size()) { - (Ok(position), Ok(size)) => Some((position.x, position.y, size.width, size.height)), - _ => None, - }; - - if bounds.is_some() && bounds == previous_bounds { - stable_polls += 1; - if stable_polls >= REQUIRED_STABLE_POLLS { - return; - } - } else { - stable_polls = 0; - } - previous_bounds = bounds; - - tokio::time::sleep(std::time::Duration::from_millis(16)).await; - } - - eprintln!("buzz-desktop: initial window geometry did not settle before reveal timeout"); -} - #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // mesh-llm's async chains (model download, node start/join) overflow @@ -885,6 +821,8 @@ pub fn run() { leave_huddle, end_huddle, get_huddle_state, + close_huddle_companion, + open_huddle_window, push_audio_pcm, reconnect_huddle_audio, start_stt_pipeline, @@ -898,8 +836,12 @@ pub fn run() { huddle::tts_settings::preview_pocket_voice, huddle::tts_settings::import_pocket_voice, huddle::tts_settings::delete_pocket_voice, + huddle::agent_voice::ensure_huddle_agent_voice_settings, + huddle::agent_voice::set_huddle_agent_tts_enabled, + huddle::agent_voice::set_huddle_agent_voice, speak_agent_message, add_agent_to_huddle, + huddle::agents::sync_agents_to_active_huddle, check_pipeline_hotstart, confirm_huddle_active, perform_sidebar_default_haptic, @@ -971,6 +913,29 @@ pub fn run() { } } } + RunEvent::WindowEvent { + label, + event: WindowEvent::CloseRequested { .. }, + .. + } if label.starts_with("huddle-") => { + let is_active_huddle_window = + app_handle + .state::() + .huddle() + .ok() + .is_some_and(|huddle| { + !matches!(huddle.phase, HuddlePhase::Idle | HuddlePhase::Leaving) + && huddle + .ephemeral_channel_id + .as_deref() + .is_some_and(|channel_id| label == format!("huddle-{channel_id}")) + }); + if is_active_huddle_window { + if let Err(error) = app_handle.emit("huddle-companion-returned", ()) { + eprintln!("buzz-desktop: failed to restore huddle drawer: {error}"); + } + } + } RunEvent::ExitRequested { code, .. } => { if is_restart_request(code) { restart_requested.store(true, Ordering::SeqCst); diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 44618f2c721..104edcbaaf3 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -20,6 +20,7 @@ import { deriveShellRoute } from "@/app/AppShell.helpers"; import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground"; import { useReloadShortcut } from "@/app/useReloadShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; import { @@ -652,12 +653,13 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { [activeCommunity, communityOnboarding.start], ); - // Deep links are captured here — above the machine-onboarding gate — not in - // CommunityApp. The Rust side queues them; draining into the persisted - // community-onboarding transaction immediately means an invite opened on a - // fresh install is acknowledged on screen while the identity steps are - // still pending, and survives a relaunch in between. + // Community links are app-global work. A Huddle companion loads the same + // React tree, but must never race the main window for the native pending-link + // queue or replace its dedicated transcript surface with onboarding. + const acceptsCommunityDeepLinks = huddleWindowChannelId() === null; useEffect(() => { + if (!acceptsCommunityDeepLinks) return; + const unlisten = listenForDeepLinks({ startCommunityOnboarding: communityOnboarding.start, openAddCommunity, @@ -666,7 +668,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { return () => { void unlisten.then((fn) => fn()); }; - }, [communityOnboarding.start, openAddCommunity]); + }, [acceptsCommunityDeepLinks, communityOnboarding.start, openAddCommunity]); if (machine.stage === "reset-failed") return ; if (machine.stage === "keyring-locked") return ; diff --git a/desktop/src/app/AppHuddleBar.tsx b/desktop/src/app/AppHuddleBar.tsx index 9fa12d513f8..5dfc31d41c3 100644 --- a/desktop/src/app/AppHuddleBar.tsx +++ b/desktop/src/app/AppHuddleBar.tsx @@ -6,10 +6,12 @@ import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; type AppHuddleBarProps = Pick< React.ComponentProps, - "onOpenThread" | "onVisibilityChange" + "mode" | "onOpenHuddleWindow" | "onOpenThread" | "onVisibilityChange" >; export function AppHuddleBar({ + mode, + onOpenHuddleWindow, onOpenThread, onVisibilityChange, }: AppHuddleBarProps) { @@ -17,6 +19,8 @@ export function AppHuddleBar({ diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx new file mode 100644 index 00000000000..8370e8efd43 --- /dev/null +++ b/desktop/src/app/AppHuddleShell.tsx @@ -0,0 +1,76 @@ +import type * as React from "react"; +import { AppHuddleBar } from "@/app/AppHuddleBar"; +import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; +import { HuddleProvider } from "@/features/huddle"; +import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; +import { cn } from "@/shared/lib/cn"; + +type AppHuddleShellProps = { + children: React.ReactNode; + currentPubkey?: string; + isCompanionOpen: boolean; + isDrawerOpen: boolean; + isRoom: boolean; + onCompanionOpen: () => void; + onHuddleStartPendingChange: (pending: boolean) => void; + onHuddleStarted: (ephemeralChannelId: string) => void | Promise; + onShowHuddleInMainApp: (ephemeralChannelId: string) => void; + onViewHuddleChannel: (ephemeralChannelId: string) => void; + onVisibilityChange: (visible: boolean) => void; +}; + +export function AppHuddleShell({ + children, + currentPubkey, + isCompanionOpen, + isDrawerOpen, + isRoom, + onCompanionOpen, + onHuddleStartPendingChange, + onHuddleStarted, + onShowHuddleInMainApp, + onViewHuddleChannel, + onVisibilityChange, +}: AppHuddleShellProps) { + return ( + + +
+
+ + {children} +
+ {isRoom || !isCompanionOpen ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 46e06b2f9f4..e5f9f6d866c 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -3,8 +3,9 @@ import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; import { AppShellProvider } from "@/app/AppShellContext"; -import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; import { AppShellOverlays } from "@/app/AppShellOverlays"; +import { AppShellChannelSurface } from "@/app/AppShellChannelSurface"; +import { AppHuddleShell } from "@/app/AppHuddleShell"; import { AppTopChrome } from "@/app/AppTopChrome"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useBackForwardControls } from "@/app/navigation/useBackForwardControls"; @@ -18,6 +19,8 @@ import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useChannelActivityProjection } from "@/app/useChannelActivityProjection"; import { useTauriWindowDrag } from "@/app/useTauriWindowDrag"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; +import { useHuddlePresentation } from "@/app/useHuddlePresentation"; +import { shouldShowSidebarChannel } from "@/app/huddleChannelVisibility"; import { channelsQueryKey, useChannelsQuery, @@ -62,10 +65,7 @@ import { type SettingsSection, isSettingsSection, } from "@/features/settings/ui/SettingsPanels"; -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"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; import { requestFocusedThreadClose } from "@/features/channels/focusedThreadCloseRequest"; @@ -86,28 +86,38 @@ import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBoundaryLock"; import { joinChannel } from "@/shared/api/tauri"; -import type { ChannelVisibility, SearchHit } from "@/shared/api/types"; +import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; -import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; -import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; -import { cn } from "@/shared/lib/cn"; import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; -import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; +import { 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 }; -}); - +import { LazySettingsScreen } from "@/app/LazySettingsScreen"; +const EMPTY_CHANNELS: Channel[] = []; export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); const communitiesHook = useCommunities(); + const { + handleHuddleCompanionOpen, + handleHuddleEnded, + handleHuddleStartPendingChange, + handleHuddleStarted, + handleHuddleVisibilityChange, + handleSidebarChannelSelect, + huddleBackingChannelIds, + revealedHuddleChannelIds, + isHuddleCompanionOpen, + isHuddleDrawerOpen, + isHuddleRoom, + isHuddleRoomStarting, + showHuddleInMainApp, + viewHuddleChannel, + } = useHuddlePresentation(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); const [isChannelManagementOpen, setIsChannelManagementOpen] = @@ -118,7 +128,6 @@ export function AppShell() { const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false); - const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); @@ -238,8 +247,17 @@ export function AppShell() { [channels], ); const sidebarChannels = React.useMemo( - () => memberChannels.filter((channel) => channel.archivedAt === null), - [memberChannels], + () => + memberChannels.filter( + (channel) => + channel.archivedAt === null && + shouldShowSidebarChannel( + channel, + huddleBackingChannelIds, + revealedHuddleChannelIds, + ), + ), + [huddleBackingChannelIds, memberChannels, revealedHuddleChannelIds], ); const hasRestoredCommunityDestinationRef = React.useRef(false); React.useEffect(() => { @@ -308,6 +326,7 @@ export function AppShell() { handleThreadReplyDesktopNotification, } = useAppShellDesktopNotifications({ channels, + enabled: !isHuddleRoom, goChannel, goHome, notificationSettings: notificationSettings.settings, @@ -344,19 +363,23 @@ export function AppShell() { mutedRootIds, muteThread, unmuteThread, - } = useUnreadChannels(sidebarChannels, activeChannel, { - pubkey: identityQuery.data?.pubkey, - relayClient, - relayUrl: communitiesHook.activeCommunity?.relayUrl, - currentPubkey: identityQuery.data?.pubkey, - mutedChannelIds, - notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, - onChannelMessage: handleChannelNotification, - onDmMessage: handleDmNotification, - onLiveMention: refetchHomeFeedFromLiveSignal, - onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, - followedRootIds, - }); + } = useUnreadChannels( + isHuddleRoom ? EMPTY_CHANNELS : sidebarChannels, + isHuddleRoom ? null : activeChannel, + { + pubkey: identityQuery.data?.pubkey, + relayClient, + relayUrl: communitiesHook.activeCommunity?.relayUrl, + currentPubkey: identityQuery.data?.pubkey, + mutedChannelIds, + notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing, + onChannelMessage: handleChannelNotification, + onDmMessage: handleDmNotification, + onLiveMention: refetchHomeFeedFromLiveSignal, + onThreadReplyDesktopNotification: handleThreadReplyDesktopNotification, + followedRootIds, + }, + ); const { getThreadReadAt, @@ -397,6 +420,7 @@ export function AppShell() { markChannelRead, unreadThreadFeedItems, ]); + // Badge count consumes the shared NIP-RS read-state from useUnreadChannels. const { homeBadgeCount, homeBadgeCountExcludingHighPriority } = useHomeFeedNotificationState( @@ -404,6 +428,7 @@ export function AppShell() { identityQuery.data?.pubkey, notificationSettings.settings, notificationSettings.setDesktopEnabled, + !isHuddleRoom, selectedView === "home" && !settingsOpen, getChannelReadAt, readStateVersion, @@ -603,18 +628,20 @@ export function AppShell() { [openSearchHit], ); useAppShellLifecycleEffects({ + desktopBadgeEnabled: !isHuddleRoom, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://message` deep links into the router. - useMessageDeepLinks(); + // Dispatch `buzz://message` deep links only from the main window. The + // companion is dedicated to its active Huddle route. + useMessageDeepLinks(!isHuddleRoom); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], ); React.useLayoutEffect(() => { - if (settingsOpen) { + if (settingsOpen || isHuddleRoom) { return; } @@ -674,12 +701,13 @@ export function AppShell() { handleOpenSearch, goNewMessage, goHome, + isHuddleRoom, settingsOpen, ]); useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, - open: settingsOpen, + open: isHuddleRoom ? undefined : settingsOpen, }); useMarkAsReadShortcuts({ activeChannelId: activeChannel?.id ?? null, @@ -690,11 +718,13 @@ export function AppShell() { }); return ( - + {!isHuddleRoom ? ( + + ) : null} - - -
-
- - {hasCommunityRail ? ( - void handleRemoveCommunity(id)} - onReorderCommunities={communitiesHook.reorderCommunities} - onSwitchCommunity={handleSwitchCommunity} - onUpdateCommunity={communitiesHook.updateCommunity} - communities={communitiesHook.communities} - /> - ) : null} - - - {!settingsOpen ? ( - - ) : null} - {settingsOpen ? ( -
- - - -
- ) : ( -
- { - const id = communitiesHook.addCommunity({ - ...community, - pubkey: - community.pubkey ?? - identityQuery.data?.pubkey, - }); - handleSwitchCommunity(id); - }} - onAddCommunityOpenChange={ - addCommunityDialog.onOpenChange - } - onNewMessage={goNewMessage} - 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 - } - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} - starredChannelIds={starredChannelIds} - onStarChannel={starChannel} - onUnstarChannel={unstarChannel} - /> - - - - - - - - -
- )} - - - + {hasCommunityRail && !isHuddleRoom ? ( + void handleRemoveCommunity(id)} + onReorderCommunities={communitiesHook.reorderCommunities} + onSwitchCommunity={handleSwitchCommunity} + onUpdateCommunity={communitiesHook.updateCommunity} + communities={communitiesHook.communities} + /> + ) : null} + + + {!settingsOpen && !isHuddleRoom ? ( + + ) : null} + {settingsOpen ? ( +
+ + { - setIsChannelManagementOpen(open); - if (!open) { - setManagedChannelId(null); - } - }} - onDeleteActiveChannel={() => { - setIsChannelManagementOpen(false); - setManagedChannelId(null); - void goHome({ replace: true }); + notificationErrorMessage={ + notificationSettings.errorMessage + } + notificationPermission={notificationSettings.permission} + notificationSettings={notificationSettings.settings} + onClose={handleCloseSettings} + onSectionChange={handleSettingsSectionChange} + onSetDesktopNotificationsEnabled={ + notificationSettings.setDesktopEnabled + } + onSetHomeBadgeEnabled={ + notificationSettings.setHomeBadgeEnabled + } + onSetSlotAlertsEnabled={ + notificationSettings.setSlotAlertsEnabled + } + onSetNotifyWhileViewing={ + notificationSettings.setNotifyWhileViewing + } + onSetAllSlotAlertsEnabled={ + notificationSettings.setAllSlotAlertsEnabled + } + onSetSoundForSlot={notificationSettings.setSoundForSlot} + section={settingsSection} + /> + +
+ ) : ( +
+ {!isHuddleRoom ? ( + { + const id = communitiesHook.addCommunity({ + ...community, + pubkey: + community.pubkey ?? identityQuery.data?.pubkey, + }); + handleSwitchCommunity(id); }} - onSelectChannel={(channelId) => { - void goChannel(channelId); + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange + } + onNewMessage={goNewMessage} + 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} + onHuddleEnded={handleHuddleEnded} + 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={handleSidebarChannelSelect} + 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 + } + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + unreadChannelCounts={unreadChannelCounts} + mutedChannelIds={mutedChannelIds} + onMuteChannel={muteChannel} + onUnmuteChannel={unmuteChannel} + starredChannelIds={starredChannelIds} + onStarChannel={starChannel} + onUnstarChannel={unstarChannel} /> - + + + {!isHuddleRoom ? ( + - - -
- -
- { - void goChannel(channelId, { - messageId, - threadRootId: messageId, - }); - }} - onVisibilityChange={setIsHuddleDrawerOpen} - /> -
-
- - + ) : null} +
+ )} + + + { + setIsChannelManagementOpen(open); + if (!open) { + setManagedChannelId(null); + } + }} + onDeleteActiveChannel={() => { + setIsChannelManagementOpen(false); + setManagedChannelId(null); + void goHome({ replace: true }); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> + +
+ + diff --git a/desktop/src/app/AppShellChannelSurface.tsx b/desktop/src/app/AppShellChannelSurface.tsx new file mode 100644 index 00000000000..4ab2ea1df34 --- /dev/null +++ b/desktop/src/app/AppShellChannelSurface.tsx @@ -0,0 +1,43 @@ +import type * as React from "react"; +import * as BuzzTheme from "@/app/BuzzThemeSurfaces"; +import { HuddleRoomHeader, HuddleStartingView } from "@/features/huddle"; +import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; +import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; +import { cn } from "@/shared/lib/cn"; +import { SidebarInset } from "@/shared/ui/sidebar"; + +type AppShellChannelSurfaceProps = { + children: React.ReactNode; + isHuddleRoom: boolean; + isHuddleRoomStarting: boolean; + mainInsetRef: React.RefObject; +}; + +export function AppShellChannelSurface({ + children, + isHuddleRoom, + isHuddleRoomStarting, + mainInsetRef, +}: AppShellChannelSurfaceProps) { + return ( + + + {isHuddleRoom && !isHuddleRoomStarting ? : null} + + {isHuddleRoomStarting ? : children} + + + + ); +} diff --git a/desktop/src/app/BuzzThemeSurfaces.tsx b/desktop/src/app/BuzzThemeSurfaces.tsx index 80461f0185c..4976fc2ed8a 100644 --- a/desktop/src/app/BuzzThemeSurfaces.tsx +++ b/desktop/src/app/BuzzThemeSurfaces.tsx @@ -7,6 +7,7 @@ export function GradientLayer() { className="buzz-theme-gradient-layer pointer-events-none absolute inset-0 -z-10" data-buzz-gradient-layer > +
{children}
diff --git a/desktop/src/app/LazySettingsScreen.tsx b/desktop/src/app/LazySettingsScreen.tsx new file mode 100644 index 00000000000..8308ec723bb --- /dev/null +++ b/desktop/src/app/LazySettingsScreen.tsx @@ -0,0 +1,6 @@ +import * as React from "react"; + +export const LazySettingsScreen = React.lazy(async () => { + const module = await import("@/features/settings/ui/SettingsScreen"); + return { default: module.SettingsScreen }; +}); diff --git a/desktop/src/app/huddleBackingChannelStorage.test.mjs b/desktop/src/app/huddleBackingChannelStorage.test.mjs new file mode 100644 index 00000000000..f88c0e1feb7 --- /dev/null +++ b/desktop/src/app/huddleBackingChannelStorage.test.mjs @@ -0,0 +1,33 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +class MemoryStorage { + values = new Map(); + getItem(key) { + return this.values.get(key) ?? null; + } + setItem(key, value) { + this.values.set(key, value); + } +} + +globalThis.window = { localStorage: new MemoryStorage() }; +const { loadHuddleBackingChannelIds, rememberHuddleBackingChannelId } = + await import("./huddleBackingChannelStorage.ts"); + +test("restores remembered Huddle backing channels", () => { + rememberHuddleBackingChannelId("first"); + rememberHuddleBackingChannelId("second"); + rememberHuddleBackingChannelId("first"); + assert.deepEqual([...loadHuddleBackingChannelIds()], ["second", "first"]); +}); + +test("bounds persisted backing channels", () => { + for (let index = 0; index < 105; index += 1) { + rememberHuddleBackingChannelId(`channel-${index}`); + } + const ids = [...loadHuddleBackingChannelIds()]; + assert.equal(ids.length, 100); + assert.equal(ids.at(0), "channel-5"); + assert.equal(ids.at(-1), "channel-104"); +}); diff --git a/desktop/src/app/huddleBackingChannelStorage.ts b/desktop/src/app/huddleBackingChannelStorage.ts new file mode 100644 index 00000000000..44b21a25d24 --- /dev/null +++ b/desktop/src/app/huddleBackingChannelStorage.ts @@ -0,0 +1,36 @@ +const STORAGE_KEY = "buzz:huddle-backing-channel-ids:v1"; +const MAX_TRACKED_CHANNELS = 100; + +function readStoredIds(): string[] { + try { + const value = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "[]"); + return Array.isArray(value) + ? value.filter((id): id is string => typeof id === "string") + : []; + } catch { + return []; + } +} + +/** Restores Huddle implementation channels after an abnormal app restart. */ +export function loadHuddleBackingChannelIds(): ReadonlySet { + return new Set(readStoredIds()); +} + +/** + * Remembers a backing channel beyond the native Huddle process lifetime. + * Relay archive/removal can lag, so IDs remain hidden if the app crashes or is + * force-quit. The bounded list prevents abandoned local state growing forever. + */ +export function rememberHuddleBackingChannelId(channelId: string): void { + const ids = readStoredIds().filter((id) => id !== channelId); + ids.push(channelId); + try { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify(ids.slice(-MAX_TRACKED_CHANNELS)), + ); + } catch { + // Storage can be unavailable in browser previews or locked-down webviews. + } +} diff --git a/desktop/src/app/huddleChannelVisibility.test.mjs b/desktop/src/app/huddleChannelVisibility.test.mjs new file mode 100644 index 00000000000..b08306e9e7b --- /dev/null +++ b/desktop/src/app/huddleChannelVisibility.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + isHuddleBackingChannel, + shouldShowSidebarChannel, +} from "./huddleChannelVisibility.ts"; + +function channel(overrides = {}) { + return { + id: "channel-id", + name: "general", + ttlSeconds: null, + ...overrides, + }; +} + +test("ordinary channels stay visible without an explicit reveal", () => { + assert.equal(shouldShowSidebarChannel(channel(), new Set(), new Set()), true); +}); + +test("tracked huddle backing channels stay hidden by default", () => { + const huddle = channel({ + id: "stale-huddle", + name: "general huddle", + ttlSeconds: 3_600, + }); + const huddleBackingChannelIds = new Set([huddle.id]); + + assert.equal(isHuddleBackingChannel(huddle, huddleBackingChannelIds), true); + assert.equal( + shouldShowSidebarChannel(huddle, huddleBackingChannelIds, new Set()), + false, + ); +}); + +test("an explicitly revealed huddle channel appears in the sidebar", () => { + const huddle = channel({ + id: "active-huddle", + name: "huddle", + ttlSeconds: 3_600, + }); + + assert.equal( + shouldShowSidebarChannel( + huddle, + new Set([huddle.id]), + new Set([huddle.id]), + ), + true, + ); +}); + +test("one-hour channels with huddle-shaped names remain ordinary", () => { + const ordinaryChannel = channel({ + name: "design huddle", + ttlSeconds: 3_600, + }); + + assert.equal(isHuddleBackingChannel(ordinaryChannel, new Set()), false); + assert.equal( + shouldShowSidebarChannel(ordinaryChannel, new Set(), new Set()), + true, + ); +}); diff --git a/desktop/src/app/huddleChannelVisibility.ts b/desktop/src/app/huddleChannelVisibility.ts new file mode 100644 index 00000000000..cee0717bee5 --- /dev/null +++ b/desktop/src/app/huddleChannelVisibility.ts @@ -0,0 +1,19 @@ +import type { Channel } from "@/shared/api/types"; + +export function isHuddleBackingChannel( + channel: Channel, + huddleBackingChannelIds: ReadonlySet, +): boolean { + return huddleBackingChannelIds.has(channel.id); +} + +export function shouldShowSidebarChannel( + channel: Channel, + huddleBackingChannelIds: ReadonlySet, + revealedHuddleChannelIds: ReadonlySet, +): boolean { + return ( + !isHuddleBackingChannel(channel, huddleBackingChannelIds) || + revealedHuddleChannelIds.has(channel.id) + ); +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index d19ac031201..5afd0e7e4ba 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -171,6 +171,8 @@ export function useAppNavigation() { autoSend?: string; messageId?: string; replace?: boolean; + /** Open this thread panel directly without waiting for a timeline row. */ + thread?: string; threadRootId?: string | null; }, ) => @@ -190,6 +192,7 @@ export function useAppNavigation() { ...(options?.agentSession ? { agentSession: options.agentSession } : {}), + ...(options?.thread ? { thread: options.thread } : {}), ...(options?.autoSend ? { autoSend: options.autoSend } : {}), }, }, diff --git a/desktop/src/app/routes/ChannelRouteScreen.tsx b/desktop/src/app/routes/ChannelRouteScreen.tsx index 9e689ab507c..d626179ebb4 100644 --- a/desktop/src/app/routes/ChannelRouteScreen.tsx +++ b/desktop/src/app/routes/ChannelRouteScreen.tsx @@ -4,6 +4,8 @@ import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useChannelsQuery } from "@/features/channels/hooks"; import { ChannelScreen } from "@/features/channels/ui/ChannelScreen"; +import { HuddleStartingView } from "@/features/huddle/components/HuddleStartingView"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; import { getThreadReference, isBroadcastReply, @@ -102,6 +104,7 @@ export function ChannelRouteScreen({ targetReplyId, targetThreadRootId, }: ChannelRouteScreenProps) { + const isHuddleTranscript = huddleWindowChannelId() !== null; const { closeForumPost, goForumPost } = useAppNavigation(); const channelsQuery = useChannelsQuery(); const identityQuery = useIdentityQuery(); @@ -186,6 +189,9 @@ export function ChannelRouteScreen({ }, [selectedPostId, targetMessageId, targetThreadRootId]); if (channelsQuery.isPending && !activeChannel) { + if (isHuddleTranscript) { + return ; + } return ( { function ChannelRouteComponent() { const { channelId } = Route.useParams(); const search = Route.useSearch(); + const isHuddleTranscript = huddleWindowChannelId() !== null; return ( } + fallback={ + isHuddleTranscript ? ( + + ) : ( + + ) + } > Promise; goHome: () => Promise; notificationSettings: NotificationSettings; @@ -42,6 +44,7 @@ export function useAppShellDesktopNotifications({ }) { const handleChannelNotification = React.useEffectEvent( (_channelId: string, event: RelayEvent) => { + if (!enabled) return; if (!shouldBounceForChannelNotification(event.tags)) return; if (!notificationSettings.desktopEnabled) return; void requestDockBounce(); @@ -50,6 +53,7 @@ export function useAppShellDesktopNotifications({ const handleDmNotification = React.useEffectEvent( (event: RelayEvent, channel: Channel) => { + if (!enabled) return; if ( !notificationSettings.desktopEnabled || !notificationSettings.slotAlertsEnabled.dm @@ -84,6 +88,7 @@ export function useAppShellDesktopNotifications({ const handleThreadReplyDesktopNotification = React.useEffectEvent( (channelId: string, event: RelayEvent) => { + if (!enabled) return; if ( !notificationSettings.desktopEnabled || !notificationSettings.slotAlertsEnabled.thread_reply @@ -151,6 +156,7 @@ export function useAppShellDesktopNotifications({ ); React.useEffect(() => { + if (!enabled) return; let isCancelled = false; let cleanup = () => {}; @@ -173,7 +179,7 @@ export function useAppShellDesktopNotifications({ isCancelled = true; cleanup(); }; - }, []); + }, [enabled]); return { handleChannelNotification, diff --git a/desktop/src/app/useAppShellLifecycleEffects.ts b/desktop/src/app/useAppShellLifecycleEffects.ts index cc0447cc32b..02c97bac57b 100644 --- a/desktop/src/app/useAppShellLifecycleEffects.ts +++ b/desktop/src/app/useAppShellLifecycleEffects.ts @@ -4,12 +4,14 @@ import { setDesktopAppBadge } from "@/features/notifications/lib/desktop"; import { relayClient } from "@/shared/api/relayClient"; type AppShellLifecycleEffectsOptions = { + desktopBadgeEnabled: boolean; homeBadgeCountExcludingHighPriority: number; unreadChannelIds: ReadonlySet; unreadChannelNotificationCount: number; }; export function useAppShellLifecycleEffects({ + desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, @@ -64,6 +66,10 @@ export function useAppShellLifecycleEffects({ }, []); React.useEffect(() => { + if (!desktopBadgeEnabled) { + return; + } + const count = unreadChannelNotificationCount + homeBadgeCountExcludingHighPriority; void setDesktopAppBadge( @@ -72,6 +78,7 @@ export function useAppShellLifecycleEffects({ : { kind: unreadChannelIds.size ? "dot" : "none" }, ); }, [ + desktopBadgeEnabled, homeBadgeCountExcludingHighPriority, unreadChannelIds, unreadChannelNotificationCount, diff --git a/desktop/src/app/useHuddlePresentation.ts b/desktop/src/app/useHuddlePresentation.ts new file mode 100644 index 00000000000..ddc8674714c --- /dev/null +++ b/desktop/src/app/useHuddlePresentation.ts @@ -0,0 +1,444 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useLocation } from "@tanstack/react-router"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import * as React from "react"; +import { + loadHuddleBackingChannelIds, + rememberHuddleBackingChannelId, +} from "@/app/huddleBackingChannelStorage"; +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { channelsQueryKey } from "@/features/channels/hooks"; +import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { + channelMessagesKey, + channelWindowKey, +} from "@/features/messages/lib/messageQueryKeys"; + +type HuddleTranscriptRouteState = { + phase: + | "idle" + | "creating" + | "connecting" + | "connected" + | "active" + | "leaving"; + parent_channel_id: string | null; + ephemeral_channel_id: string | null; + huddle_thread_event_id: string | null; +}; + +export function useHuddlePresentation() { + const huddleRoomChannelId = huddleWindowChannelId(); + const isHuddleRoom = huddleRoomChannelId !== null; + const [isHuddleDrawerOpen, setIsHuddleDrawerOpen] = React.useState(false); + const [isHuddleCompanionOpen, setIsHuddleCompanionOpen] = + React.useState(false); + const [isHuddleStartPending, setIsHuddleStartPending] = React.useState(false); + const [revealedHuddleChannelIds, setRevealedHuddleChannelIds] = + React.useState>(() => new Set()); + const [huddleBackingChannelIds, setHuddleBackingChannelIds] = React.useState< + ReadonlySet + >(loadHuddleBackingChannelIds); + const activeHuddleChannelIdRef = React.useRef(null); + const huddleCompanionChannelIdRef = React.useRef(null); + const huddleCompanionDismissedChannelIdRef = React.useRef( + null, + ); + const huddleCompanionOpenPromiseRef = React.useRef | null>( + null, + ); + const activeHuddleParentChannelIdRef = React.useRef(null); + const [huddleTranscriptRoute, setHuddleTranscriptRoute] = + React.useState(null); + const location = useLocation(); + const queryClient = useQueryClient(); + const { goChannel } = useAppNavigation(); + + React.useEffect(() => { + if (!isHuddleRoom) return; + + let cancelled = false; + let unlisten: (() => void) | null = null; + const syncRoute = (state: HuddleTranscriptRouteState) => { + if (!cancelled) setHuddleTranscriptRoute(state); + }; + + void invoke("get_huddle_state") + .then(syncRoute) + .catch((error) => { + console.error("Failed to resolve huddle transcript route:", error); + if (!cancelled) { + setHuddleTranscriptRoute({ + ephemeral_channel_id: huddleRoomChannelId, + huddle_thread_event_id: null, + parent_channel_id: null, + phase: "active", + }); + } + }); + void listen("huddle-state-changed", (event) => + syncRoute(event.payload), + ).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + + return () => { + cancelled = true; + unlisten?.(); + }; + }, [huddleRoomChannelId, isHuddleRoom]); + + const huddleRouteResolved = huddleTranscriptRoute !== null; + const huddleRouteEphemeralChannelId = + huddleTranscriptRoute?.ephemeral_channel_id ?? null; + const huddleRouteIsActive = huddleTranscriptRoute?.phase === "active"; + const huddleRouteDestinationChannelId = + huddleRouteEphemeralChannelId ?? huddleRoomChannelId; + const huddleRouteMatchesLocation = Boolean( + huddleRouteDestinationChannelId && + location.pathname === `/channels/${huddleRouteDestinationChannelId}`, + ); + const isHuddleRoomStarting = + isHuddleRoom && + (!huddleRouteResolved || + !huddleRouteIsActive || + !huddleRouteMatchesLocation); + + React.useEffect(() => { + if (!huddleRoomChannelId || !huddleRouteResolved || !huddleRouteIsActive) { + return; + } + + let cancelled = false; + const channelId = huddleRouteEphemeralChannelId ?? huddleRoomChannelId; + void Promise.all([ + queryClient.invalidateQueries({ queryKey: channelsQueryKey }), + queryClient.invalidateQueries({ + queryKey: channelMessagesKey(channelId), + }), + queryClient.invalidateQueries({ queryKey: channelWindowKey(channelId) }), + ]).then(() => { + if (!cancelled) void goChannel(channelId, { replace: true }); + }); + + return () => { + cancelled = true; + }; + }, [ + goChannel, + huddleRoomChannelId, + huddleRouteEphemeralChannelId, + huddleRouteIsActive, + huddleRouteResolved, + queryClient, + ]); + + const handleHuddleStartPendingChange = React.useCallback( + (pending: boolean) => { + setIsHuddleStartPending(pending); + if (pending) setIsHuddleDrawerOpen(false); + }, + [], + ); + const handleHuddleVisibilityChange = React.useCallback( + (visible: boolean) => { + setIsHuddleDrawerOpen( + visible && !isHuddleStartPending && !isHuddleCompanionOpen, + ); + }, + [isHuddleCompanionOpen, isHuddleStartPending], + ); + const hideHuddleChannel = React.useCallback( + (ephemeralChannelId: string | null | undefined) => { + if (!ephemeralChannelId) return; + setRevealedHuddleChannelIds((current) => { + if (!current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.delete(ephemeralChannelId); + return next; + }); + }, + [], + ); + const trackHuddleBackingChannel = React.useCallback( + (ephemeralChannelId: string) => { + rememberHuddleBackingChannelId(ephemeralChannelId); + setHuddleBackingChannelIds((current) => { + if (current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.add(ephemeralChannelId); + return next; + }); + }, + [], + ); + const revealHuddleChannel = React.useCallback( + (ephemeralChannelId: string) => { + setRevealedHuddleChannelIds((current) => { + if (current.has(ephemeralChannelId)) return current; + const next = new Set(current); + next.add(ephemeralChannelId); + return next; + }); + }, + [], + ); + const returnMainWindowToHuddleParent = React.useCallback( + (state: HuddleTranscriptRouteState) => { + const ephemeralChannelId = state.ephemeral_channel_id; + const parentChannelId = state.parent_channel_id; + if (parentChannelId) { + activeHuddleParentChannelIdRef.current = parentChannelId; + } + if ( + ephemeralChannelId && + parentChannelId && + location.pathname === `/channels/${ephemeralChannelId}` + ) { + void goChannel(parentChannelId, { replace: true }); + } + }, + [goChannel, location.pathname], + ); + const handleHuddleCompanionOpen = React.useCallback(() => { + const ephemeralChannelId = activeHuddleChannelIdRef.current; + huddleCompanionDismissedChannelIdRef.current = null; + hideHuddleChannel(ephemeralChannelId); + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(true); + + const parentChannelId = activeHuddleParentChannelIdRef.current; + if ( + ephemeralChannelId && + parentChannelId && + location.pathname === `/channels/${ephemeralChannelId}` + ) { + void goChannel(parentChannelId, { replace: true }); + return; + } + + void invoke("get_huddle_state") + .then(returnMainWindowToHuddleParent) + .catch((error) => { + console.error("Failed to restore the huddle parent channel:", error); + }); + }, [ + goChannel, + hideHuddleChannel, + location.pathname, + returnMainWindowToHuddleParent, + ]); + const openHuddleCompanion = React.useCallback( + (ephemeralChannelId: string) => { + activeHuddleChannelIdRef.current = ephemeralChannelId; + trackHuddleBackingChannel(ephemeralChannelId); + + if (huddleCompanionDismissedChannelIdRef.current === ephemeralChannelId) { + return Promise.resolve(); + } + + huddleCompanionDismissedChannelIdRef.current = null; + hideHuddleChannel(ephemeralChannelId); + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(true); + + if ( + huddleCompanionChannelIdRef.current === ephemeralChannelId && + huddleCompanionOpenPromiseRef.current + ) { + return huddleCompanionOpenPromiseRef.current; + } + + huddleCompanionChannelIdRef.current = ephemeralChannelId; + const openPromise = invoke("open_huddle_window").catch((error) => { + if (huddleCompanionChannelIdRef.current === ephemeralChannelId) { + huddleCompanionChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + } + throw error; + }); + huddleCompanionOpenPromiseRef.current = openPromise; + return openPromise; + }, + [hideHuddleChannel, trackHuddleBackingChannel], + ); + const handleHuddleStarted = React.useCallback( + async (ephemeralChannelId: string) => { + try { + await openHuddleCompanion(ephemeralChannelId); + } catch (error) { + revealHuddleChannel(ephemeralChannelId); + throw error; + } + }, + [openHuddleCompanion, revealHuddleChannel], + ); + const viewHuddleChannel = React.useCallback( + (ephemeralChannelId: string) => { + revealHuddleChannel(ephemeralChannelId); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: channelMessagesKey(ephemeralChannelId), + }); + void queryClient.invalidateQueries({ + queryKey: channelWindowKey(ephemeralChannelId), + }); + void goChannel(ephemeralChannelId); + }, + [goChannel, queryClient, revealHuddleChannel], + ); + const showHuddleInMainApp = React.useCallback( + (ephemeralChannelId: string) => { + activeHuddleChannelIdRef.current = ephemeralChannelId; + trackHuddleBackingChannel(ephemeralChannelId); + viewHuddleChannel(ephemeralChannelId); + }, + [trackHuddleBackingChannel, viewHuddleChannel], + ); + const handleSidebarChannelSelect = React.useCallback( + (channelId: string) => { + if ( + isHuddleDrawerOpen && + channelId === activeHuddleChannelIdRef.current + ) { + showHuddleInMainApp(channelId); + return; + } + void goChannel(channelId); + }, + [goChannel, isHuddleDrawerOpen, showHuddleInMainApp], + ); + const handleHuddleEnded = React.useCallback( + (ephemeralChannelId: string | null) => { + const endedChannelId = + ephemeralChannelId ?? activeHuddleChannelIdRef.current; + hideHuddleChannel(endedChannelId); + activeHuddleChannelIdRef.current = null; + huddleCompanionChannelIdRef.current = null; + huddleCompanionDismissedChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + }, + [hideHuddleChannel, queryClient], + ); + + React.useEffect(() => { + if (isHuddleRoom) return; + + let cancelled = false; + let unlisten: (() => void) | null = null; + void listen("huddle-companion-returned", () => { + if (cancelled) return; + huddleCompanionDismissedChannelIdRef.current = + activeHuddleChannelIdRef.current; + huddleCompanionChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleCompanionOpen(false); + setIsHuddleDrawerOpen(true); + void invoke("get_huddle_state") + .then((state) => { + if (!state.ephemeral_channel_id) return; + if (state.parent_channel_id) { + activeHuddleParentChannelIdRef.current = state.parent_channel_id; + } + showHuddleInMainApp(state.ephemeral_channel_id); + }) + .catch((error) => { + console.error("Failed to open huddle in the main app:", error); + }); + }).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [isHuddleRoom, showHuddleInMainApp]); + + React.useEffect(() => { + let cancelled = false; + let unlisten: (() => void) | null = null; + void invoke("get_huddle_state") + .then((state) => { + if (cancelled || !state.ephemeral_channel_id) return; + activeHuddleChannelIdRef.current = state.ephemeral_channel_id; + trackHuddleBackingChannel(state.ephemeral_channel_id); + if (state.parent_channel_id) { + activeHuddleParentChannelIdRef.current = state.parent_channel_id; + } + }) + .catch(() => { + /* lifecycle events remain authoritative */ + }); + listen("huddle-state-changed", (event) => { + if (cancelled) return; + if (event.payload.ephemeral_channel_id) { + activeHuddleChannelIdRef.current = event.payload.ephemeral_channel_id; + trackHuddleBackingChannel(event.payload.ephemeral_channel_id); + } + if (event.payload.parent_channel_id) { + activeHuddleParentChannelIdRef.current = + event.payload.parent_channel_id; + } + if ( + !isHuddleRoom && + event.payload.phase === "creating" && + event.payload.ephemeral_channel_id + ) { + void openHuddleCompanion(event.payload.ephemeral_channel_id).catch( + (error) => { + console.error("Failed to open starting huddle window:", error); + }, + ); + } + if (event.payload.phase === "idle") { + hideHuddleChannel(activeHuddleChannelIdRef.current); + activeHuddleChannelIdRef.current = null; + activeHuddleParentChannelIdRef.current = null; + huddleCompanionChannelIdRef.current = null; + huddleCompanionDismissedChannelIdRef.current = null; + huddleCompanionOpenPromiseRef.current = null; + setIsHuddleDrawerOpen(false); + setIsHuddleCompanionOpen(false); + setIsHuddleStartPending(false); + void queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + } + }).then((cleanup) => { + if (cancelled) cleanup(); + else unlisten = cleanup; + }); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [ + hideHuddleChannel, + isHuddleRoom, + openHuddleCompanion, + queryClient, + trackHuddleBackingChannel, + ]); + + return { + handleHuddleCompanionOpen, + handleHuddleEnded, + handleHuddleStartPendingChange, + handleHuddleStarted, + handleHuddleVisibilityChange, + handleSidebarChannelSelect, + huddleBackingChannelIds, + revealedHuddleChannelIds, + isHuddleCompanionOpen, + isHuddleDrawerOpen, + isHuddleRoom, + isHuddleRoomStarting, + isHuddleStartPending, + showHuddleInMainApp, + viewHuddleChannel, + }; +} diff --git a/desktop/src/app/useSettingsShortcuts.ts b/desktop/src/app/useSettingsShortcuts.ts index 0d0813a35e5..757ddc65e1d 100644 --- a/desktop/src/app/useSettingsShortcuts.ts +++ b/desktop/src/app/useSettingsShortcuts.ts @@ -5,7 +5,7 @@ import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; type UseSettingsShortcutsOptions = { onClose: () => void; onOpenSettings: () => void; - open: boolean; + open?: boolean; }; export function useSettingsShortcuts({ @@ -14,6 +14,8 @@ export function useSettingsShortcuts({ open, }: UseSettingsShortcutsOptions) { React.useLayoutEffect(() => { + if (open === undefined) return; + function handleKeyDown(event: KeyboardEvent) { const isSettingsShortcut = hasPrimaryShortcutModifier(event) && diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 122c872e54c..b39a7e8ec8b 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -33,6 +33,7 @@ import { getManagedAgentLog, getRuntimeFileConfig, installAcpRuntime, + invokeTauri, listManagedAgents, listRelayAgents, saveCustomHarness, @@ -647,6 +648,12 @@ export function useAttachManagedAgentToChannelMutation( pubkey: result.agent.pubkey, }), ); + void invokeTauri("sync_agents_to_active_huddle", { + channelId: effectiveChannelId, + agentPubkeys: [result.agent.pubkey], + }).catch((error) => { + console.warn("Could not sync attached agent into Huddle:", error); + }); }, onSettled: (_data, _err, variables) => { // Invalidate the effective channel (the one the server actually mutated) diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9003d0f5a58..8829edab399 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -14,6 +14,7 @@ import { joinChannel, leaveChannel, openDm, + invokeTauri, removeChannelMember, setCanvas, setChannelPurpose, @@ -517,6 +518,21 @@ export function useAddChannelMembersMutation(channelId: string | null) { return addChannelMembers({ ...rest, channelId: effectiveChannelId }); }, + onSuccess: (result, variables) => { + const effectiveChannelId = variables.channelId ?? channelId; + if ( + effectiveChannelId && + variables.role === "bot" && + result.added.length > 0 + ) { + void invokeTauri("sync_agents_to_active_huddle", { + channelId: effectiveChannelId, + agentPubkeys: result.added, + }).catch((error) => { + console.warn("Could not sync added agents into Huddle:", error); + }); + } + }, onSettled: async (_data, _err, variables) => { // Invalidate the effective channel (the one actually mutated) not the // live hook-closure channel, which may have changed mid-send. diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index 7b9bf2b79f1..2debd9e7a9b 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -165,8 +165,8 @@ export function ChannelMembersBar({ members, }), ); - // Refetch channels so the new ephemeral channel appears in the sidebar immediately - // (default poll interval is 60s — too slow for huddle UX). + // Keep the channel cache current so the ephemeral transcript is + // available immediately if the huddle returns to the in-app drawer. void queryClient.invalidateQueries({ queryKey: ["channels"] }); } catch (e) { console.error("Failed to start huddle:", e); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index a30ee241140..cb0600a28ae 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -43,6 +43,20 @@ export function isWelcomeSetupSystemMessage(message: TimelineMessage) { } } +export function isChannelCreatedSystemMessage(message: TimelineMessage) { + if (message.kind !== KIND_SYSTEM_MESSAGE) { + return false; + } + + try { + return ( + (JSON.parse(message.body) as { type?: string }).type === "channel_created" + ); + } catch { + return false; + } +} + export function mentionsKnownAgent( mentionPubkeys: string[], knownAgentPubkeys: ReadonlySet, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 770c45e4452..54c0dcd5d9d 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -48,16 +48,14 @@ import { WELCOME_PERSONA_ROTATION_MS, type WelcomeComposerBannerState, } from "@/features/channels/ui/WelcomeComposerBanner"; -import { - isWelcomeSetupSystemMessage, - mentionsKnownAgent, -} from "@/features/channels/ui/ChannelPane.helpers"; +import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; +import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; import { usePrepareDmSendChannel } from "@/features/channels/ui/usePrepareDmSendChannel"; +import { useChannelPaneMessages } from "@/features/channels/ui/useChannelPaneMessages"; import { Button } from "@/shared/ui/button"; -import { buildMainTimelineEntries } from "@/features/messages/lib/threadPanel"; import { useRenderScopedReactionHydration } from "@/features/messages/lib/useRenderScopedReactionHydration"; import type { TimelineMessage } from "@/features/messages/types"; import { isWelcomeExperienceChannel as isWelcomeExperience } from "@/features/onboarding/welcome"; @@ -65,6 +63,12 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; + +const HUDDLE_TRANSCRIPT_ROOT_STYLE = { + "--buzz-channel-content-top-padding": "0rem", + "--channel-top-chrome-height": "0.25rem", +} as React.CSSProperties; + export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -83,6 +87,7 @@ export const ChannelPane = React.memo(function ChannelPane({ hasOlderMessages, historyExhausted, isFetchingOlder, + isHuddleTranscript = false, followThreadById, isFollowingThread, isFollowingThreadById, @@ -191,13 +196,8 @@ export const ChannelPane = React.memo(function ChannelPane({ channelPaneMountedRef.current = false; }; }, []); - // Clear the ?autoSend search param once the auto-submit fires so - // back-navigation cannot re-trigger the send. - // When `onAutoSendComplete` is provided it does a surgical single-key clear - // that preserves `?thread` and all other panel search state (required for - // the thread-draft send path so the thread panel does not unmount before the - // deferred setTimeout(0) submit fires). The goChannel fallback is kept for - // callers that do not supply the prop (e.g. isolated tests / older wrappers). + // Clear only the auto-send key so thread state survives deferred submission; + // older wrappers fall back to goChannel to prevent back-navigation replay. const handleAutoSubmitComplete = React.useCallback(() => { if (onAutoSendComplete) { onAutoSendComplete(); @@ -409,8 +409,6 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel?.id ?? null, ); const hasComposerBotActivity = composerWorkingBotPubkeys.length > 0; - // Background card mints surface in the same rail ("Minting card…" chip), - // so they must also reserve the activity row. const hasCardMintActivity = useCardMintJobs().length > 0; const hasComposerBottomActivity = hasComposerBotActivity || hasTypingActivity || hasCardMintActivity; @@ -444,7 +442,7 @@ export const ChannelPane = React.memo(function ChannelPane({ messageTimelineRef.current?.scrollToBottomOnNextUpdate(), }); }, [onAddAgent]); - const channelIntro = useChannelIntro({ + const standardChannelIntro = useChannelIntro({ activeChannel, onAddAgent, onBrowseChannels, @@ -452,23 +450,14 @@ export const ChannelPane = React.memo(function ChannelPane({ onOpenMembers, onWelcomeAddAgent: onAddAgent ? handleWelcomeAddAgent : undefined, }); - const visibleMessages = React.useMemo(() => { - if (!isWelcomeExperience(activeChannel)) { - return messages; - } - - return messages.filter((message) => !isWelcomeSetupSystemMessage(message)); - }, [activeChannel, messages]); - const mainTimelineEntries = React.useMemo( - () => - buildMainTimelineEntries( - visibleMessages, - new Set(), - threadSummaries, - profiles, - ), - [profiles, threadSummaries, visibleMessages], - ); + const channelIntro = isHuddleTranscript ? null : standardChannelIntro; + const { mainTimelineEntries, visibleMessages } = useChannelPaneMessages({ + activeChannel, + isHuddleTranscript, + messages, + profiles, + threadSummaries, + }); useRenderScopedReactionHydration({ activeChannel, mainTimelineEntries, @@ -585,9 +574,14 @@ export const ChannelPane = React.memo(function ChannelPane({ isSinglePanelView, useSplitAuxiliaryPane, }); + const timelineReplyHandler = + activeChannel?.archivedAt || isHuddleTranscript ? undefined : onOpenThread; return ( -
- {!isSinglePanelView ? ( +
+ {!isSinglePanelView && !isHuddleTranscript ? (