diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..9d2d6e90b2 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -52,6 +52,8 @@ mod project_terminal; mod qr_download; mod relay_members; mod relay_reconnect; +mod remote_agent_connect; +mod remote_agent_discovery; mod social; mod team_snapshot; mod teams; @@ -103,6 +105,8 @@ pub use project_terminal::*; pub use qr_download::*; pub use relay_members::*; pub use relay_reconnect::*; +pub use remote_agent_connect::*; +pub use remote_agent_discovery::*; pub use social::*; pub use team_snapshot::*; pub use teams::*; diff --git a/desktop/src-tauri/src/commands/remote_agent_connect.rs b/desktop/src-tauri/src/commands/remote_agent_connect.rs new file mode 100644 index 0000000000..4798941427 --- /dev/null +++ b/desktop/src-tauri/src/commands/remote_agent_connect.rs @@ -0,0 +1,305 @@ +//! Connecting a self-hosted agent — one that already runs on a machine the +//! user owns, supervises itself, and holds its own key. +//! +//! This is *connect*, not create. Every other agent path in Buzz mints an +//! identity, writes a key, and takes responsibility for a process. Here Buzz +//! learns about an identity that already exists and records where it lives. +//! The result is a [`ConnectedAgentRecord`] in its own store — a type with no +//! key, no command, and no pid, which is what keeps it out of every spawn, +//! deploy, auto-start, profile-republish, and tombstone path. Those paths take +//! `ManagedAgentRecord`, so they cannot receive one of these by construction +//! rather than by filtering. +//! +//! Three things this deliberately does not do: +//! +//! - **No key transport.** The agent's nsec never crosses the network, is +//! never requested, and is never stored. Buzz holds the public half only. +//! - **No published claim.** Connecting does not emit an owner-signed +//! kind:30177 "I manage this agent" event. That event is what +//! `delete_managed_agent` tombstones, so publishing it would let Buzz +//! assert — and later revoke — the directory entry for an agent it cannot +//! restart. A self-hosted agent's directory presence is its own replaceable +//! kind:10100, signed with the key Buzz has never held. +//! - **No lifecycle.** There is no start, stop, restart, or deploy here, and +//! `disconnect` removes Buzz's local pointer without touching the remote +//! process. Disconnecting an agent that is happily running is expected to +//! leave it running. + +use tauri::{AppHandle, Manager}; + +use nostr::nips::nip19::FromBech32; + +use crate::app_state::AppState; +use crate::managed_agents::ssh_config::parse_ssh_config; +use crate::managed_agents::storage::{load_agent_definitions, load_managed_agents}; +use crate::managed_agents::{ + load_connected_agents, save_connected_agents, ConnectedAgentRecord, ConnectedAgentSummary, +}; +use crate::util::now_iso; + +/// Longest accepted local label. Matches nothing on the wire — this name is +/// Buzz-local, so the limit only needs to keep the list readable. +const MAX_CONNECTED_NAME_LEN: usize = 64; + +/// Normalize a user-supplied agent pubkey to 64-char lowercase hex. +/// +/// Both `npub1…` and bare hex are accepted because both are things a user +/// legitimately has on hand: `npub` is what an agent's own tooling prints, +/// hex is what appears in event tags and relay queries. Normalizing at this +/// one boundary means the stored record and every comparison downstream sees +/// a single form — a mixed-case hex duplicate of an already-connected agent +/// would otherwise slip past the collision check below. +pub(crate) fn normalize_agent_pubkey(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("agent pubkey is required".to_string()); + } + if let Some(stripped) = trimmed.strip_prefix("nsec") { + // Refuse loudly and specifically. A user who pastes a secret key here + // has made a serious mistake, and "invalid pubkey" would not tell them + // what it was. The value itself is never echoed back. + let _ = stripped; + return Err( + "that is a secret key (nsec), not a pubkey — a self-hosted agent's secret must \ + never leave its own machine. Paste the agent's npub instead." + .to_string(), + ); + } + let parsed = if trimmed.starts_with("npub") { + nostr::PublicKey::from_bech32(trimmed) + .map_err(|_| "invalid npub — check for a truncated or mistyped value".to_string())? + } else { + nostr::PublicKey::from_hex(trimmed).map_err(|_| { + "invalid agent pubkey — expected an npub or 64 hex characters".to_string() + })? + }; + Ok(parsed.to_hex()) +} + +/// Validate the Buzz-local label for a connected agent. +pub(crate) fn validate_connected_name(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + return Err("agent name is required".to_string()); + } + if trimmed.chars().count() > MAX_CONNECTED_NAME_LEN { + return Err(format!( + "agent name must be at most {MAX_CONNECTED_NAME_LEN} characters" + )); + } + if trimmed.chars().any(|c| c.is_control()) { + return Err("agent name must not contain control characters".to_string()); + } + Ok(trimmed.to_string()) +} + +/// Resolve a host alias against the user's own `~/.ssh/config`. +/// +/// Requiring a real alias is not gratuitous strictness. The host is a probe +/// target: `probe_agent_host` re-resolves it through this same parsed config +/// and refuses anything it cannot find, so a free-form host string would +/// produce a connected agent whose reachability could never be reported — a +/// row that silently never works. Failing at connect time, with the fix named, +/// is the honest alternative. +fn resolve_connect_host(host: &str) -> Result { + let trimmed = host.trim(); + if trimmed.is_empty() { + return Err("host is required".to_string()); + } + let known = parse_ssh_config(); + known + .iter() + .find(|candidate| candidate.host == trimmed) + .map(|candidate| candidate.host.clone()) + .ok_or_else(|| { + format!( + "'{trimmed}' is not a Host in ~/.ssh/config. Add a stanza for it (Buzz reaches \ + self-hosted agents through your own ssh config) and try again." + ) + }) +} + +/// List the self-hosted agents this machine is connected to. +#[tauri::command] +pub async fn list_connected_agents(app: AppHandle) -> Result, String> { + tokio::task::spawn_blocking(move || { + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + let records = load_connected_agents(&app)?; + Ok(records.iter().map(ConnectedAgentSummary::from).collect()) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +/// Record a self-hosted agent that already runs on `host`. +/// +/// `harness` is the id observed by the host probe (e.g. `"claude"`). It is +/// stored as an observation for display; nothing in Buzz executes it. +#[tauri::command] +pub async fn connect_remote_agent( + host: String, + pubkey: String, + name: String, + harness: Option, + app: AppHandle, +) -> Result { + tokio::task::spawn_blocking(move || { + // Validate everything before taking the store lock: none of these + // checks need the store, and a bad input should not serialize behind + // an unrelated agent save. + let host = resolve_connect_host(&host)?; + let pubkey = normalize_agent_pubkey(&pubkey)?; + let name = validate_connected_name(&name)?; + let harness = harness.and_then(|value| { + let trimmed = value.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }); + + let state = app.state::(); + + // Connecting your own identity would make you an agent that replies to + // your own messages. The relay- and desktop-side loop guards key off + // author identity, so this is the one collision they cannot help with. + // An unavailable identity is not a reason to block the connect. + if let Ok(keys) = state.signing_keys() { + if keys.public_key().to_hex() == pubkey { + return Err( + "that is your own pubkey. Connect the agent's identity, not yours — \ + an agent sharing your key would answer your own messages." + .to_string(), + ); + } + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + // Collision checks span BOTH stores. Separating the stores is what makes + // the lifecycle exclusion structural, but uniqueness is the one property + // that does not partition: one identity with a record in each store + // would be two answers to "who is this pubkey", and two agents sharing a + // name would be ambiguous at every mention site. + let connected = load_connected_agents(&app)?; + if let Some(clash) = connected.iter().find(|record| record.pubkey == pubkey) { + return Err(format!( + "that agent is already connected as '{}' on {}", + clash.name, clash.host + )); + } + + // Both halves of `managed-agents.json`: keyed instances and the key-less + // definitions folded into the same file. A definition's name is just as + // mentionable, so checking only instances would let a connect shadow one. + let managed = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + if let Some(clash) = managed + .iter() + .chain(definitions.iter()) + .find(|record| record.pubkey == pubkey) + { + return Err(format!( + "'{}' is an agent Buzz already manages on this machine — it holds that agent's \ + key, so it cannot also be connected as self-hosted", + clash.name + )); + } + + let name_taken = connected + .iter() + .any(|record| record.name.eq_ignore_ascii_case(&name)) + || managed + .iter() + .chain(definitions.iter()) + .any(|record| record.name.eq_ignore_ascii_case(&name)); + if name_taken { + return Err(format!( + "an agent named '{name}' already exists — names are how agents are mentioned, \ + so pick a different one" + )); + } + + let now = now_iso(); + // Stamp the record from Buzz's active workspace rather than accepting a + // caller-supplied community assertion. + let community = crate::managed_agents::normalize_community_url( + &crate::relay::relay_ws_url_with_override(&state), + ); + let record = connected_record(&host, &pubkey, &name, harness, Some(community), &now); + let summary = ConnectedAgentSummary::from(&record); + + let mut connected = connected; + connected.push(record); + save_connected_agents(&app, &connected)?; + + Ok(summary) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +/// Build the stored record for a connected agent. +/// +/// A pure function so the invariants that matter are directly testable without +/// a Tauri app handle. With a dedicated record type most of them are no longer +/// assertions at all: there is no key field to leave empty, no `agent_command` +/// to leave blank, and no `start_on_app_launch` to set false. The type states +/// them, so this function only has to be correct about the six facts Buzz knows. +pub(crate) fn connected_record( + host: &str, + pubkey: &str, + name: &str, + harness: Option, + community: Option, + now: &str, +) -> ConnectedAgentRecord { + ConnectedAgentRecord { + pubkey: pubkey.to_string(), + name: name.to_string(), + host: host.to_string(), + harness, + community, + created_at: now.to_string(), + updated_at: now.to_string(), + } +} + +/// Forget a connected agent. +/// +/// Local-only by construction: this removes Buzz's pointer and nothing else. +/// It deliberately does not take the paths `delete_managed_agent` takes — +/// no process stop (Buzz owns no process), no keyring delete (Buzz holds no +/// key), and above all no kind:30177 tombstone or NIP-IA archive. Those +/// publish the owner's assertion that an agent is gone; running them for an +/// agent that is still alive on its own machine would remove a working agent +/// from every member picker and autocomplete on the relay. +#[tauri::command] +pub async fn disconnect_remote_agent(pubkey: String, app: AppHandle) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let pubkey = normalize_agent_pubkey(&pubkey)?; + let state = app.state::(); + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + + let mut connected = load_connected_agents(&app)?; + let before = connected.len(); + connected.retain(|record| record.pubkey != pubkey); + if connected.len() == before { + return Err(format!("connected agent {pubkey} not found")); + } + save_connected_agents(&app, &connected) + }) + .await + .map_err(|error| format!("spawn_blocking failed: {error}"))? +} + +#[cfg(test)] +#[path = "remote_agent_connect_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs b/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs new file mode 100644 index 0000000000..59c320e18a --- /dev/null +++ b/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs @@ -0,0 +1,214 @@ +use super::*; + +const AGENT_HEX: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; +const AGENT_NPUB: &str = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + +fn sample_record() -> ConnectedAgentRecord { + connected_record( + "workstation", + AGENT_HEX, + "Scout", + Some("claude".to_string()), + Some("wss://community.example".to_string()), + "2026-07-28T00:00:00Z", + ) +} + +#[test] +fn npub_and_hex_normalize_to_the_same_stored_form() { + // Both are forms a user legitimately has on hand. If they normalized + // differently, connecting the same agent twice — once from each form — + // would pass the pubkey collision check and produce two records for one + // identity. + assert_eq!(normalize_agent_pubkey(AGENT_NPUB).unwrap(), AGENT_HEX); + assert_eq!(normalize_agent_pubkey(AGENT_HEX).unwrap(), AGENT_HEX); +} + +#[test] +fn uppercase_hex_is_normalized_rather_than_stored_verbatim() { + let shouty = AGENT_HEX.to_uppercase(); + assert_eq!(normalize_agent_pubkey(&shouty).unwrap(), AGENT_HEX); +} + +#[test] +fn surrounding_whitespace_is_tolerated() { + // Pasted from a terminal, an npub routinely arrives with a trailing + // newline. + assert_eq!( + normalize_agent_pubkey(&format!(" {AGENT_NPUB}\n")).unwrap(), + AGENT_HEX + ); +} + +#[test] +fn a_pasted_secret_key_is_refused_with_a_specific_message() { + // The whole point of this feature is that the agent's secret stays on its + // own machine. A user who pastes an nsec has made a serious mistake, and + // "invalid pubkey" would not tell them what it was. + let error = + normalize_agent_pubkey("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5") + .expect_err("an nsec must never be accepted as an agent pubkey"); + assert!( + error.contains("secret key"), + "message must name the mistake: {error}" + ); + assert!( + !error.contains("nsec1vl029"), + "the secret must not be echoed back into an error string: {error}" + ); +} + +#[test] +fn malformed_pubkeys_are_refused() { + for bad in [ + "", + " ", + "not-a-key", + "npub1truncated", + // 63 hex chars — one short. + "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459", + ] { + assert!( + normalize_agent_pubkey(bad).is_err(), + "expected {bad:?} to be refused" + ); + } +} + +#[test] +fn names_are_trimmed_and_bounded() { + assert_eq!(validate_connected_name(" Scout ").unwrap(), "Scout"); + assert!(validate_connected_name("").is_err()); + assert!(validate_connected_name(" ").is_err()); + assert!(validate_connected_name(&"n".repeat(65)).is_err()); + assert!(validate_connected_name(&"n".repeat(64)).is_ok()); + // A newline in a name would break every single-line list rendering it. + assert!(validate_connected_name("Sco\nut").is_err()); +} + +#[test] +fn a_connected_record_stores_the_identity_and_the_host_and_nothing_else() { + // The exhaustive field set, asserted against the serialized form rather + // than field-by-field. Under the previous design each absent capability + // needed its own assertion (`private_key_nsec` empty, `agent_command` + // blank, `start_on_app_launch` false, `runtime_pid` none) because the + // fields existed and merely held harmless values. They no longer exist, so + // the honest test is that the shape itself cannot express them: anyone + // widening this type to carry a key or a command breaks this test. + let record = sample_record(); + let json = serde_json::to_value(&record).unwrap(); + let mut keys: Vec<&str> = json + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + keys.sort_unstable(); + + assert_eq!( + keys, + [ + "community", + "created_at", + "harness", + "host", + "name", + "pubkey", + "updated_at" + ] + ); + assert_eq!(record.pubkey, AGENT_HEX); + assert_eq!(record.host, "workstation"); + assert_eq!(record.harness.as_deref(), Some("claude")); +} + +#[test] +fn a_probeless_connect_stores_no_harness_key_at_all() { + // `harness` is an observation, so "not observed" must be representable. + // `skip_serializing_if` keeps it out of the file rather than writing null, + // which keeps the stored shape honest about what was actually seen. + let record = connected_record( + "workstation", + AGENT_HEX, + "Scout", + None, + None, + "2026-07-28T00:00:00Z", + ); + let json = serde_json::to_value(&record).unwrap(); + assert!(!json.as_object().unwrap().contains_key("harness")); +} + +#[test] +fn a_connected_record_round_trips_through_the_store_format() { + let record = sample_record(); + let json = serde_json::to_string(&record).unwrap(); + let restored: ConnectedAgentRecord = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, record); + assert_eq!( + restored.host, "workstation", + "the host must survive a store write/read cycle — it is the probe target" + ); +} + +#[test] +fn the_summary_projection_omits_lifecycle_and_secrets() { + // `ConnectedAgentSummary` is intentionally narrower than + // `ManagedAgentSummary`. Serialize it and assert the absent fields stay + // absent: a later widening that reintroduces `status` or `pid` would give + // the UI something to render a start button from. + let record = sample_record(); + let summary = ConnectedAgentSummary::from(&record); + let json = serde_json::to_value(&summary).unwrap(); + let object = json.as_object().unwrap(); + + assert_eq!(object.get("host").unwrap(), "workstation"); + assert_eq!(object.get("harness").unwrap(), "claude"); + assert_eq!(object.get("pubkey").unwrap(), AGENT_HEX); + for absent in [ + "status", + "pid", + "logPath", + "log_path", + "needsRestart", + "startOnAppLaunch", + "privateKeyNsec", + "private_key_nsec", + "relayUrl", + ] { + assert!( + !object.contains_key(absent), + "{absent} must not reach the connected-agent surface" + ); + } +} + +#[test] +fn the_summary_projection_is_total() { + // The custody-field version of this projection read the host out of an + // `Option` and fell back to an empty string for a record that arrived under + // local custody — a case that could only happen if a caller's filtering was + // wrong. With a dedicated type there is no such case and no fallback, so an + // empty host in the UI can now only mean an empty host on disk. + let record = sample_record(); + assert_eq!(ConnectedAgentSummary::from(&record).host, record.host); +} + +#[test] +fn an_unknown_ssh_host_is_refused_with_the_fix_named() { + // The host is a probe target. Accepting a free-form string would create a + // row whose reachability can never be reported, which reads as a broken + // feature rather than a missing config entry. + let error = resolve_connect_host("definitely-not-in-any-ssh-config-xyzzy") + .expect_err("an unknown alias must be refused"); + assert!( + error.contains("~/.ssh/config"), + "the message must name the fix: {error}" + ); +} + +#[test] +fn a_blank_host_is_refused() { + assert!(resolve_connect_host("").is_err()); + assert!(resolve_connect_host(" ").is_err()); +} diff --git a/desktop/src-tauri/src/commands/remote_agent_discovery.rs b/desktop/src-tauri/src/commands/remote_agent_discovery.rs new file mode 100644 index 0000000000..85e9f47a03 --- /dev/null +++ b/desktop/src-tauri/src/commands/remote_agent_discovery.rs @@ -0,0 +1,57 @@ +//! Tauri commands for host-aware harness discovery. +//! +//! These answer "which machines can I reach, and what agent harnesses are on +//! them?" so an agent that already runs on another host can be found instead of +//! described by hand. +//! +//! All three commands are read-only. Nothing here installs software, writes to +//! a remote host, or collects a credential — the probe runs `command -v` and +//! `--version` and nothing else. + +use crate::managed_agents::remote_probe::{probe_localhost, probe_ssh_host, HostProbeResult}; +use crate::managed_agents::ssh_config::{parse_ssh_config, SshHost}; + +/// Enumerate the user's `~/.ssh/config` host aliases. +/// +/// No connection is attempted. An absent config yields an empty list, which +/// means "no remote hosts configured", not a failure. +#[tauri::command] +pub async fn list_ssh_hosts() -> Result, String> { + tokio::task::spawn_blocking(parse_ssh_config) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} + +/// Probe one host for agent harnesses and the `buzz` CLI. +/// +/// `host` must name an alias present in `~/.ssh/config`. Resolving it through +/// the parsed config rather than trusting the argument is what keeps an +/// arbitrary string — including anything shaped like an ssh option — from +/// reaching the `ssh` argv. +/// +/// A host-side problem (unreachable, password-only, unknown host key) comes back +/// as `Ok` with `ok: false` and a classified `errorKind`: the UI shows one row +/// per host and needs a renderable status, not an exception. +#[tauri::command] +pub async fn probe_agent_host(host: String) -> Result { + tokio::task::spawn_blocking(move || { + let hosts = parse_ssh_config(); + let Some(entry) = hosts.into_iter().find(|candidate| candidate.host == host) else { + return Err(format!( + "'{host}' is not a Host alias in ~/.ssh/config; only configured hosts can be probed" + )); + }; + Ok(probe_ssh_host(&entry)) + }) + .await + .map_err(|e| format!("spawn_blocking failed: {e}"))? +} + +/// Probe the machine Buzz is running on, using the identical probe script so +/// the result is shape-compatible with [`probe_agent_host`]. +#[tauri::command] +pub async fn probe_local_agent_host() -> Result { + tokio::task::spawn_blocking(probe_localhost) + .await + .map_err(|e| format!("spawn_blocking failed: {e}")) +} diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs new file mode 100644 index 0000000000..05dee47674 --- /dev/null +++ b/desktop/src-tauri/src/initial_window.rs @@ -0,0 +1,70 @@ +//! Initial window reveal helpers. +//! +//! Kept outside the app entrypoint so platform-specific first-frame handling +//! does not make command registration and shutdown wiring harder to navigate. + +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 + // 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"); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6814008f0d..3a4cf1ba09 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; @@ -54,6 +55,12 @@ use huddle::{ 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, }; +use initial_window::reveal_initial_window; +#[cfg(target_os = "macos")] +use initial_window::{ + clear_initial_window_backing, set_initial_window_backing, + wait_for_stable_initial_window_geometry, +}; use managed_agents::{ backfill_persona_snapshots, ensure_nest, list_managed_agent_runtimes, put_managed_agent_runtime_lifecycle, reconcile_managed_agent_runtimes, @@ -79,70 +86,6 @@ 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 @@ -800,6 +743,12 @@ pub fn run() { get_relay_self, resolve_oa_owner, list_relay_agents, + list_ssh_hosts, + probe_agent_host, + probe_local_agent_host, + list_connected_agents, + connect_remote_agent, + disconnect_remote_agent, list_managed_agents, list_managed_agent_runtimes, start_managed_agent_runtime, diff --git a/desktop/src-tauri/src/managed_agents/connected_agents.rs b/desktop/src-tauri/src/managed_agents/connected_agents.rs new file mode 100644 index 0000000000..e8813e83b6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/connected_agents.rs @@ -0,0 +1,212 @@ +//! Connected self-hosted agents: a distinct record type in a distinct store. +//! +//! A connected agent is one that already runs on a machine the user owns, +//! supervises itself, and holds its own key. Buzz knows its pubkey and where it +//! lives, and nothing else. +//! +//! # Why a separate type and file +//! +//! The first cut of this feature added a `key_custody` field to +//! [`ManagedAgentRecord`] and filtered on it inside `load_managed_agents`. That +//! works, but it makes "Buzz must not act on this agent" a *value* that every +//! reader has to interpret correctly, and it puts a connected agent one missed +//! filter away from the spawn, deploy, tombstone, and key-persisting paths. It +//! also obliged every one of the ~20 `ManagedAgentRecord { .. }` literals in the +//! tree to name a field about custody they have no opinion on. +//! +//! Making it a separate type removes the question instead of answering it: +//! +//! - [`ConnectedAgentRecord`] has no `private_key_nsec`, no `agent_command`, no +//! `start_on_app_launch`, no `runtime_pid`. A lifecycle path cannot act on one +//! because there is nothing to act *with* — and it cannot receive one anyway, +//! because it takes [`ManagedAgentRecord`]. +//! - The records live in `connected-agents.json`, so +//! [`super::load_managed_agents`] cannot return one no matter what it filters, +//! and an instance-side save cannot erase one no matter what it re-reads. +//! - [`super::storage`] is byte-identical to upstream. Key custody is expressed +//! by which store a record is in, which is not something a future contributor +//! can forget to check. +//! +//! The invariants that used to need guards are now properties of the types, and +//! the cross-store tests below assert the ones a type cannot state by itself. + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; + +use super::storage::{atomic_write_json, backup_invalid_store, managed_agents_base_dir}; + +/// A self-hosted agent Buzz talks to but does not own — the persisted shape. +/// +/// Every field is either an identity Buzz only holds the public half of, or a +/// local label. There is deliberately no key, no command, no timeout, no +/// auto-start flag, and no pid: this type cannot describe a process, so no +/// amount of downstream code can use it to start one. +/// +/// There is no operational `relay_url`. Every agent relay lookup resolves the +/// active workspace relay at read time (see +/// [`crate::relay::effective_agent_relay_url`]). The optional `community` below +/// is only a display-scope marker; it is never used to route agent traffic. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ConnectedAgentRecord { + /// The agent's own pubkey, 64-char lowercase hex. Normalized at the connect + /// boundary so every comparison downstream sees one form. + pub pubkey: String, + /// Buzz-local label. Not published anywhere — the agent's own kind:10100 + /// profile is the authority on how it presents itself on the relay. + pub name: String, + /// `~/.ssh/config` alias of the machine the agent and its key live on. + /// + /// A plain `String`, not an `Option`: a connected agent without a host would + /// be a record whose reachability can never be probed, so the connect + /// boundary rejects it and the type refuses to represent it. + pub host: String, + /// Harness id observed on the host at connect time, e.g. `"claude"`. A + /// record of what was seen, not a spawn instruction — nothing in Buzz + /// executes it. `None` when the user connected without a completed probe. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, + /// Normalized relay URL identifying the community where this connection + /// was created. Records written before this field was introduced remain + /// readable and visible until they are reconnected. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub community: Option, + pub created_at: String, + pub updated_at: String, +} + +/// The connected-agent view handed to the frontend. +/// +/// Distinct from [`ConnectedAgentRecord`] only in casing: the record is the +/// on-disk shape (snake_case, matching `managed-agents.json`) and this is the +/// wire shape (camelCase, matching every other Tauri command). Keeping them +/// separate means a future storage field is not automatically exposed to the UI. +/// +/// Deliberately not a `ManagedAgentSummary`. That type carries `status`, `pid`, +/// `log_path`, `needs_restart`, `start_on_app_launch`, and +/// `auto_restart_on_config_change` — every one a claim about a process Buzz +/// supervises. Projecting a connected agent onto it would force this surface to +/// invent a lifecycle it has no access to (a self-supervised agent with no local +/// pid is not "stopped"), and the UI would then render controls that cannot +/// work. The narrow shape is what makes "no start/stop button" a property of the +/// type rather than a rule someone has to remember. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConnectedAgentSummary { + pub pubkey: String, + pub name: String, + pub host: String, + pub harness: Option, + /// Community where this connection was created, or `None` for a legacy + /// record that predates community scoping. + pub community: Option, + pub created_at: String, + pub updated_at: String, +} + +impl From<&ConnectedAgentRecord> for ConnectedAgentSummary { + /// Total and infallible. The custody-field version of this projection had to + /// fall back to an empty host for a record that reached it under local + /// custody; with a dedicated type that case does not exist. + fn from(record: &ConnectedAgentRecord) -> Self { + Self { + pubkey: record.pubkey.clone(), + name: record.name.clone(), + host: record.host.clone(), + harness: record.harness.clone(), + community: record.community.clone(), + created_at: record.created_at.clone(), + updated_at: record.updated_at.clone(), + } + } +} + +/// Path of the connected-agent store, beside `managed-agents.json`. +pub(crate) fn connected_agents_store_path(app: &AppHandle) -> Result { + Ok(managed_agents_base_dir(app)?.join("connected-agents.json")) +} + +/// Load the connected self-hosted agents. +/// +/// No key hydration, because there is no key to hydrate: a keyring lookup here +/// would query for a secret that by definition does not exist locally, and a +/// miss would be indistinguishable from an outage. +/// +/// Parse failure is fail-loud with the evidence preserved, matching +/// [`super::storage::load_managed_agents`]: a later in-app save rewrites this +/// file wholesale, which would otherwise silently destroy a malformed hand edit. +pub(crate) fn load_connected_agents(app: &AppHandle) -> Result, String> { + load_connected_agents_at(&connected_agents_store_path(app)?) +} + +/// Path-based seam, so the store's behavior is testable over a tempdir without +/// a Tauri app handle. Mirrors the `hydrate_keys` / `hydrate_keys_with` split in +/// [`super::storage`]. +pub(crate) fn load_connected_agents_at(path: &Path) -> Result, String> { + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(path) + .map_err(|error| format!("failed to read connected agent store: {error}"))?; + serde_json::from_str(&content).map_err(|error| { + backup_invalid_store(path); + format!("failed to parse connected agent store (preserved as .invalid): {error}") + }) +} + +/// Save the connected self-hosted agents. +/// +/// A wholesale rewrite of this file only. It cannot disturb `managed-agents.json` +/// — which is the point of the separate store — so unlike the custody-field +/// design there is no other half to re-read and no way for an unrelated save to +/// erase these rows. +/// +/// Uses the ordinary [`atomic_write_json`], not the `0o600` restricted variant: +/// that exists for files carrying plaintext agent nsecs, and this type cannot +/// hold one. +pub(crate) fn save_connected_agents( + app: &AppHandle, + connected: &[ConnectedAgentRecord], +) -> Result<(), String> { + save_connected_agents_at(&connected_agents_store_path(app)?, connected) +} + +/// Path-based seam. See [`load_connected_agents_at`]. +pub(crate) fn save_connected_agents_at( + path: &Path, + connected: &[ConnectedAgentRecord], +) -> Result<(), String> { + let mut sorted = connected.to_vec(); + sort_for_stable_diffs(&mut sorted); + let payload = serde_json::to_vec_pretty(&sorted) + .map_err(|error| format!("failed to serialize connected agents: {error}"))?; + // `atomic_write_json` canonicalizes to preserve a symlink at `path`, which + // requires the target to exist. A first save has nothing to canonicalize. + if !path.exists() { + fs::write(path, b"[]") + .map_err(|error| format!("failed to create connected agent store: {error}"))?; + } + atomic_write_json(path, &payload) +} + +/// Order by name then pubkey, matching how instances are sorted, so the file +/// produces stable diffs. +fn sort_for_stable_diffs(records: &mut [ConnectedAgentRecord]) { + records.sort_by(|left, right| { + left.name + .to_lowercase() + .cmp(&right.name.to_lowercase()) + .then_with(|| left.pubkey.cmp(&right.pubkey)) + }); +} + +/// Normalize a relay URL so equivalent community spellings compare equally. +pub fn normalize_community_url(url: &str) -> String { + url.trim().trim_end_matches('/').to_ascii_lowercase() +} + +#[cfg(test)] +#[path = "connected_agents_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs b/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs new file mode 100644 index 0000000000..a6973ae640 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs @@ -0,0 +1,250 @@ +//! Cross-store tests for connected self-hosted agents. +//! +//! The lifecycle-exclusion invariants used to be enforced by a `key_custody` +//! filter inside `load_managed_agents`, and were tested by asserting that the +//! filter returned the right subset. With a separate type in a separate file +//! there is no filter to test — so what these cover instead is that the +//! separation is real: that the two stores cannot see each other's rows, that +//! neither type can be read out of the other's file, and that a write to one +//! cannot disturb the other. +//! +//! Together with the type itself (no key, no command, no pid — see +//! [`super::ConnectedAgentRecord`]) that is the whole of the old invariant set, +//! reproved at the boundary rather than at each consumer. + +use std::fs; + +use super::{ + load_connected_agents_at, normalize_community_url, save_connected_agents_at, + ConnectedAgentRecord, ConnectedAgentSummary, +}; +use crate::managed_agents::ManagedAgentRecord; + +const CONNECTED_HEX: &str = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; +const OWNED_HEX: &str = "1bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa4591"; + +fn connected(pubkey: &str, name: &str, host: &str) -> ConnectedAgentRecord { + ConnectedAgentRecord { + pubkey: pubkey.to_string(), + name: name.to_string(), + host: host.to_string(), + harness: Some("claude".to_string()), + community: None, + created_at: "2026-07-28T00:00:00Z".to_string(), + updated_at: "2026-07-28T00:00:00Z".to_string(), + } +} + +/// A `managed-agents.json` payload as earlier builds wrote it. +fn managed_store_json() -> String { + serde_json::json!([{ + "pubkey": OWNED_HEX, + "name": "Owned", + "private_key_nsec": "", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" + }]) + .to_string() +} + +#[test] +fn an_absent_store_is_an_empty_list_not_an_error() { + // First run, and every run before the user connects anything. This must not + // surface as a load failure in the agents view. + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("connected-agents.json"); + assert_eq!(load_connected_agents_at(&path).unwrap(), Vec::new()); +} + +#[test] +fn records_round_trip_through_the_store() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("connected-agents.json"); + let records = vec![connected(CONNECTED_HEX, "Scout", "workstation")]; + + save_connected_agents_at(&path, &records).expect("save"); + assert_eq!(load_connected_agents_at(&path).unwrap(), records); +} + +#[test] +fn a_first_save_creates_the_store_and_a_second_overwrites_it_atomically() { + // `atomic_write_json` canonicalizes its target to preserve a symlink, which + // needs the file to exist — so the create-then-write path is load-bearing on + // the very first connect, and a regression there would make connecting fail + // only on a machine that had never connected anything. + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("connected-agents.json"); + + save_connected_agents_at(&path, &[connected(CONNECTED_HEX, "Scout", "workstation")]) + .expect("first"); + save_connected_agents_at(&path, &[connected(CONNECTED_HEX, "Scout", "buildbox")]) + .expect("second"); + + let loaded = load_connected_agents_at(&path).unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].host, "buildbox"); + assert!( + !path.with_extension("json.tmp").exists(), + "the atomic write must not leave its temp file behind" + ); +} + +#[test] +fn records_are_sorted_for_stable_diffs() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("connected-agents.json"); + let records = vec![ + connected(CONNECTED_HEX, "zeta", "workstation"), + connected(OWNED_HEX, "Alpha", "buildbox"), + ]; + + save_connected_agents_at(&path, &records).expect("save"); + + let names: Vec = load_connected_agents_at(&path) + .unwrap() + .into_iter() + .map(|record| record.name) + .collect(); + assert_eq!(names, ["Alpha", "zeta"], "case-insensitive name order"); +} + +#[test] +fn a_malformed_store_fails_loudly_and_preserves_the_evidence() { + // Matches `load_managed_agents`: a later in-app save rewrites this file + // wholesale, so swallowing a parse error into an empty list would silently + // destroy a hand edit. + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("connected-agents.json"); + fs::write(&path, b"{ not an array").expect("seed"); + + let error = load_connected_agents_at(&path).expect_err("a malformed store must not load as []"); + assert!(error.contains(".invalid"), "message must name the backup"); + assert!( + path.with_extension("json.invalid").exists(), + "the malformed content must survive for the user to recover" + ); +} + +#[test] +fn a_connected_record_cannot_be_deserialized_as_a_managed_record() { + // The type boundary, stated as data. Even a future reader that pointed at + // the wrong file could not produce a `ManagedAgentRecord` from a connected + // row: the fields every lifecycle path needs are not merely empty, they are + // absent, so serde refuses. This is what replaces the custody filter — the + // old design's connected rows WERE `ManagedAgentRecord`s and deserialized + // happily, which is exactly why a missed filter was dangerous. + let record = connected(CONNECTED_HEX, "Scout", "workstation"); + let json = serde_json::to_value(&record).unwrap(); + + let parsed = serde_json::from_value::(json); + assert!( + parsed.is_err(), + "a connected row must not satisfy ManagedAgentRecord" + ); +} + +#[test] +fn a_managed_record_cannot_be_deserialized_as_a_connected_record() { + // The converse, and the reason `host` is a plain `String`: an owned agent's + // row has no host, so it cannot become a connected agent by being read out + // of the wrong file. If `host` were `Option` this would silently + // succeed and produce a connected agent that can never be probed. + let managed: serde_json::Value = serde_json::from_str(&managed_store_json()).unwrap(); + let first = managed.as_array().unwrap()[0].clone(); + + let parsed = serde_json::from_value::(first); + assert!( + parsed.is_err(), + "an owned agent's row must not satisfy ConnectedAgentRecord" + ); +} + +#[test] +fn the_two_stores_are_separate_files_and_a_connected_save_leaves_the_other_untouched() { + // The invariant that most needed a guard before. Under the shared-file + // design, `load_managed_agents` filtered connected rows out, so every one of + // the dozens of existing `load … mutate … save_managed_agents` call sites + // would have erased them without a deliberate re-read of the connected + // third. Separate files remove the failure mode rather than compensating for + // it: there is no shared payload to drop a half of. + let dir = tempfile::tempdir().expect("temp dir"); + let managed_path = dir.path().join("managed-agents.json"); + let connected_path = dir.path().join("connected-agents.json"); + fs::write(&managed_path, managed_store_json()).expect("seed managed store"); + let before = fs::read(&managed_path).expect("read managed store"); + + save_connected_agents_at( + &connected_path, + &[connected(CONNECTED_HEX, "Scout", "workstation")], + ) + .expect("save connected"); + + assert_eq!( + fs::read(&managed_path).expect("re-read managed store"), + before, + "a connected save must not rewrite managed-agents.json at all" + ); + + // And the managed store still parses to exactly the agent it started with — + // no connected row leaked into the reader that feeds spawn and deploy. + let managed: Vec = + serde_json::from_slice(&fs::read(&managed_path).unwrap()).unwrap(); + assert_eq!(managed.len(), 1); + assert_eq!(managed[0].pubkey, OWNED_HEX); + assert!( + managed.iter().all(|record| record.pubkey != CONNECTED_HEX), + "the connected agent must be invisible to the managed-agent reader" + ); +} + +#[test] +fn a_connected_store_write_carries_no_secret_and_needs_no_restricted_mode() { + // `managed-agents.json` is written `0o600` because it can carry plaintext + // agent nsecs during a keyring outage. This store uses the ordinary write, + // which is only correct because the type cannot hold a secret — so assert + // the serialized bytes contain nothing key-shaped. + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("connected-agents.json"); + save_connected_agents_at(&path, &[connected(CONNECTED_HEX, "Scout", "workstation")]) + .expect("save"); + + let raw = fs::read_to_string(&path).expect("read back"); + for forbidden in ["nsec", "private_key", "auth_tag"] { + assert!( + !raw.contains(forbidden), + "{forbidden} must never appear in the connected store: {raw}" + ); + } +} + +#[test] +fn the_summary_is_a_lossless_projection_of_the_record() { + // Both types exist so a future storage field is not automatically exposed to + // the UI. Today they carry the same six facts, and this pins that: if the + // record gains a field the summary should not have, this test still passes, + // but if the projection starts dropping or renaming one it fails. + let record = connected(CONNECTED_HEX, "Scout", "workstation"); + let summary = ConnectedAgentSummary::from(&record); + + assert_eq!(summary.pubkey, record.pubkey); + assert_eq!(summary.name, record.name); + assert_eq!(summary.host, record.host); + assert_eq!(summary.harness, record.harness); + assert_eq!(summary.community, record.community); + assert_eq!(summary.created_at, record.created_at); + assert_eq!(summary.updated_at, record.updated_at); +} + +#[test] +fn community_comparison_ignores_trailing_slashes_and_case() { + assert_eq!( + normalize_community_url(" wss://Relay.Example.com/ "), + "wss://relay.example.com" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8d1b8a5013..4cafed1c9a 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,18 +10,25 @@ use crate::managed_agents::{ HarnessSource, }; +mod known_runtimes; mod presets; +mod probe_targets; mod runtime_metadata; +pub(crate) use known_runtimes::KNOWN_ACP_RUNTIMES; use presets::{preset_catalog_entry, PRESET_HARNESSES}; pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; +pub use probe_targets::{harness_probe_targets, HarnessProbeTarget}; +// The avatar URLs are only named directly by `tests.rs`, which asserts each +// runtime resolves to its own icon; production code reaches them through +// `KNOWN_ACP_RUNTIMES`. Re-exported here so the move out of this file stays +// invisible to the test module. +#[cfg(test)] +pub(crate) use known_runtimes::{ + BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, +}; pub(crate) use runtime_metadata::KnownAcpRuntime; -const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; -const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; -const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; -const BUZZ_AGENT_AVATAR_URL: &str = - "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); PATHS.get_or_init(|| { @@ -72,140 +79,6 @@ fn common_binary_paths() -> &'static [PathBuf] { }) } -const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ - KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: GOOSE_AVATAR_URL, - mcp_command: None, - mcp_hooks: false, - underlying_cli: Some("goose"), - 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\""], - adapter_install_commands: &[], - cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", - adapter_install_instructions_url: "", - cli_install_hint: "Buzz talks to Goose through the Goose CLI.", - adapter_install_hint: "", - skill_dir: Some(".goose/skills"), - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[("GOOSE_MODE", "auto")], - 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, - }, - KnownAcpRuntime { - id: "claude", - label: "Claude Code", - commands: &["claude-agent-acp", "claude-code-acp"], - aliases: &["claude-code", "claudecode"], - avatar_url: CLAUDE_CODE_AVATAR_URL, - mcp_command: None, - 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\""], - adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], - cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", - adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", - skill_dir: Some(".claude/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: true, - default_env: &[], - config_file_path: Some("~/.claude/settings.json"), - config_file_format: Some("json"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_env_var: None, - required_normalized_fields: &[], - login_hint: Some("Run the Claude CLI to complete authentication."), - auth_probe_args: Some(&["claude", "auth", "status"]), - }, - KnownAcpRuntime { - id: "codex", - label: "Codex", - commands: &["codex-acp"], - aliases: &[], - avatar_url: CODEX_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - 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\""], - adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], - cli_install_instructions_url: "https://developers.openai.com/codex/cli/", - adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Buzz talks to Codex through the Codex CLI.", - adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", - skill_dir: Some(".codex/skills"), - supports_acp_model_switching: false, - model_env_var: None, - provider_env_var: None, - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.codex/config.toml"), - config_file_format: Some("toml"), - supports_acp_native_config: false, - thinking_env_var: None, - max_tokens_env_var: None, - context_limit_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. - auth_probe_args: Some(&["codex", "login", "status"]), - }, - KnownAcpRuntime { - id: "buzz-agent", - label: "Buzz Agent", - commands: &["buzz-agent"], - aliases: &[], - avatar_url: BUZZ_AGENT_AVATAR_URL, - mcp_command: Some("buzz-dev-mcp"), - mcp_hooks: true, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "https://github.com/block/buzz", - adapter_install_instructions_url: "https://github.com/block/buzz", - cli_install_hint: "Ships with the Buzz desktop app.", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: true, - model_env_var: Some("BUZZ_AGENT_MODEL"), - provider_env_var: Some("BUZZ_AGENT_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: None, - config_file_format: None, - supports_acp_native_config: false, - 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"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - }, -]; - /// Skill discovery directories declared by known runtimes. pub(crate) fn known_skill_dirs() -> impl Iterator { KNOWN_ACP_RUNTIMES.iter().filter_map(|p| p.skill_dir) diff --git a/desktop/src-tauri/src/managed_agents/discovery/known_runtimes.rs b/desktop/src-tauri/src/managed_agents/discovery/known_runtimes.rs new file mode 100644 index 0000000000..be294a7b33 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/known_runtimes.rs @@ -0,0 +1,149 @@ +//! Compiled-in metadata for the ACP runtimes Buzz knows how to discover. +//! +//! Split out of `discovery.rs` so the remote-probe work can grow the module +//! without pushing the parent past the desktop file-size ratchet. This is a +//! verbatim move: `KNOWN_ACP_RUNTIMES` and the avatar URLs that populate it +//! are the single source of truth for both local discovery and the remote +//! probe target list in `probe_targets.rs`. + +use super::KnownAcpRuntime; + +pub(crate) const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; +pub(crate) const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; +pub(crate) const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +pub(crate) const BUZZ_AGENT_AVATAR_URL: &str = + "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; + +pub(crate) const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ + KnownAcpRuntime { + id: "goose", + label: "Goose", + commands: &["goose"], + aliases: &[], + avatar_url: GOOSE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("goose"), + 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\""], + adapter_install_commands: &[], + cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz talks to Goose through the Goose CLI.", + adapter_install_hint: "", + skill_dir: Some(".goose/skills"), + supports_acp_model_switching: false, + model_env_var: Some("GOOSE_MODEL"), + provider_env_var: Some("GOOSE_PROVIDER"), + provider_locked: false, + default_env: &[("GOOSE_MODE", "auto")], + 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, + }, + KnownAcpRuntime { + id: "claude", + label: "Claude Code", + commands: &["claude-agent-acp", "claude-code-acp"], + aliases: &["claude-code", "claudecode"], + avatar_url: CLAUDE_CODE_AVATAR_URL, + mcp_command: None, + 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\""], + adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", + cli_install_hint: "Buzz talks to Claude Code through the Claude Code CLI.", + adapter_install_hint: "Buzz talks to the Claude Code CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/claude-agent-acp.", + skill_dir: Some(".claude/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.claude/settings.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run the Claude CLI to complete authentication."), + auth_probe_args: Some(&["claude", "auth", "status"]), + }, + KnownAcpRuntime { + id: "codex", + label: "Codex", + commands: &["codex-acp"], + aliases: &[], + avatar_url: CODEX_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + 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\""], + adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], + cli_install_instructions_url: "https://developers.openai.com/codex/cli/", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", + cli_install_hint: "Buzz talks to Codex through the Codex CLI.", + adapter_install_hint: "Buzz talks to the Codex CLI through an ACP adapter. Install it with: npm install -g @agentclientprotocol/codex-acp.", + skill_dir: Some(".codex/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: Some("~/.codex/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_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. + auth_probe_args: Some(&["codex", "login", "status"]), + }, + KnownAcpRuntime { + id: "buzz-agent", + label: "Buzz Agent", + commands: &["buzz-agent"], + aliases: &[], + avatar_url: BUZZ_AGENT_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: true, + underlying_cli: None, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://github.com/block/buzz", + adapter_install_instructions_url: "https://github.com/block/buzz", + cli_install_hint: "Ships with the Buzz desktop app.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + model_env_var: Some("BUZZ_AGENT_MODEL"), + provider_env_var: Some("BUZZ_AGENT_PROVIDER"), + provider_locked: false, + default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + 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"), + required_normalized_fields: &["model", "provider"], + login_hint: None, + auth_probe_args: None, + }, +]; diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 72c4657dc7..e1de9396be 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -10,17 +10,17 @@ use super::normalize_agent_args; /// Static data for a well-known tier-2 ACP harness. pub(super) struct PresetHarness { pub(super) id: &'static str, - label: &'static str, - command: &'static str, - args: &'static [&'static str], - install_instructions_url: &'static str, - install_hint: &'static str, + pub(super) label: &'static str, + pub(super) command: &'static str, + pub(super) args: &'static [&'static str], + pub(super) install_instructions_url: &'static str, + pub(super) install_hint: &'static str, /// Vendor CLI the ACP command wraps, when the preset is an adapter. /// /// Consulted only when the adapter is absent, so `AdapterMissing` replaces /// `NotInstalled` when the CLI is present but the adapter is not. `None` /// when the command is itself the vendor CLI. - underlying_cli: Option<&'static str>, + pub(super) underlying_cli: Option<&'static str>, } /// Build one preset catalog entry through an injectable command resolver. diff --git a/desktop/src-tauri/src/managed_agents/discovery/probe_targets.rs b/desktop/src-tauri/src/managed_agents/discovery/probe_targets.rs new file mode 100644 index 0000000000..c6a58bc9ba --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/probe_targets.rs @@ -0,0 +1,77 @@ +//! What to look for when probing another machine for agent harnesses. +//! +//! A child module of `discovery` rather than new lines inside `discovery.rs`: +//! that file is already over the desktop 1000-line limit and carries a +//! documented "queued to be split" override, so new surface goes beside it. As +//! a child it still sees `discovery`'s private tables directly, so nothing had +//! to be made more visible to accommodate the move. +//! +//! The projection direction matters. These targets are derived from the same +//! compiled-in tables local discovery uses (`KNOWN_ACP_RUNTIMES` and +//! `PRESET_HARNESSES`), never from a parallel list. A hand-maintained set of +//! "harnesses we can find remotely" would drift the moment a preset is added — +//! the exact failure `preset_harness_ids()` already exists to prevent. + +use super::{KNOWN_ACP_RUNTIMES, PRESET_HARNESSES}; +use crate::managed_agents::types::HarnessSource; + +/// One harness's probe target set, projected from the compiled-in tables. +/// +/// Remote discovery needs to know *what to look for* on another machine. That +/// set must come from the same tables local discovery uses — a second, +/// hand-maintained list of harnesses would drift the moment a preset is added, +/// which is the failure mode `preset_harness_ids()` already exists to prevent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HarnessProbeTarget { + pub id: &'static str, + pub label: &'static str, + /// ACP command basenames to look for, in preference order. The first one + /// found on the remote host wins. + pub acp_commands: &'static [&'static str], + /// Vendor CLI the ACP command wraps, when the harness is an adapter. + /// `None` when the ACP command *is* the vendor CLI. + pub underlying_cli: Option<&'static str>, + pub install_hint: &'static str, + pub install_instructions_url: &'static str, + pub source: HarnessSource, +} + +/// Every harness a remote host can be probed for: the four builtins plus every +/// bundled preset. +/// +/// Custom (tier-3) harnesses are deliberately excluded. Their definitions live +/// in the *local* user's `custom_harnesses/` directory and describe commands on +/// the local machine; projecting them onto a remote host would assert a layout +/// nothing has verified. A user who wants a custom harness discovered remotely +/// is better served by it becoming a preset. +pub fn harness_probe_targets() -> Vec { + let mut targets: Vec = KNOWN_ACP_RUNTIMES + .iter() + .map(|runtime| HarnessProbeTarget { + id: runtime.id, + label: runtime.label, + acp_commands: runtime.commands, + underlying_cli: runtime.underlying_cli, + // Builtins carry separate CLI and adapter hints. The CLI hint is the + // useful one for a remote host: an absent adapter is only reachable + // once the vendor CLI it wraps is present. + install_hint: runtime.cli_install_hint, + install_instructions_url: runtime.cli_install_instructions_url, + source: HarnessSource::Builtin, + }) + .collect(); + + targets.extend(PRESET_HARNESSES.iter().map(|preset| HarnessProbeTarget { + id: preset.id, + label: preset.label, + // A preset's `command` is the binary; its `args` are how it is invoked. + // Only the binary is probeable, matching the local PATH probe. + acp_commands: std::slice::from_ref(&preset.command), + underlying_cli: preset.underlying_cli, + install_hint: preset.install_hint, + install_instructions_url: preset.install_instructions_url, + source: HarnessSource::Preset, + })); + + targets +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index be9b07cf11..2c3acb7840 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -7,6 +7,7 @@ pub(crate) use agent_env::{ }; mod backend; pub(crate) mod config_bridge; +mod connected_agents; pub(crate) mod custom_harnesses; mod discovery; pub(crate) mod effective_config; @@ -23,6 +24,7 @@ mod process_lifecycle; pub(crate) mod readiness; pub(crate) mod reconcile; mod relay_mesh; +pub mod remote_probe; mod repos; mod restore; pub mod retention; @@ -31,6 +33,7 @@ mod runtime_commands; mod runtime_types; pub(crate) mod snapshot_avatar; pub(crate) mod spawn_hash; +pub mod ssh_config; pub(crate) mod storage; pub(crate) mod team_events; mod team_repair; @@ -47,6 +50,10 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { } pub use backend::*; +pub(crate) use connected_agents::{ + load_connected_agents, normalize_community_url, save_connected_agents, ConnectedAgentRecord, + ConnectedAgentSummary, +}; pub use discovery::*; pub use env_vars::*; #[cfg(windows)] diff --git a/desktop/src-tauri/src/managed_agents/remote_probe.rs b/desktop/src-tauri/src/managed_agents/remote_probe.rs new file mode 100644 index 0000000000..e2c582a72c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/remote_probe.rs @@ -0,0 +1,649 @@ +//! Host-aware harness discovery. +//! +//! Local discovery (`discover_acp_runtimes_from`) answers "which harnesses are +//! on *this* machine?" This module answers it for any host in the user's +//! `~/.ssh/config`, so an agent that already runs on another machine can be +//! found rather than described by hand. +//! +//! # Design constraints, learned the hard way +//! +//! * **The probe script is a constant.** No user input is interpolated into it, +//! so single-quoting it into the `ssh` argv is safe by construction rather +//! than by careful escaping. Host and port reach `ssh` as separate argv +//! entries, never through the shell. +//! * **It runs under `exec $SHELL -lc` — login, but NOT interactive.** Harness +//! binaries live in npm-global, homebrew, pyenv, and venv prefixes that a +//! *login* shell puts on `PATH`, so `-l` is required. `-i` is not, and is +//! actively harmful: an interactive shell sources `.zshrc`/`.bashrc`, which is +//! where prompt frameworks, completion init, and autosuggestion plugins live. +//! Several of those block forever without a TTY. Verified against a real macOS +//! `/bin/zsh` host: `-lic` hung indefinitely and had to be killed, while +//! `-lc` returned the complete binary set including a Python venv prefix. +//! A probe that hangs is worse than one that misses a path, because it turns a +//! healthy host into a timeout. +//! * **The `for` list is a flat set of binary names.** Harness identity is +//! reattached afterwards, in Rust, by matching resolved binaries back to the +//! probe targets. Encoding `harness=binary` pairs in the shell loop instead +//! would put a delimiter inside a `for … in` list, and the obvious choice +//! (`|`) is a parse error in both bash and zsh that kills the loop before it +//! runs. Keeping the shell dumb avoids the question entirely. +//! * **`BatchMode=yes`, and a password wall is a status, not a prompt.** Buzz +//! never collects or stores an SSH password. A host that offers only +//! interactive auth is reported as such, with the fix (install a key) in the +//! message. +//! * **Local and remote return the same shape.** `probe_localhost` runs the +//! identical script, so nothing downstream needs a special case for "this +//! machine". + +use std::collections::{BTreeMap, BTreeSet}; +use std::process::Command; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +use crate::managed_agents::discovery::{harness_probe_targets, HarnessProbeTarget}; +use crate::managed_agents::ssh_config::{resolve_ssh_binary, SshHost}; +use crate::managed_agents::HarnessSource; + +/// Sentinel that brackets the probe's own output. +/// +/// A login shell may print motd banners, shell-init chatter, or warnings before +/// and after our commands. Without a delimiter those lines get parsed as +/// results; with one, everything outside the markers is discarded. +const PROBE_START: &str = "---BUZZ-PROBE-START---"; +const PROBE_END: &str = "---BUZZ-PROBE-END---"; + +/// Wall-clock ceiling for a single host probe. A wedged host must not be able +/// to hold the caller open — the UI renders one row per host and a single +/// unresponsive machine would otherwise stall the whole list. +const PROBE_TIMEOUT: Duration = Duration::from_secs(20); + +/// `ssh` connect timeout, kept well under [`PROBE_TIMEOUT`] so an unreachable +/// host fails through ssh's own error path (which yields a useful message) +/// rather than our blunt kill path. +const SSH_CONNECT_TIMEOUT_SECS: u32 = 6; + +/// Per-binary ceiling for a `--version` call on the probed host. +/// +/// A version string is informational; a hung `--version` is not. Observed on a +/// real host: `claude --version` never returned, which truncated the probe and +/// silently hid every harness later in the loop. Bounding each call means a +/// broken or first-run binary costs one `unknown` version instead of the whole +/// result. +/// +/// Kept small because it multiplies: worst case is roughly this value times the +/// number of harnesses that both exist and hang, and it must stay well inside +/// [`PROBE_TIMEOUT`]. +const VERSION_TIMEOUT_SECS: u32 = 3; + +/// Why a probe failed, when the cause is actionable. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HostProbeErrorKind { + /// The host offered only password / keyboard-interactive auth, which a + /// `BatchMode` probe cannot satisfy and Buzz will not collect. + PasswordRequired, + /// The host key is unknown or changed — a trust decision the user must make + /// outside Buzz. + HostKeyProblem, + /// Name resolution or the TCP connection failed. + Unreachable, + /// The probe exceeded [`PROBE_TIMEOUT`]. + TimedOut, + /// The probe started but its output stopped before the closing marker, so + /// the facts gathered are an unknown fraction of the real ones. + Truncated, +} + +/// One harness found on a probed host. +/// +/// Deliberately narrower than the local `AcpRuntimeCatalogEntry`. That type +/// carries `can_auto_install`, `node_required`, and `auth_status`, all of which +/// describe actions Buzz performs on the local machine. Buzz does not install +/// software on, or authenticate CLIs on, someone else's host — reusing the local +/// shape would mean fabricating those fields, and the UI would then offer +/// buttons that cannot work. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteHarness { + pub id: String, + pub label: String, + pub source: HarnessSource, + /// Resolved absolute path of the ACP command on the remote host. + pub acp_command_path: Option, + /// The ACP command basename that resolved, for building a run command. + pub acp_command: Option, + /// Version string the ACP command reported, when it reported one. + pub version: Option, + /// Resolved path of the vendor CLI this harness wraps, when it wraps one. + pub underlying_cli_path: Option, + /// True when the harness is usable on this host: its ACP command resolved, + /// and any vendor CLI it wraps also resolved. + pub ready: bool, + pub install_hint: String, + pub install_instructions_url: String, +} + +/// Result of probing one host. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HostProbeResult { + /// The `ssh` alias probed, or [`LOCALHOST_ID`] for this machine. + pub host: String, + pub ok: bool, + pub duration_ms: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub os: Option, + /// Path of the `buzz` CLI on the host. A connected agent needs it to reach + /// the relay, so its absence is the single most useful thing to surface. + #[serde(skip_serializing_if = "Option::is_none")] + pub buzz_cli_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub buzz_cli_version: Option, + pub harnesses: Vec, +} + +/// Host id used for the local machine, so it can sit in the same list as ssh +/// aliases without colliding with one (`localhost` is a legal alias, this is +/// not). +pub const LOCALHOST_ID: &str = "__localhost__"; + +/// Build the probe script for a target set. +/// +/// Returns a script containing only literals derived from the compiled-in +/// harness tables — never user input. Callers must not append anything to it. +fn build_probe_script(targets: &[HarnessProbeTarget]) -> String { + // Every ACP command basename across all harnesses, plus every vendor CLI, + // plus `buzz`. Sorted and deduped so the emitted script is deterministic + // (which makes it cacheable and makes test assertions stable). + let mut binaries: BTreeSet<&str> = BTreeSet::new(); + for target in targets { + for command in target.acp_commands { + binaries.insert(command); + } + if let Some(cli) = target.underlying_cli { + binaries.insert(cli); + } + } + binaries.insert("buzz"); + + let binary_list = binaries.into_iter().collect::>().join(" "); + + // `command -v` rather than `which`: it is a POSIX shell builtin, present + // even on minimal images, and does not depend on an external binary that + // may itself be missing. + // + // Each `--version` call is individually time-bounded. This is not + // defensive padding — a real harness binary was observed hanging forever on + // `--version` on a real host (a `claude` install on macOS), which truncated + // the whole probe: every harness after it in the loop went unreported and + // the trailing sentinel never printed, so the result looked like a + // half-provisioned machine rather than a stuck command. + // + // The bound is hand-rolled because `timeout(1)` is not portable — it is + // absent from a stock macOS, which is precisely where the hang was found. + // Shape: run the version command in the background, run a killer in the + // background, then `wait` for the version command. The killer's stdout is + // closed, which matters — otherwise it holds the command substitution's + // pipe open for the full sleep and every binary would cost + // `VERSION_TIMEOUT` even when it answered instantly. + // + // `/dev/null) + if [ -n "$bin" ]; then + ver=$( {{ "$tool" --version /dev/null & vp=$!; {{ sleep {version_timeout}; kill -9 $vp; }} >/dev/null 2>&1 & kp=$!; wait $vp; kill -9 $kp; }} 2>/dev/null | head -1 | tr -d "\"\047" | tr -d "\r" ) + echo "BIN:$tool:$bin:${{ver:-unknown}}" + fi +done +echo "USER:$USER" +echo "HOST:$(hostname -s 2>/dev/null)" +echo "OS:$(uname -s 2>/dev/null)" +echo "{PROBE_END}" +'"#, + version_timeout = VERSION_TIMEOUT_SECS + ) +} + +/// Facts a single probe run recovered from the host. +#[derive(Debug, Default)] +struct ProbeFacts { + /// binary basename → (resolved path, version) + binaries: BTreeMap)>, + user: Option, + hostname: Option, + os: Option, +} + +/// Parse probe stdout, ignoring everything outside the sentinels. +fn parse_probe_output(raw: &str) -> ProbeFacts { + let mut facts = ProbeFacts::default(); + let mut inside = false; + + for line in raw.lines() { + if line.contains(PROBE_START) { + inside = true; + continue; + } + if line.contains(PROBE_END) { + inside = false; + continue; + } + if !inside { + continue; + } + + if let Some(rest) = line.strip_prefix("BIN:") { + // `BIN:::` — the version may itself contain + // colons, so split into at most 3 pieces and keep the remainder + // whole. The path may not contain a colon, which holds for every + // real install prefix. + let mut parts = rest.splitn(3, ':'); + let (Some(tool), Some(path)) = (parts.next(), parts.next()) else { + continue; + }; + let version = parts + .next() + .map(str::trim) + .filter(|v| !v.is_empty() && *v != "unknown") + .map(str::to_string); + let tool = tool.trim(); + let path = path.trim(); + if tool.is_empty() || path.is_empty() { + continue; + } + facts + .binaries + .insert(tool.to_string(), (path.to_string(), version)); + } else if let Some(rest) = line.strip_prefix("USER:") { + facts.user = non_empty(rest); + } else if let Some(rest) = line.strip_prefix("HOST:") { + facts.hostname = non_empty(rest); + } else if let Some(rest) = line.strip_prefix("OS:") { + facts.os = non_empty(rest); + } + } + + facts +} + +fn non_empty(value: &str) -> Option { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +/// Assemble harness entries from probe facts. +/// +/// Shared by the ssh and localhost paths so both produce identical shapes. +fn assemble_harnesses(facts: &ProbeFacts, targets: &[HarnessProbeTarget]) -> Vec { + targets + .iter() + .map(|target| { + // First listed ACP command that resolved wins, matching the local + // catalog's preference-order semantics. + let found = target + .acp_commands + .iter() + .find_map(|cmd| facts.binaries.get(*cmd).map(|hit| (*cmd, hit))); + + let underlying_cli_path = target + .underlying_cli + .and_then(|cli| facts.binaries.get(cli)) + .map(|(path, _)| path.clone()); + + // A harness is ready only if its ACP command exists AND, when it is + // an adapter, the vendor CLI it wraps exists too. An adapter without + // its CLI starts and then fails at first use, so reporting it as + // ready would be worse than reporting it missing. + let ready = found.is_some() + && (target.underlying_cli.is_none() || underlying_cli_path.is_some()); + + RemoteHarness { + id: target.id.to_string(), + label: target.label.to_string(), + source: target.source.clone(), + acp_command: found.map(|(cmd, _)| cmd.to_string()), + acp_command_path: found.map(|(_, (path, _))| path.clone()), + version: found.and_then(|(_, (_, version))| version.clone()), + underlying_cli_path, + ready, + install_hint: target.install_hint.to_string(), + install_instructions_url: target.install_instructions_url.to_string(), + } + }) + .collect() +} + +/// Classify ssh's stderr into an actionable cause. +/// +/// Raw ssh stderr is accurate but unhelpful in a UI; these are the cases where +/// naming the cause tells the user what to actually do. +pub fn classify_ssh_failure(stderr: &str) -> Option { + let lower = stderr.to_ascii_lowercase(); + + // A denial listing password or keyboard-interactive means the host wants + // interactive auth. A bare `(publickey)` denial is NOT this case — that is a + // missing or rejected key, where the raw message is the more honest report. + if let Some(start) = lower.find("permission denied") { + let tail = &lower[start..]; + if let (Some(open), Some(close)) = (tail.find('('), tail.find(')')) { + if open < close { + let methods = &tail[open + 1..close]; + if methods.contains("password") || methods.contains("keyboard-interactive") { + return Some(HostProbeErrorKind::PasswordRequired); + } + } + } + } + + if lower.contains("host key verification failed") + || lower.contains("remote host identification has changed") + // Emitted by `StrictHostKeyChecking=yes` for a first-seen host. Matched + // in its own right because it is the line that names the actual cause; + // relying only on the generic "verification failed" that follows it + // would leave an unknown key indistinguishable from a changed one. + || lower.contains("you have requested strict checking") + { + return Some(HostProbeErrorKind::HostKeyProblem); + } + + if lower.contains("could not resolve hostname") + || lower.contains("name or service not known") + || lower.contains("connection refused") + || lower.contains("connection timed out") + || lower.contains("no route to host") + || lower.contains("network is unreachable") + || lower.contains("operation timed out") + { + return Some(HostProbeErrorKind::Unreachable); + } + + None +} + +/// Human-facing message for a classified failure, including the remedy. +fn failure_message(kind: &HostProbeErrorKind, host: &str, stderr: &str) -> String { + match kind { + HostProbeErrorKind::PasswordRequired => format!( + "'{host}' accepts only password login. Buzz never stores SSH passwords — \ + set up key-based access instead (for example `ssh-copy-id {host}`), or add \ + an IdentityFile for this host in ~/.ssh/config." + ), + // A changed key and a first-seen key are both refused, but they are not + // the same news: one is routine setup, the other is the warning ssh + // exists to give. Reporting them identically would train the user to + // dismiss the serious one. + HostProbeErrorKind::HostKeyProblem + if stderr + .to_ascii_lowercase() + .contains("remote host identification has changed") => + { + format!( + "The host key for '{host}' has CHANGED since it was last trusted. This can mean \ + the host was rebuilt — or that the connection is being intercepted. Buzz will \ + not probe it. Verify the new key out of band before touching known_hosts." + ) + } + HostProbeErrorKind::HostKeyProblem => format!( + "The host key for '{host}' is not yet trusted on this machine. Buzz does not accept \ + host keys on your behalf — connect once with `ssh {host}`, check the fingerprint, \ + then probe again." + ), + HostProbeErrorKind::Unreachable => { + format!("'{host}' is not reachable: {}", first_line(stderr)) + } + HostProbeErrorKind::TimedOut => format!( + "Probing '{host}' exceeded {}s and was cancelled.", + PROBE_TIMEOUT.as_secs() + ), + HostProbeErrorKind::Truncated => format!( + "The probe of '{host}' was cut off before it finished. What it found is incomplete, \ + so it is not being reported. Check the connection to '{host}' and probe again." + ), + } +} + +fn first_line(text: &str) -> String { + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or("no error output") + .to_string() +} + +/// Probe one ssh host for harnesses and the `buzz` CLI. +/// +/// Never returns `Err` for a *host-side* problem: an unreachable or +/// unauthenticated host is a normal, reportable outcome, and the caller renders +/// one row per host regardless. `Err` is reserved for a failure to run `ssh` at +/// all. +pub fn probe_ssh_host(host: &SshHost) -> HostProbeResult { + let started = Instant::now(); + let targets = harness_probe_targets(); + let script = build_probe_script(&targets); + + let mut command = Command::new(resolve_ssh_binary()); + command.args(ssh_probe_args(host)).arg(&script); + + run_probe(command, &host.host, &targets, started) +} + +/// The `ssh` arguments preceding the probe script, ending with the host alias. +/// +/// Split out so the trust-affecting options are assertable: nothing else in this +/// module consults `known_hosts`, so whether Buzz can alter the user's trust +/// state is decided entirely by this list. +fn ssh_probe_args(host: &SshHost) -> Vec { + let mut args = vec![ + "-o".to_string(), + format!("ConnectTimeout={SSH_CONNECT_TIMEOUT_SECS}"), + // Never prompt. A probe that blocks on a password prompt would hang the + // UI with no way for the user to see or answer it. + "-o".to_string(), + "BatchMode=yes".to_string(), + // Reject an unknown key as well as a changed one. `accept-new` would + // write a first-seen key into the user's `known_hosts` as a side effect + // of opening a dialog and clicking Probe — Buzz would be making a trust + // decision, and persisting it, on their behalf. Both cases are a + // reportable status here; the user grants trust with `ssh `, where + // they see the fingerprint and answer for themselves. + "-o".to_string(), + "StrictHostKeyChecking=yes".to_string(), + // Suppress banners so parsing has less to discard. + "-o".to_string(), + "LogLevel=ERROR".to_string(), + ]; + if let Some(port) = &host.port { + args.push("-p".to_string()); + args.push(port.clone()); + } + // The alias, not `user@hostname`: the alias is what carries the user's own + // ssh config (User, IdentityFile, ProxyJump, and anything else we do not + // model). Rebuilding a user@host string would discard all of it. + args.push(host.host.clone()); + args +} + +/// Probe the machine Buzz is running on, using the identical script. +pub fn probe_localhost() -> HostProbeResult { + let started = Instant::now(); + let targets = harness_probe_targets(); + let script = build_probe_script(&targets); + + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg(&script); + + run_probe(command, LOCALHOST_ID, &targets, started) +} + +/// Execute a prepared probe command and shape its outcome. +fn run_probe( + mut command: Command, + host: &str, + targets: &[HarnessProbeTarget], + started: Instant, +) -> HostProbeResult { + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let base = |ok: bool| HostProbeResult { + host: host.to_string(), + ok, + duration_ms: started.elapsed().as_millis() as u64, + error: None, + error_kind: None, + user: None, + hostname: None, + os: None, + buzz_cli_path: None, + buzz_cli_version: None, + harnesses: Vec::new(), + }; + + let output = match wait_with_timeout(command, PROBE_TIMEOUT) { + Ok(Some(output)) => output, + Ok(None) => { + let kind = HostProbeErrorKind::TimedOut; + return HostProbeResult { + error: Some(failure_message(&kind, host, "")), + error_kind: Some(kind), + ..base(false) + }; + } + Err(err) => { + return HostProbeResult { + error: Some(format!("could not run probe for '{host}': {err}")), + error_kind: None, + ..base(false) + }; + } + }; + + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + + // Success is "the probe produced its own output", not "exit code 0". A login + // shell can exit non-zero because of an unrelated rc-file quirk while still + // having run every command we asked for; discarding that would report a + // healthy host as broken. + if !stdout.contains(PROBE_START) { + let kind = classify_ssh_failure(&stderr); + let message = match &kind { + Some(kind) => failure_message(kind, host, &stderr), + None => { + let detail = first_line(&stderr); + format!("probe of '{host}' produced no output: {detail}") + } + }; + return HostProbeResult { + error: Some(message), + error_kind: kind, + ..base(false) + }; + } + + // Both markers, not just the opening one. The script emits PROBE_END as its + // last statement, so its absence means the session died partway through the + // harness loop — and `parse_probe_output` cannot tell that from a host that + // genuinely has no `openclaw` installed. Reporting `ok: true` there would + // present "this harness is missing" and "we never got to look" as the same + // answer, and the connect dialog would offer a harness list that is missing + // entries for no visible reason. + if !stdout.contains(PROBE_END) { + let kind = HostProbeErrorKind::Truncated; + return HostProbeResult { + error: Some(failure_message(&kind, host, &stderr)), + error_kind: Some(kind), + ..base(false) + }; + } + + let facts = parse_probe_output(&stdout); + let harnesses = assemble_harnesses(&facts, targets); + let buzz = facts.binaries.get("buzz"); + + HostProbeResult { + user: facts.user.clone(), + hostname: facts.hostname.clone(), + os: facts.os.clone(), + buzz_cli_path: buzz.map(|(path, _)| path.clone()), + buzz_cli_version: buzz.and_then(|(_, version)| version.clone()), + harnesses, + ..base(true) + } +} + +/// Wait for a child with a wall-clock ceiling. +/// +/// Returns `Ok(None)` on timeout, having killed the child. `Command::output()` +/// has no timeout, and an ssh that connects but then stalls (a wedged login +/// shell, a hung NFS mount in a profile script) would otherwise block forever. +fn wait_with_timeout( + mut command: Command, + timeout: Duration, +) -> std::io::Result> { + let mut child = command.spawn()?; + + // Reading the pipes must not be deferred until after the wait: a child that + // fills its stdout pipe buffer blocks on write while we block on wait. + // Draining on threads keeps both sides moving. + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let stdout_reader = std::thread::spawn(move || read_all(stdout)); + let stderr_reader = std::thread::spawn(move || read_all(stderr)); + + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait()? { + Some(status) => break Some(status), + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + None => std::thread::sleep(Duration::from_millis(50)), + } + }; + + let stdout = stdout_reader.join().unwrap_or_default(); + let stderr = stderr_reader.join().unwrap_or_default(); + + Ok(status.map(|status| std::process::Output { + status, + stdout, + stderr, + })) +} + +fn read_all(source: Option) -> Vec { + let mut buffer = Vec::new(); + if let Some(mut source) = source { + let _ = std::io::Read::read_to_end(&mut source, &mut buffer); + } + buffer +} + +#[cfg(test)] +#[path = "remote_probe_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/remote_probe_tests.rs b/desktop/src-tauri/src/managed_agents/remote_probe_tests.rs new file mode 100644 index 0000000000..55f265fa1e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/remote_probe_tests.rs @@ -0,0 +1,578 @@ +//! Tests for [`super::remote_probe`]. +//! +//! Extracted to a sibling file to keep `remote_probe.rs` inside the desktop +//! file-size ratchet, following the `#[path = "..._tests.rs"]` convention used +//! elsewhere in this module. + +use super::*; + +fn targets() -> Vec { + harness_probe_targets() +} + +#[test] +fn probe_script_interpolates_no_user_input() { + let script = build_probe_script(&targets()); + // Everything in the script must come from the compiled tables. The only + // shell expansions present are the ones we wrote. + assert!(script.contains(PROBE_START)); + assert!(script.contains(PROBE_END)); + assert!(script.starts_with("exec $SHELL -lc '")); + assert!(script.trim_end().ends_with('\'')); + // The script is single-quoted into the ssh argv, so a literal single + // quote anywhere inside would terminate the quoting early and hand the + // remainder to the remote shell as separate words. + let body = script + .trim_end() + .trim_start_matches("exec $SHELL -lc '") + .trim_end_matches('\''); + assert!( + !body.contains('\''), + "script body must contain no literal single quote: {body}" + ); +} + +#[test] +fn probe_script_uses_a_login_but_not_interactive_shell() { + // -l is required: without it, homebrew/npm-global/venv prefixes are + // absent from PATH and a provisioned host reports as empty. + // + // -i must NOT be present. An interactive shell sources .zshrc/.bashrc, + // where prompt frameworks and completion init live; several of those + // block forever without a TTY. Verified against a real macOS zsh host: + // -lic hung indefinitely, -lc returned the full binary set. A hang turns + // a healthy host into a timeout, which is worse than a missed path. + let script = build_probe_script(&targets()); + assert!(script.contains("$SHELL -lc"), "must be a login shell"); + assert!( + !script.contains("-lic") && !script.contains("-li "), + "probe must not request an interactive shell: {script}" + ); +} + +#[test] +fn probe_script_contains_no_pipe_delimited_for_list() { + // An unquoted `|` inside a `for … in` list is a parse error in bash and + // zsh both, which kills the loop before it runs. + let script = build_probe_script(&targets()); + for line in script.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("for ") { + assert!( + !trimmed.contains('|'), + "for-loop list must not contain a pipe: {trimmed}" + ); + } + } +} + +#[test] +fn probe_script_covers_every_table_harness_and_the_buzz_cli() { + let targets = targets(); + let script = build_probe_script(&targets); + for target in &targets { + for command in target.acp_commands { + assert!( + script.contains(command), + "probe script missing ACP command {command} for {}", + target.id + ); + } + if let Some(cli) = target.underlying_cli { + assert!( + script.contains(cli), + "probe script missing vendor CLI {cli} for {}", + target.id + ); + } + } + assert!(script.contains("buzz"), "probe must look for the buzz CLI"); +} + +#[test] +fn every_version_call_is_time_bounded_and_stdin_closed() { + // Regression guard for a failure found on a real host: `claude --version` + // never returned, and because the call was unbounded it truncated the + // whole probe — every harness after it in the loop went unreported and + // the trailing sentinel never printed. The result read as a + // half-provisioned machine instead of a stuck command. + let script = build_probe_script(&targets()); + let version_line = script + .lines() + .find(|line| line.contains("--version")) + .expect("probe script must capture versions"); + + // A killer process bounds the call... + assert!( + version_line.contains(&format!("sleep {VERSION_TIMEOUT_SECS}")), + "version call must be time-bounded: {version_line}" + ); + assert!( + version_line.contains("kill -9"), + "version call must kill on timeout: {version_line}" + ); + // ...the killer's stdout is closed, or it holds the command + // substitution's pipe open for the full sleep and every binary costs + // the timeout even when it answers instantly... + assert!( + version_line.contains(">/dev/null 2>&1 &"), + "killer must not hold the capture pipe open: {version_line}" + ); + // ...and stdin is closed, so a harness that starts its JSON-RPC server + // instead of printing a version gets EOF rather than blocking. + assert!( + version_line.contains(" HostProbeResult { + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg(script); + run_probe(command, "workstation", &targets(), Instant::now()) +} + +#[test] +fn a_probe_cut_off_after_the_start_marker_is_not_reported_as_success() { + // A dropped session mid-probe. Only PROBE_START was required, so this + // returned `ok: true` carrying whichever harnesses happened to be + // enumerated before the connection died — indistinguishable from a host + // where the rest genuinely are not installed. + let result = probe_with_stdout(&format!( + "printf '%s\\nBIN:goose:/usr/bin/goose:1.2\\n' '{PROBE_START}'" + )); + + assert!( + !result.ok, + "an incomplete probe must not be reported as a successful one" + ); + assert_eq!(result.error_kind, Some(HostProbeErrorKind::Truncated)); + assert!( + result.harnesses.is_empty(), + "partial facts must be withheld, not shown as a complete answer" + ); + assert!(result.buzz_cli_path.is_none()); + assert!(result.os.is_none()); + let error = result.error.expect("a truncated probe must explain itself"); + assert!(error.contains("incomplete"), "unhelpful message: {error}"); +} + +#[test] +fn a_complete_probe_with_the_same_facts_does_succeed() { + // The control for the case above: identical output plus the closing + // marker. Without this, requiring PROBE_END could pass by rejecting + // everything. + let result = probe_with_stdout(&format!( + "printf '%s\\nBIN:goose:/usr/bin/goose:1.2\\nOS:Linux\\n%s\\n' \ + '{PROBE_START}' '{PROBE_END}'" + )); + + assert!(result.ok, "error: {:?}", result.error); + assert_eq!(result.error_kind, None); + assert_eq!(result.os.as_deref(), Some("Linux")); + assert_eq!(result.harnesses.len(), targets().len()); +} + +#[test] +fn a_truncated_probe_is_distinguished_from_one_that_never_started() { + // Both are failures, and both must stay distinct: "never started" is an + // ssh-level problem to classify from stderr, while "truncated" means + // authentication already succeeded. Collapsing them would point the user + // at the wrong layer. + let never_started = probe_with_stdout("printf 'motd only\\n'"); + assert!(!never_started.ok); + assert_ne!( + never_started.error_kind, + Some(HostProbeErrorKind::Truncated), + "no start marker is not a truncated probe" + ); +} + +#[test] +fn unreachable_host_reports_a_status_rather_than_erroring() { + let host = SshHost { + host: "buzz-nonexistent-test-host.invalid".to_string(), + hostname: None, + user: None, + port: None, + identity_file: None, + }; + let result = probe_ssh_host(&host); + assert!(!result.ok); + assert!(result.error.is_some()); + assert!(result.harnesses.is_empty()); + // Whatever the local resolver does, this must not be reported as a + // password wall — that would send the user chasing the wrong fix. + assert_ne!( + result.error_kind, + Some(HostProbeErrorKind::PasswordRequired) + ); +} diff --git a/desktop/src-tauri/src/managed_agents/ssh_config.rs b/desktop/src-tauri/src/managed_agents/ssh_config.rs new file mode 100644 index 0000000000..027b34c415 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/ssh_config.rs @@ -0,0 +1,334 @@ +//! `~/.ssh/config` host enumeration. +//! +//! Buzz needs the user's own host list to offer "which machine is this agent +//! on?" without asking them to retype connection details they already +//! maintain. This is a deliberately partial parser: it reads the four keywords +//! needed to open a connection and ignores everything else. `ssh` itself +//! remains the authority on how a connection is actually made — we never +//! reimplement its resolution rules, we only enumerate candidate host aliases +//! to show the user and hand back to `ssh` verbatim. + +use std::path::{Path, PathBuf}; + +/// One `Host` stanza from `~/.ssh/config`, reduced to the fields Buzz uses. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SshHost { + /// The `Host` alias as written. This is what gets passed to `ssh`, not + /// `hostname` — the alias is what carries the user's own config. + pub host: String, + pub hostname: Option, + pub user: Option, + pub port: Option, + pub identity_file: Option, +} + +/// Parse `~/.ssh/config` and return its host stanzas in file order. +/// +/// A missing or unreadable file yields an empty list rather than an error: not +/// having an ssh config is a normal state, and it means "no remote hosts to +/// offer", not "discovery failed". +pub fn parse_ssh_config() -> Vec { + let Some(home) = dirs::home_dir() else { + return Vec::new(); + }; + parse_ssh_config_at(&home.join(".ssh").join("config")) +} + +/// Testable core of [`parse_ssh_config`], reading from an explicit path. +pub fn parse_ssh_config_at(path: &Path) -> Vec { + let Ok(content) = std::fs::read_to_string(path) else { + return Vec::new(); + }; + parse_ssh_config_str(&content) +} + +/// Pure parser over the config text. +/// +/// Keyword matching is case-insensitive because ssh's own is; `Host`, `host`, +/// and `HOST` are all valid in a real config file. +pub fn parse_ssh_config_str(content: &str) -> Vec { + let mut hosts: Vec = Vec::new(); + // Entries created by the `Host` line currently in effect. `None` before the + // first one; an empty range for a stanza whose aliases were all patterns, so + // its keywords apply to nothing instead of leaking onto the stanza above. + let mut current_stanza: Option> = None; + + for raw_line in content.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + // ssh accepts `Key value` and `Key=value`; normalize the separator + // before splitting so `User=alice` is not read as a key named + // "user=alice". + let normalized = line.replacen('=', " ", 1); + let mut parts = normalized.split_whitespace(); + let Some(key) = parts.next() else { + continue; + }; + let value = parts.collect::>().join(" "); + if value.is_empty() { + continue; + } + + if key.eq_ignore_ascii_case("host") { + // One `Host` line may declare several aliases. Each becomes its own + // entry so the user can pick any of them, and subsequent keywords + // in the stanza apply to all of them — which is ssh's behavior. + let start = hosts.len(); + for alias in value.split_whitespace() { + // Patterns cannot be connected to, only matched against. `*` in + // particular is the catch-all defaults stanza. + if alias.contains('*') || alias.contains('?') || alias.starts_with('!') { + continue; + } + hosts.push(SshHost { + host: alias.to_string(), + hostname: None, + user: None, + port: None, + identity_file: None, + }); + } + current_stanza = Some(start..hosts.len()); + continue; + } + + // Keywords before any `Host` line are global defaults. We deliberately + // do not model them: applying them would mean reimplementing ssh's + // precedence rules, and `ssh` already applies them itself when we + // invoke it with the alias. + let Some(stanza) = current_stanza.clone() else { + continue; + }; + let keyword = key.to_ascii_lowercase(); + for entry in &mut hosts[stanza] { + match keyword.as_str() { + "hostname" => entry.hostname = Some(value.clone()), + "user" => entry.user = Some(value.clone()), + "port" => entry.port = Some(value.clone()), + "identityfile" => entry.identity_file = Some(value.clone()), + _ => {} + } + } + } + + hosts +} + +/// Resolve the `ssh` binary Buzz should invoke. +/// +/// A GUI app on macOS inherits a minimal `PATH` from launchd, so a bare `ssh` +/// lookup can miss. The standard locations are checked before falling back to +/// the bare name for the platform's own resolution. +pub fn resolve_ssh_binary() -> PathBuf { + for candidate in ["/usr/bin/ssh", "/bin/ssh", "/opt/homebrew/bin/ssh"] { + let path = Path::new(candidate); + if path.exists() { + return path.to_path_buf(); + } + } + PathBuf::from("ssh") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_a_basic_stanza() { + let hosts = parse_ssh_config_str( + "Host workstation\n HostName workstation.example.com\n User alice\n \ + IdentityFile ~/.ssh/id_ed25519\n", + ); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].host, "workstation"); + assert_eq!( + hosts[0].hostname.as_deref(), + Some("workstation.example.com") + ); + assert_eq!(hosts[0].user.as_deref(), Some("alice")); + assert_eq!(hosts[0].identity_file.as_deref(), Some("~/.ssh/id_ed25519")); + assert!(hosts[0].port.is_none()); + } + + #[test] + fn parses_multiple_stanzas_independently() { + let hosts = parse_ssh_config_str( + "Host alpha\n User a\n Port 22\n\nHost beta\n User b\n Port 2222\n", + ); + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].host, "alpha"); + assert_eq!(hosts[0].user.as_deref(), Some("a")); + assert_eq!(hosts[0].port.as_deref(), Some("22")); + assert_eq!(hosts[1].host, "beta"); + assert_eq!(hosts[1].user.as_deref(), Some("b")); + assert_eq!(hosts[1].port.as_deref(), Some("2222")); + } + + #[test] + fn skips_wildcard_and_negated_patterns() { + // `*` is the defaults stanza and cannot be connected to; offering it as + // a host would produce a guaranteed-failing probe. + let hosts = parse_ssh_config_str( + "Host *\n ServerAliveInterval 60\n\nHost prod-?\n User x\n\n\ + Host !secret\n User y\n\nHost real\n User z\n", + ); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].host, "real"); + assert_eq!(hosts[0].user.as_deref(), Some("z")); + } + + #[test] + fn one_host_line_with_several_aliases_shares_its_keywords() { + let hosts = parse_ssh_config_str("Host one two three\n User shared\n Port 2200\n"); + assert_eq!(hosts.len(), 3); + for entry in &hosts { + assert_eq!(entry.user.as_deref(), Some("shared")); + assert_eq!(entry.port.as_deref(), Some("2200")); + } + assert_eq!( + hosts.iter().map(|h| h.host.as_str()).collect::>(), + vec!["one", "two", "three"] + ); + } + + #[test] + fn accepts_equals_separated_and_mixed_case_keywords() { + let hosts = parse_ssh_config_str("HOST workstation\n user=alice\n HostName=box.local\n"); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].user.as_deref(), Some("alice")); + assert_eq!(hosts[0].hostname.as_deref(), Some("box.local")); + } + + #[test] + fn ignores_comments_blank_lines_and_unknown_keywords() { + let hosts = parse_ssh_config_str( + "# a comment\n\nHost workstation\n # inline comment line\n \ + ForwardAgent yes\n ProxyJump bastion\n User alice\n", + ); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].user.as_deref(), Some("alice")); + } + + #[test] + fn keywords_before_any_host_line_are_ignored() { + // Global defaults would require reimplementing ssh's precedence; ssh + // applies them itself when we invoke it with the alias. + let hosts = parse_ssh_config_str("User global\n\nHost workstation\n Port 22\n"); + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].host, "workstation"); + assert!(hosts[0].user.is_none()); + } + + #[test] + fn missing_file_is_empty_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let hosts = parse_ssh_config_at(&dir.path().join("does-not-exist")); + assert!(hosts.is_empty()); + } + + #[test] + fn empty_config_yields_no_hosts() { + assert!(parse_ssh_config_str("").is_empty()); + assert!(parse_ssh_config_str("\n\n# only comments\n").is_empty()); + } + + #[test] + fn later_keyword_still_reaches_a_multi_alias_group() { + // Regression guard for the group-tracking logic: `User` arrives first + // and mutates all three entries, then `Port` must still find the same + // group rather than only the tail entry. + let hosts = parse_ssh_config_str("Host a b c\n User u\n Port 42\n"); + assert_eq!(hosts.len(), 3); + for entry in &hosts { + assert_eq!(entry.port.as_deref(), Some("42"), "host {}", entry.host); + } + } + + #[test] + fn a_keywordless_host_line_does_not_absorb_the_next_stanzas_keywords() { + // The stanza boundary used to be reconstructed by comparing field + // values, which made two consecutive `Host` lines indistinguishable + // while both were still blank: `Port 2222` matched `alpha` as well as + // `beta` and was written to both. `probe_ssh_host` passes the parsed + // port through as `-p`, so alpha was probed on beta's port — a + // connection to a port the user never associated with that host. + let hosts = parse_ssh_config_str("Host alpha\nHost beta\n Port 2222\n"); + + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].host, "alpha"); + assert_eq!( + hosts[0].port, None, + "alpha declares no port and must not inherit beta's" + ); + assert_eq!(hosts[1].host, "beta"); + assert_eq!(hosts[1].port.as_deref(), Some("2222")); + } + + #[test] + fn every_keyword_stays_inside_its_own_stanza() { + // All four keywords, in the arrangement the old value-comparison could + // not survive: `alpha` is still entirely blank when `beta`'s keywords + // arrive, so the two stanzas were indistinguishable and every value + // landed on both. + let hosts = parse_ssh_config_str( + "Host alpha\nHost beta\n HostName beta.internal\n User second\n \ + Port 2222\n IdentityFile ~/.ssh/beta\n", + ); + + assert_eq!(hosts.len(), 2); + let alpha = &hosts[0]; + assert_eq!(alpha.host, "alpha"); + assert_eq!( + ( + alpha.hostname.as_deref(), + alpha.user.as_deref(), + alpha.port.as_deref(), + alpha.identity_file.as_deref(), + ), + (None, None, None, None), + "alpha declares nothing and must inherit nothing" + ); + let beta = &hosts[1]; + assert_eq!(beta.hostname.as_deref(), Some("beta.internal")); + assert_eq!(beta.user.as_deref(), Some("second")); + assert_eq!(beta.port.as_deref(), Some("2222")); + assert_eq!(beta.identity_file.as_deref(), Some("~/.ssh/beta")); + } + + #[test] + fn an_earlier_stanzas_values_are_not_overwritten_by_a_later_one() { + // The other direction: a populated stanza must stay as written once the + // next `Host` line takes over. + let hosts = parse_ssh_config_str( + "Host alpha\n User first\n Port 22\nHost beta\n User second\n Port 2222\n", + ); + + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].user.as_deref(), Some("first")); + assert_eq!(hosts[0].port.as_deref(), Some("22")); + assert_eq!(hosts[1].user.as_deref(), Some("second")); + assert_eq!(hosts[1].port.as_deref(), Some("2222")); + } + + #[test] + fn a_pattern_only_stanza_swallows_its_own_keywords() { + // `Host *` contributes no entry, so its keywords have nowhere to land. + // Under value-comparison tracking they landed on the previous stanza + // instead, which silently rewrote a real host's port from the defaults + // block — the opposite of ignoring global defaults. + let hosts = + parse_ssh_config_str("Host alpha\n Port 22\nHost *\n Port 2222\n User nobody\n"); + + assert_eq!(hosts.len(), 1); + assert_eq!(hosts[0].host, "alpha"); + assert_eq!( + hosts[0].port.as_deref(), + Some("22"), + "the catch-all stanza must not overwrite an explicit host" + ); + assert_eq!(hosts[0].user, None); + } +} diff --git a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx index 05441e8618..277e18fae8 100644 --- a/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddAgentToChannelDialog.tsx @@ -4,11 +4,16 @@ import { type AttachManagedAgentToChannelResult, useAttachManagedAgentToChannelMutation, } from "@/features/agents/hooks"; +import { + type AttachConnectedAgentToChannelResult, + useAttachConnectedAgentToChannelMutation, +} from "./useConnectedAgents"; import { useChannelMembersQuery, useChannelsQuery, } from "@/features/channels/hooks"; import type { Channel, ChannelRole, ManagedAgent } from "@/shared/api/types"; +import type { ConnectedAgent } from "@/shared/api/remoteAgentTypes"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { @@ -20,26 +25,43 @@ import { } from "@/shared/ui/dialog"; import { CopyButton } from "./CopyButton"; -export function AddAgentToChannelDialog({ - agent, - open, - onAdded, - onOpenChange, -}: { - agent: ManagedAgent | null; +type AddAgentToChannelDialogSharedProps = { open: boolean; - onAdded: ( - channel: Channel, - result: AttachManagedAgentToChannelResult, - ) => void; onOpenChange: (open: boolean) => void; -}) { +}; + +type AddAgentToChannelDialogProps = + | (AddAgentToChannelDialogSharedProps & { + agent: ManagedAgent | null; + kind?: "managed"; + onAdded: ( + channel: Channel, + result: AttachManagedAgentToChannelResult, + ) => void; + }) + | (AddAgentToChannelDialogSharedProps & { + agent: ConnectedAgent | null; + kind: "connected"; + onAdded: ( + channel: Channel, + result: AttachConnectedAgentToChannelResult, + ) => void; + }); + +export function AddAgentToChannelDialog(props: AddAgentToChannelDialogProps) { + const { agent, open, onOpenChange } = props; const channelsQuery = useChannelsQuery(); const [channelId, setChannelId] = React.useState(""); const [role, setRole] = React.useState>("bot"); - const attachAgentMutation = useAttachManagedAgentToChannelMutation( + const attachManagedAgentMutation = useAttachManagedAgentToChannelMutation( channelId || null, ); + const attachConnectedAgentMutation = + useAttachConnectedAgentToChannelMutation(); + const activeMutation = + props.kind === "connected" + ? attachConnectedAgentMutation + : attachManagedAgentMutation; const channels = React.useMemo( () => (channelsQuery.data ?? []).filter( @@ -51,7 +73,8 @@ export function AddAgentToChannelDialog({ function reset() { setChannelId(""); setRole("bot"); - attachAgentMutation.reset(); + attachManagedAgentMutation.reset(); + attachConnectedAgentMutation.reset(); } function handleOpenChange(next: boolean) { @@ -91,17 +114,25 @@ export function AddAgentToChannelDialog({ channels.find((channel) => channel.id === channelId) ?? null; async function handleSubmit() { - if (!agent || !selectedChannel) { + if (!props.agent || !selectedChannel) { return; } try { - const result = await attachAgentMutation.mutateAsync({ - agent, - role, - }); - - onAdded(selectedChannel, result); + if (props.kind === "connected") { + const result = await attachConnectedAgentMutation.mutateAsync({ + agent: props.agent, + channelId: selectedChannel.id, + role, + }); + props.onAdded(selectedChannel, result); + } else { + const result = await attachManagedAgentMutation.mutateAsync({ + agent: props.agent, + role, + }); + props.onAdded(selectedChannel, result); + } handleOpenChange(false); } catch { // React Query stores the error; keep the dialog open and render it inline. @@ -116,8 +147,10 @@ export function AddAgentToChannelDialog({ Add agent to channel Add {agent?.name ?? "this agent"} to a channel so desktop chat can - `@mention` it. Running agents pick up new channels automatically - via membership notifications. + `@mention` it.{" "} + {props.kind === "connected" + ? "Buzz writes membership only — the remote agent keeps supervising itself." + : "Running agents pick up new channels automatically via membership notifications."} @@ -128,9 +161,7 @@ export function AddAgentToChannelDialog({ setRole(event.target.value as Exclude) @@ -200,9 +231,9 @@ export function AddAgentToChannelDialog({

) : null} - {attachAgentMutation.error instanceof Error ? ( + {activeMutation.error instanceof Error ? (

- {attachAgentMutation.error.message} + {activeMutation.error.message}

) : null} @@ -221,13 +252,13 @@ export function AddAgentToChannelDialog({ !agent || !selectedChannel || channelsQuery.isLoading || - attachAgentMutation.isPending + activeMutation.isPending } onClick={() => void handleSubmit()} size="sm" type="button" > - {attachAgentMutation.isPending + {activeMutation.isPending ? "Adding..." : isAlreadyMember ? "Re-add to channel" diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 3d1673c365..29b18797bb 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -19,8 +19,11 @@ import { TeamShareDialog } from "./TeamShareDialog"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; +import { ConnectAgentDialog } from "./ConnectAgentDialog"; +import { ConnectedAgentsSection } from "./ConnectedAgentsSection"; import { TeamsSection } from "./TeamsSection"; import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; +import { useConnectedAgents } from "./useConnectedAgents"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -44,6 +47,7 @@ export function AgentsView() { const { data: bakedEnv } = useBakedBuildEnvQuery({ enabled: true }); const inheritedDefaults = getInheritedAgentDefaults(globalConfig, bakedEnv); const agents = useManagedAgentActions(); + const connected = useConnectedAgents(); const personas = usePersonaActions(); const teamImportInputRef = React.useRef(null); const aiDefaultsTriggerRef = React.useRef(null); @@ -271,6 +275,19 @@ export function AgentsView() { }} /> + { + void connected.handleDisconnect(agent); + }} + /> + + + {isCreateDialogOpen ? ( ) : null} + {connected.agentToAddToChannel ? ( + { + if (!open) { + connected.setAgentToAddToChannel(null); + } + }} + open={connected.agentToAddToChannel !== null} + /> + ) : null} {agents.createdAgent ? ( void; + onOpenChange: (open: boolean) => void; +}) { + const [draft, setDraft] = React.useState(emptyConnectAgentDraft); + const [hosts, setHosts] = React.useState([]); + const [hostsLoaded, setHostsLoaded] = React.useState(false); + const [error, setError] = React.useState(null); + const [isSubmitting, setIsSubmitting] = React.useState(false); + + React.useEffect(() => { + if (!open) return; + let cancelled = false; + void listSshHosts() + .then((result) => { + if (cancelled) return; + setHosts(result); + setHostsLoaded(true); + setDraft((current) => + current.host || result.length === 0 + ? current + : { ...current, host: result[0].host }, + ); + }) + .catch(() => { + if (!cancelled) setHostsLoaded(true); + }); + return () => { + cancelled = true; + }; + }, [open]); + + const runProbe = React.useCallback((host: string) => { + if (!host) return; + setDraft((current) => ({ ...current, isProbing: true, probe: null })); + void probeAgentHost(host) + .then((probe) => { + setDraft((current) => + // A stale probe must not overwrite a newer host selection — the user + // may have switched machines while a slow ssh handshake was open. + current.host === host + ? { ...current, probe, isProbing: false } + : current, + ); + }) + .catch(() => { + setDraft((current) => + current.host === host + ? { ...current, probe: null, isProbing: false } + : current, + ); + }); + }, []); + + // Probe on host change only — deliberately not on every draft edit, which + // would open an ssh connection per keystroke. The probe is what fills the + // harness options and it is read-only on the host, so running it + // automatically costs the user nothing they did not ask for by picking a + // machine. + React.useEffect(() => { + if (!open || !draft.host) return; + runProbe(draft.host); + }, [draft.host, open, runProbe]); + + function reset() { + setDraft(emptyConnectAgentDraft); + setError(null); + setIsSubmitting(false); + } + + function handleOpenChange(next: boolean) { + if (!next) reset(); + onOpenChange(next); + } + + async function handleSubmit() { + const payload = connectAgentPayload(draft); + if (!payload) return; + setIsSubmitting(true); + setError(null); + try { + const agent = await connectRemoteAgent(payload); + onConnected(agent); + handleOpenChange(false); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setIsSubmitting(false); + } + } + + const readyHarnesses = harnessOptions(draft.probe); + const pubkeyProblem = pubkeyInputMessage(draft.pubkey); + const nameProblem = nameInputMessage(draft.name); + + return ( + + + + Connect an agent + + Point Buzz at an agent that already runs on one of your machines. + The agent keeps its own key and supervises itself — Buzz only talks + to it. + + + +
+
+ + {hostsLoaded && hosts.length === 0 ? ( +

+ No hosts in ~/.ssh/config. + Buzz reaches self-hosted agents through your own ssh config — + add a Host stanza for the + machine and reopen this dialog. +

+ ) : ( +
+ + +
+ )} + +
+ +
+ + + setDraft((current) => ({ + ...current, + pubkey: event.target.value, + })) + } + placeholder="npub1… or 64 hex characters" + spellCheck={false} + value={draft.pubkey} + /> + {pubkeyProblem ? ( +

{pubkeyProblem}

+ ) : ( +

+ The agent's public key. Run{" "} + buzz users me on the machine + to read it. +

+ )} +
+ +
+ + + setDraft((current) => ({ + ...current, + name: event.target.value, + })) + } + placeholder="Scout" + value={draft.name} + /> + {nameProblem ? ( +

{nameProblem}

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

+ Recorded for reference. Buzz never runs it — the agent starts + itself. +

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

+ {error} +

+ ) : null} +
+ +
+ + +
+
+
+ ); +} + +/** + * What the host probe found, or why it could not say. + * + * A probe failure is reported but never blocks the connect: a machine that is + * asleep or off the VPN is still an agent host worth recording. + */ +function HostProbeSummary({ draft }: { draft: typeof emptyConnectAgentDraft }) { + if (draft.isProbing) { + return ( +

+ + Checking {draft.host}… +

+ ); + } + + const probe = draft.probe; + if (!probe) return null; + + if (!probe.ok) { + return ( +

+ + + {probe.errorKind === "password_required" + ? "This machine asked for a password. Buzz only uses key-based ssh — add a key to connect it later. You can still record the agent now." + : (probe.error ?? + "Could not reach this machine. You can still record the agent now.")} + +

+ ); + } + + const readyCount = harnessOptions(probe).length; + return ( +
+

+ {readyCount === 0 + ? "No known harnesses found." + : `${readyCount} harness${readyCount === 1 ? "" : "es"} available.`} + {probe.buzzCliVersion ? ` buzz ${probe.buzzCliVersion}.` : ""} +

+ {missingBuzzCli(probe) ? ( +

+ + + The buzz CLI is not on this + machine's PATH. The agent needs it to reach the relay. + +

+ ) : null} +
+ ); +} diff --git a/desktop/src/features/agents/ui/ConnectedAgentsSection.tsx b/desktop/src/features/agents/ui/ConnectedAgentsSection.tsx new file mode 100644 index 0000000000..941c337bfd --- /dev/null +++ b/desktop/src/features/agents/ui/ConnectedAgentsSection.tsx @@ -0,0 +1,245 @@ +import * as React from "react"; +import { + Loader2, + Plug, + RefreshCw, + Server, + Unplug, + UserPlus, +} from "lucide-react"; + +import { probeAgentHost } from "@/shared/api/remoteAgentApi"; +import type { + ConnectedAgent, + HostProbeResult, +} from "@/shared/api/remoteAgentTypes"; +import { Button } from "@/shared/ui/button"; +import { SectionHeader } from "@/shared/ui/PageHeader"; +import { reachabilityLabel } from "./connectAgentIntent"; +import { PubKey } from "@/shared/ui/PubKey"; + +/** + * The Connected-agents surface: agents that run on machines the user owns. + * + * There are deliberately no Start, Stop, Restart, or Deploy controls anywhere + * in this section. Buzz does not own these processes, and a button that cannot + * work is worse than no button — it invites the user to conclude the agent is + * broken when it is simply not Buzz's to command. The actions here stay inside + * Buzz's real authority: write channel membership, check whether the machine + * answers, or forget the local pointer. + */ +export function ConnectedAgentsSection({ + agents, + error, + isLoading, + isPending, + noticeMessage, + onAddToChannel, + onConnect, + onDisconnect, +}: { + agents: ConnectedAgent[]; + error: Error | null; + isLoading: boolean; + isPending: boolean; + noticeMessage: string | null; + onAddToChannel: (agent: ConnectedAgent) => void; + onConnect: () => void; + onDisconnect: (agent: ConnectedAgent) => void; +}) { + const [probes, setProbes] = React.useState< + Record + >({}); + + const checkHost = React.useCallback((host: string) => { + setProbes((current) => ({ ...current, [host]: "pending" })); + void probeAgentHost(host) + .then((result) => { + setProbes((current) => ({ ...current, [host]: result })); + }) + .catch((cause) => { + // A failure to run ssh at all is still a reachability answer; render it + // rather than leaving the row stuck on "checking". + setProbes((current) => ({ + ...current, + [host]: { + host, + ok: false, + durationMs: 0, + error: cause instanceof Error ? cause.message : String(cause), + harnesses: [], + }, + })); + }); + }, []); + + return ( +
+ + + Connect an agent + + } + className="mx-auto w-full max-w-[996px]" + description="Agents running on your own machines. They hold their own keys and start themselves — Buzz talks to them." + title="Connected agents" + /> + +
+ {error ? ( +

+ Could not load connected agents: {error.message} +

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

+ {noticeMessage} +

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

Loading…

+ ) : null} + + {!isLoading && agents.length === 0 && !error ? ( +

+ Nothing connected yet. An agent on another machine needs its own key + and the buzz CLI; connect it here + once it can reach the relay. +

+ ) : null} + + {agents.map((agent) => ( + onAddToChannel(agent)} + onCheck={() => checkHost(agent.host)} + onDisconnect={() => onDisconnect(agent)} + probe={probes[agent.host]} + /> + ))} +
+
+ ); +} + +function ConnectedAgentRow({ + agent, + isPending, + onAddToChannel, + onCheck, + onDisconnect, + probe, +}: { + agent: ConnectedAgent; + isPending: boolean; + onAddToChannel: () => void; + onCheck: () => void; + onDisconnect: () => void; + probe: HostProbeResult | "pending" | undefined; +}) { + return ( +
+ +
+
+ {agent.name} + + on {agent.host} + + {agent.harness ? ( + + {agent.harness} + + ) : null} +
+
+ + +
+
+
+ + + +
+
+ ); +} + +/** + * Reachability of the machine, not liveness of the agent. + * + * The distinction is deliberate and the wording keeps it: a reachable host does + * not mean the agent process is up, and Buzz has no way to ask. Presence on the + * relay — which the agent publishes itself — is the answer to "is it running", + * and it belongs to the agent, not to this panel. + */ +function Reachability({ + probe, +}: { + probe: HostProbeResult | "pending" | undefined; +}) { + if (probe === undefined) return null; + if (probe === "pending") { + return checking…; + } + if (probe.ok) { + return ( + + machine reachable + {probe.buzzCliPath ? "" : " · no buzz CLI"} + + ); + } + return ( + {reachabilityLabel(probe)} + ); +} diff --git a/desktop/src/features/agents/ui/connectAgentIntent.test.mjs b/desktop/src/features/agents/ui/connectAgentIntent.test.mjs new file mode 100644 index 0000000000..a0c61226a1 --- /dev/null +++ b/desktop/src/features/agents/ui/connectAgentIntent.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + canSubmitConnectAgent, + connectAgentPayload, + emptyConnectAgentDraft, + harnessOptions, + missingBuzzCli, + nameInputMessage, + pubkeyInputMessage, + reachabilityLabel, + verifyPubkeyInput, +} from "./connectAgentIntent.ts"; + +const HEX = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"; +const NPUB = "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"; + +function draft(overrides = {}) { + return { + ...emptyConnectAgentDraft, + host: "workstation", + pubkey: HEX, + name: "Scout", + ...overrides, + }; +} + +function probe(overrides = {}) { + return { + host: "workstation", + ok: true, + durationMs: 900, + harnesses: [], + buzzCliPath: "/Users/alice/.local/bin/buzz", + ...overrides, + }; +} + +test("both pubkey forms a user actually has on hand are accepted", () => { + assert.equal(verifyPubkeyInput(HEX).kind, "ok"); + assert.equal(verifyPubkeyInput(NPUB).kind, "ok"); + assert.equal(verifyPubkeyInput(HEX.toUpperCase()).kind, "ok"); + assert.equal(verifyPubkeyInput(` ${NPUB} `).kind, "ok"); +}); + +test("a pasted secret key is called out as such, not as invalid input", () => { + // A user who pastes an nsec has made a serious mistake. "Invalid pubkey" + // would not tell them what it was, and they would try again with the same + // secret. + assert.equal(verifyPubkeyInput("nsec1abcdef").kind, "secret"); + const message = pubkeyInputMessage("nsec1abcdef"); + assert.match(message, /secret key/i); + assert.match(message, /never leave/i); +}); + +test("malformed pubkeys are rejected", () => { + for (const bad of [ + "not-a-key", + "npub1short", + HEX.slice(0, 63), + `${HEX}f`, + // Bech32 excludes 1, b, i, and o — a lookalike must not pass. + `npub1${"b".repeat(58)}`, + ]) { + assert.equal(verifyPubkeyInput(bad).kind, "invalid", bad); + } +}); + +test("an empty pubkey is silent, not an error", () => { + // Nothing typed yet is not a mistake; showing red text on an untouched field + // trains users to ignore it. + assert.equal(verifyPubkeyInput("").kind, "empty"); + assert.equal(pubkeyInputMessage(""), null); + assert.equal(pubkeyInputMessage(" "), null); +}); + +test("names are bounded and the bound is stated", () => { + assert.equal(nameInputMessage("Scout"), null); + assert.equal(nameInputMessage(""), null); + assert.equal(nameInputMessage("n".repeat(64)), null); + assert.match(nameInputMessage("n".repeat(65)), /64 characters/); +}); + +test("submit requires a host, a well-formed pubkey, and a name", () => { + assert.equal(canSubmitConnectAgent(draft()), true); + assert.equal(canSubmitConnectAgent(draft({ host: "" })), false); + assert.equal(canSubmitConnectAgent(draft({ host: " " })), false); + assert.equal(canSubmitConnectAgent(draft({ pubkey: "nope" })), false); + assert.equal(canSubmitConnectAgent(draft({ name: "" })), false); + assert.equal(canSubmitConnectAgent(draft({ name: "n".repeat(65) })), false); +}); + +test("submit does not require a reachable host", () => { + // A machine that is asleep, off the VPN, or mid-reboot is still an agent host + // the user wants recorded. Gating on reachability would break the feature + // exactly during setup. + assert.equal(canSubmitConnectAgent(draft({ probe: null })), true); + assert.equal( + canSubmitConnectAgent( + draft({ probe: probe({ ok: false, errorKind: "unreachable" }) }), + ), + true, + ); +}); + +test("submit is blocked while a probe is in flight", () => { + // The probe fills the harness options; submitting mid-probe would record a + // null harness the user was about to pick. + assert.equal(canSubmitConnectAgent(draft({ isProbing: true })), false); +}); + +test("the payload trims and omits an unset harness", () => { + assert.deepEqual( + connectAgentPayload(draft({ host: " workstation ", name: " Scout " })), + { host: "workstation", pubkey: HEX, name: "Scout", harness: null }, + ); + assert.deepEqual( + connectAgentPayload(draft({ harness: "claude" })).harness, + "claude", + ); + assert.equal(connectAgentPayload(draft({ harness: " " })).harness, null); +}); + +test("an unsubmittable draft yields no payload", () => { + assert.equal(connectAgentPayload(draft({ pubkey: "" })), null); +}); + +test("only ready harnesses are offered", () => { + // An ACP adapter whose vendor CLI is missing starts and then fails at first + // use. Offering it would record something known-broken as the agent's + // harness. + const options = harnessOptions( + probe({ + harnesses: [ + { id: "claude", label: "Claude Code", ready: true }, + { id: "codex", label: "Codex", ready: false }, + ], + }), + ); + assert.deepEqual( + options.map((harness) => harness.id), + ["claude"], + ); +}); + +test("a failed or absent probe offers no harnesses", () => { + assert.deepEqual(harnessOptions(null), []); + assert.deepEqual( + harnessOptions( + probe({ + ok: false, + errorKind: "password_required", + harnesses: [{ id: "claude", label: "Claude Code", ready: true }], + }), + ), + [], + ); +}); + +test("a missing buzz CLI is flagged only once the probe succeeded", () => { + // Without the CLI the agent cannot reach the relay at all, so it is the one + // warning worth surfacing — but an unreachable host has not told us anything + // about its CLI, and claiming it is missing would be a fabrication. + assert.equal(missingBuzzCli(probe({ buzzCliPath: null })), true); + assert.equal(missingBuzzCli(probe()), false); + assert.equal(missingBuzzCli(null), false); + assert.equal(missingBuzzCli(probe({ ok: false, buzzCliPath: null })), false); +}); + +test("a failed probe is labelled by cause, not as unreachable", () => { + // "machine unreachable" is wrong for every classified kind except one — the + // host answered in all the others. The host-key case matters most: Buzz probes + // with strict checking and never writes known_hosts, so this label is the only + // prompt telling the user to go review a fingerprint. + const label = (errorKind) => + reachabilityLabel({ + host: "workstation", + ok: false, + durationMs: 1, + errorKind, + harnesses: [], + }); + + assert.equal(label("host_key_problem"), "host key not trusted"); + assert.equal(label("truncated"), "probe incomplete \u00b7 retry"); + assert.equal(label("password_required"), "needs an ssh key"); + assert.equal(label("timed_out"), "probe timed out"); + assert.equal(label("unreachable"), "machine unreachable"); + + // Only `unreachable` may claim the machine could not be reached. + for (const kind of [ + "host_key_problem", + "truncated", + "password_required", + "timed_out", + ]) { + assert.ok( + !label(kind).includes("unreachable"), + `${kind} must not be reported as unreachable`, + ); + } +}); + +test("an unclassified probe failure does not invent a cause", () => { + const label = reachabilityLabel({ + host: "workstation", + ok: false, + durationMs: 1, + errorKind: null, + harnesses: [], + }); + assert.equal(label, "probe failed"); +}); diff --git a/desktop/src/features/agents/ui/connectAgentIntent.ts b/desktop/src/features/agents/ui/connectAgentIntent.ts new file mode 100644 index 0000000000..43d8fe5c2d --- /dev/null +++ b/desktop/src/features/agents/ui/connectAgentIntent.ts @@ -0,0 +1,170 @@ +import type { + HostProbeResult, + RemoteHarness, +} from "@/shared/api/remoteAgentTypes"; + +/** + * Draft state for the Connect-an-agent dialog. + * + * `probe` is the RC3 host probe result, kept in the draft rather than derived + * on submit because the harness options and the "is this host even reachable" + * answer both come from it. + */ +export type ConnectAgentDraft = { + host: string; + pubkey: string; + name: string; + harness: string; + probe: HostProbeResult | null; + isProbing: boolean; +}; + +export const emptyConnectAgentDraft: ConnectAgentDraft = { + host: "", + pubkey: "", + name: "", + harness: "", + probe: null, + isProbing: false, +}; + +/** + * Client-side pubkey shape check. + * + * The backend is the authority — it normalizes and stores — but repeating the + * shape check here lets the dialog disable submit and explain why instead of + * round-tripping to produce an error. `nsec` gets its own answer because + * "invalid" would not tell a user who just pasted their agent's secret what + * they actually did. + */ +export type PubkeyVerdict = + | { kind: "empty" } + | { kind: "secret" } + | { kind: "invalid" } + | { kind: "ok" }; + +const HEX64 = /^[0-9a-fA-F]{64}$/; +// npub1 + 58 bech32 data characters. Length is checked rather than the checksum: +// the backend verifies the checksum, and a client-side bech32 implementation +// here would be a second decoder to keep correct. +const NPUB = /^npub1[023456789acdefghjklmnpqrstuvwxyz]{58}$/; + +export function verifyPubkeyInput(input: string): PubkeyVerdict { + const trimmed = input.trim(); + if (!trimmed) return { kind: "empty" }; + if (trimmed.startsWith("nsec")) return { kind: "secret" }; + if (HEX64.test(trimmed) || NPUB.test(trimmed)) return { kind: "ok" }; + return { kind: "invalid" }; +} + +/** Human-readable reason a pubkey input is not usable yet, or `null`. */ +export function pubkeyInputMessage(input: string): string | null { + switch (verifyPubkeyInput(input).kind) { + case "empty": + return null; + case "secret": + return "That is a secret key. A self-hosted agent's nsec must never leave its own machine — paste its npub instead."; + case "invalid": + return "Expected an npub or 64 hex characters."; + case "ok": + return null; + } +} + +export const MAX_CONNECTED_NAME_LENGTH = 64; + +/** Human-readable reason a name is not usable yet, or `null`. */ +export function nameInputMessage(input: string): string | null { + const trimmed = input.trim(); + if (!trimmed) return null; + if (trimmed.length > MAX_CONNECTED_NAME_LENGTH) { + return `Names are limited to ${MAX_CONNECTED_NAME_LENGTH} characters.`; + } + return null; +} + +/** + * Harnesses worth offering for a connected agent. + * + * Only `ready` ones: an ACP adapter whose vendor CLI is missing starts and then + * fails at first use, so listing it as the agent's harness would record + * something known-broken. An empty list is a legitimate answer — the host may + * run an agent Buzz has no recipe for — which is why the harness field is + * optional. + */ +export function harnessOptions(probe: HostProbeResult | null): RemoteHarness[] { + if (!probe?.ok) return []; + return probe.harnesses.filter((harness) => harness.ready); +} + +/** + * True when the host probe came back but found no `buzz` CLI. + * + * Not a blocker: the CLI can be installed after connecting, and a user may be + * recording an agent they are still setting up. It is the single most useful + * warning to show, because without it the agent cannot reach the relay at all. + */ +export function missingBuzzCli(probe: HostProbeResult | null): boolean { + return Boolean(probe?.ok) && !probe?.buzzCliPath; +} + +/** + * Submit gate. + * + * Deliberately does NOT require a successful probe. A machine that is asleep, + * off the VPN, or mid-reboot is still an agent host the user wants recorded — + * blocking on reachability would make the feature unusable exactly when the + * user is setting things up. What is required is a host, a well-formed pubkey, + * and a name; the backend re-validates all three and additionally rejects a + * host that is not in `~/.ssh/config`. + */ +export function canSubmitConnectAgent(draft: ConnectAgentDraft): boolean { + if (draft.isProbing) return false; + if (!draft.host.trim()) return false; + if (verifyPubkeyInput(draft.pubkey).kind !== "ok") return false; + const name = draft.name.trim(); + if (!name || name.length > MAX_CONNECTED_NAME_LENGTH) return false; + return true; +} + +/** The payload `connectRemoteAgent` expects, or `null` when not submittable. */ +export function connectAgentPayload(draft: ConnectAgentDraft) { + if (!canSubmitConnectAgent(draft)) return null; + const harness = draft.harness.trim(); + return { + host: draft.host.trim(), + pubkey: draft.pubkey.trim(), + name: draft.name.trim(), + harness: harness ? harness : null, + }; +} + +/** + * Compact label for a failed probe, for the Connected Agents list. + * + * Every classified kind gets its own wording because they call for different + * actions, and "machine unreachable" is actively wrong for all but one of them: + * the host answered in every case except `unreachable`. Labelling an untrusted + * host key as unreachable would send someone to check the network when the fix + * is to review a fingerprint — and since Buzz probes with strict host-key + * checking and never writes `known_hosts`, this label is the only prompt the + * user gets. + */ +export function reachabilityLabel(probe: HostProbeResult): string { + switch (probe.errorKind) { + case "password_required": + return "needs an ssh key"; + case "host_key_problem": + return "host key not trusted"; + case "truncated": + return "probe incomplete · retry"; + case "timed_out": + return "probe timed out"; + case "unreachable": + return "machine unreachable"; + default: + // Unclassified: the backend could not attribute the failure, so naming a + // specific cause here would be a guess. + return "probe failed"; + } +} diff --git a/desktop/src/features/agents/ui/connectedAgentChannelIntent.test.mjs b/desktop/src/features/agents/ui/connectedAgentChannelIntent.test.mjs new file mode 100644 index 0000000000..e04a54a615 --- /dev/null +++ b/desktop/src/features/agents/ui/connectedAgentChannelIntent.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { connectedAgentMembershipAdded } from "./connectedAgentChannelIntent.ts"; + +const AGENT = + "4687f50de3a9e235e28eb58d68b0746062d7be6401bbf78a766bbd6f96ffe3c9"; + +test("reports the connected agent's successful membership write", () => { + assert.equal( + connectedAgentMembershipAdded(AGENT, { + added: [AGENT.toUpperCase()], + errors: [], + }), + true, + ); +}); + +test("does not treat another batch entry as this agent's success", () => { + assert.equal( + connectedAgentMembershipAdded(AGENT, { + added: ["f".repeat(64)], + errors: [], + }), + false, + ); +}); + +test("surfaces the matching relay membership error", () => { + assert.throws( + () => + connectedAgentMembershipAdded(AGENT, { + added: [], + errors: [{ pubkey: AGENT, error: "channel is archived" }], + }), + /channel is archived/, + ); +}); + +test("ignores an error for a different batch entry", () => { + assert.equal( + connectedAgentMembershipAdded(AGENT, { + added: [AGENT], + errors: [{ pubkey: "f".repeat(64), error: "not this agent" }], + }), + true, + ); +}); diff --git a/desktop/src/features/agents/ui/connectedAgentChannelIntent.ts b/desktop/src/features/agents/ui/connectedAgentChannelIntent.ts new file mode 100644 index 0000000000..9a4417d720 --- /dev/null +++ b/desktop/src/features/agents/ui/connectedAgentChannelIntent.ts @@ -0,0 +1,35 @@ +export type ConnectedAgentMembershipResult = { + added: string[]; + errors: Array<{ + pubkey: string; + error: string; + }>; +}; + +function normalizePubkey(pubkey: string): string { + return pubkey.trim().toLowerCase(); +} + +/** + * Interpret the relay's batch membership result for one connected agent. + * + * `addChannelMembers` is batch-shaped even when this UI writes one pubkey. Keep + * the exact matching and error precedence in a pure seam so the connected path + * cannot mistake another batch entry for this agent's outcome. + */ +export function connectedAgentMembershipAdded( + agentPubkey: string, + result: ConnectedAgentMembershipResult, +): boolean { + const normalizedAgent = normalizePubkey(agentPubkey); + const membershipError = result.errors.find( + (error) => normalizePubkey(error.pubkey) === normalizedAgent, + ); + if (membershipError) { + throw new Error(membershipError.error); + } + + return result.added.some( + (pubkey) => normalizePubkey(pubkey) === normalizedAgent, + ); +} diff --git a/desktop/src/features/agents/ui/connectedAgentScope.test.mjs b/desktop/src/features/agents/ui/connectedAgentScope.test.mjs new file mode 100644 index 0000000000..686b61dea3 --- /dev/null +++ b/desktop/src/features/agents/ui/connectedAgentScope.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + connectedAgentsForCommunity, + normalizeCommunityUrl, +} from "./connectedAgentScope.ts"; + +const PRIMARY = "wss://community.example"; +const SECONDARY = "wss://other.example"; + +function agent(overrides = {}) { + return { + pubkey: "a".repeat(64), + name: "Scout", + host: "workstation", + harness: "claude", + community: PRIMARY, + createdAt: "2026-07-29T00:00:00Z", + updatedAt: "2026-07-29T00:00:00Z", + ...overrides, + }; +} + +test("an agent appears only in its recorded community", () => { + assert.equal(connectedAgentsForCommunity([agent()], PRIMARY).length, 1); + assert.deepEqual(connectedAgentsForCommunity([agent()], SECONDARY), []); +}); + +test("legacy records remain visible until reconnected", () => { + const legacy = agent({ community: undefined }); + assert.equal(connectedAgentsForCommunity([legacy], PRIMARY).length, 1); + assert.equal(connectedAgentsForCommunity([legacy], SECONDARY).length, 1); +}); + +test("comparison ignores trailing slashes and case", () => { + assert.equal( + connectedAgentsForCommunity( + [agent({ community: "WSS://COMMUNITY.EXAMPLE/" })], + PRIMARY, + ).length, + 1, + ); + assert.equal( + normalizeCommunityUrl(" wss://Relay.Example.com// "), + "wss://relay.example.com", + ); +}); + +test("an unknown active community does not hide records", () => { + assert.equal(connectedAgentsForCommunity([agent()], null).length, 1); + assert.equal(connectedAgentsForCommunity([agent()], "").length, 1); +}); + +test("mixed communities are separated while legacy records stay visible", () => { + const agents = [ + agent({ pubkey: "a".repeat(64), community: PRIMARY }), + agent({ pubkey: "b".repeat(64), community: SECONDARY }), + agent({ pubkey: "c".repeat(64), community: undefined }), + ]; + assert.deepEqual( + connectedAgentsForCommunity(agents, PRIMARY).map((item) => item.pubkey), + ["a".repeat(64), "c".repeat(64)], + ); +}); diff --git a/desktop/src/features/agents/ui/connectedAgentScope.ts b/desktop/src/features/agents/ui/connectedAgentScope.ts new file mode 100644 index 0000000000..37aaf1dd82 --- /dev/null +++ b/desktop/src/features/agents/ui/connectedAgentScope.ts @@ -0,0 +1,19 @@ +import type { ConnectedAgent } from "@/shared/api/remoteAgentTypes"; + +/** Normalize a relay URL so equivalent community spellings compare equally. */ +export function normalizeCommunityUrl(url: string): string { + return url.trim().replace(/\/+$/, "").toLowerCase(); +} + +/** Return the connected agents relevant to the currently active community. */ +export function connectedAgentsForCommunity( + agents: ConnectedAgent[], + activeRelayUrl: string | null | undefined, +): ConnectedAgent[] { + if (!activeRelayUrl) return agents; + const active = normalizeCommunityUrl(activeRelayUrl); + return agents.filter( + (agent) => + !agent.community || normalizeCommunityUrl(agent.community) === active, + ); +} diff --git a/desktop/src/features/agents/ui/useConnectedAgents.ts b/desktop/src/features/agents/ui/useConnectedAgents.ts new file mode 100644 index 0000000000..6c56f0269b --- /dev/null +++ b/desktop/src/features/agents/ui/useConnectedAgents.ts @@ -0,0 +1,177 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + disconnectRemoteAgent, + listConnectedAgents, +} from "@/shared/api/remoteAgentApi"; +import type { ConnectedAgent } from "@/shared/api/remoteAgentTypes"; +import { addChannelMembers } from "@/shared/api/tauri"; +import type { Channel, ChannelRole } from "@/shared/api/types"; +import { channelsQueryKey } from "@/features/channels/hooks"; +import { relayAgentsQueryKey } from "@/features/agents/hooks"; +import { + loadActiveCommunityId, + loadCommunities, +} from "@/features/communities/communityStorage"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { connectedAgentMembershipAdded } from "./connectedAgentChannelIntent"; +import { connectedAgentsForCommunity } from "./connectedAgentScope"; + +export const connectedAgentsQueryKey = ["connected-agents"] as const; + +export type AttachConnectedAgentToChannelInput = { + agent: ConnectedAgent; + channelId: string; + role?: Exclude; +}; + +export type AttachConnectedAgentToChannelResult = { + agent: ConnectedAgent; + membershipAdded: boolean; +}; + +/** + * Add a self-hosted agent to a relay channel without crossing the custody + * boundary. This writes owner-signed membership only: it never starts, + * deploys, restarts, or otherwise acts on the remote process. + */ +export function useAttachConnectedAgentToChannelMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + agent, + channelId, + role = "bot", + }: AttachConnectedAgentToChannelInput): Promise => { + const normalizedPubkey = normalizePubkey(agent.pubkey); + const result = await addChannelMembers({ + channelId, + pubkeys: [normalizedPubkey], + role, + }); + + return { + agent, + membershipAdded: connectedAgentMembershipAdded( + normalizedPubkey, + result, + ), + }; + }, + onSettled: async (_data, _error, variables) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: channelsQueryKey }), + queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }), + ...(variables + ? [ + queryClient.invalidateQueries({ + queryKey: ["channels", variables.channelId, "members"], + }), + ] + : []), + ]); + }, + }); +} + +/** + * Connected self-hosted agents. + * + * No `refetchInterval`. The managed-agents query polls because a local process + * can die with no relay event to signal it; a connected agent's record is a + * local pointer that changes only when the user connects or disconnects one, so + * polling it would be pure noise. Liveness of the agent itself comes from relay + * presence, which the agent publishes and Buzz already subscribes to. + */ +export function useConnectedAgentsQuery() { + return useQuery({ + queryKey: connectedAgentsQueryKey, + queryFn: listConnectedAgents, + staleTime: 30_000, + }); +} + +function activeCommunityRelayUrl(): string | null { + const activeId = loadActiveCommunityId(); + if (!activeId) return null; + return ( + loadCommunities().find((community) => community.id === activeId) + ?.relayUrl ?? null + ); +} + +/** + * State and actions for the Connected-agents section. + * + * There is deliberately no start/stop/restart action here to match: the + * surface offers only what Buzz can actually do to an agent it does not own. + */ +export function useConnectedAgents() { + const queryClient = useQueryClient(); + const query = useConnectedAgentsQuery(); + // The community-scoped app subtree remounts when the active community changes. + const activeRelayUrl = React.useMemo(activeCommunityRelayUrl, []); + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const [agentToAddToChannel, setAgentToAddToChannel] = + React.useState(null); + const [noticeMessage, setNoticeMessage] = React.useState(null); + + const disconnectMutation = useMutation({ + mutationFn: (pubkey: string) => disconnectRemoteAgent(pubkey), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: connectedAgentsQueryKey }); + }, + }); + + const handleDisconnect = React.useCallback( + async (agent: ConnectedAgent) => { + await disconnectMutation.mutateAsync(agent.pubkey); + // Say what did NOT happen. A user who just clicked "Disconnect" has good + // reason to wonder whether they killed their agent; they did not, and + // silence would leave them guessing. + setNoticeMessage( + `${agent.name} is no longer listed here. It is still running on ${agent.host} — Buzz never controlled it.`, + ); + }, + [disconnectMutation], + ); + + const handleConnected = React.useCallback( + (agent: ConnectedAgent) => { + void queryClient.invalidateQueries({ queryKey: connectedAgentsQueryKey }); + setNoticeMessage(`Connected ${agent.name} on ${agent.host}.`); + }, + [queryClient], + ); + + const handleAddedToChannel = React.useCallback( + (channel: Channel, result: AttachConnectedAgentToChannelResult) => { + setAgentToAddToChannel(null); + setNoticeMessage( + result.membershipAdded + ? `Added ${result.agent.name} to ${channel.name} as a bot. The agent remains self-supervised on ${result.agent.host}.` + : `${result.agent.name} is already available in ${channel.name}.`, + ); + }, + [], + ); + + return { + agents: connectedAgentsForCommunity(query.data ?? [], activeRelayUrl), + agentToAddToChannel, + error: query.error instanceof Error ? query.error : null, + isLoading: query.isLoading, + isPending: disconnectMutation.isPending, + isDialogOpen, + noticeMessage, + openConnectDialog: () => setIsDialogOpen(true), + setAgentToAddToChannel, + setIsDialogOpen, + setNoticeMessage, + handleAddedToChannel, + handleConnected, + handleDisconnect, + }; +} diff --git a/desktop/src/shared/api/remoteAgentApi.ts b/desktop/src/shared/api/remoteAgentApi.ts new file mode 100644 index 0000000000..9e26e7ad17 --- /dev/null +++ b/desktop/src/shared/api/remoteAgentApi.ts @@ -0,0 +1,72 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { + ConnectedAgent, + HostProbeResult, + SshHost, +} from "@/shared/api/remoteAgentTypes"; + +/** + * Enumerate the user's `~/.ssh/config` host aliases. No connection is attempted; + * an absent config yields an empty list. + */ +export async function listSshHosts(): Promise { + return await invokeTauri("list_ssh_hosts"); +} + +/** + * Probe one configured host for agent harnesses and the `buzz` CLI. + * + * `host` must be an alias present in `~/.ssh/config`. A host-side failure + * (unreachable, password-only, unknown key) resolves with `ok: false` and a + * classified `errorKind`; only a failure to run `ssh` at all rejects. + */ +export async function probeAgentHost(host: string): Promise { + return await invokeTauri("probe_agent_host", { host }); +} + +/** + * Probe the machine Buzz is running on, using the identical probe script so the + * result is shape-compatible with `probeAgentHost`. + */ +export async function probeLocalAgentHost(): Promise { + return await invokeTauri("probe_local_agent_host"); +} + +/** The self-hosted agents this machine is connected to. */ +export async function listConnectedAgents(): Promise { + return await invokeTauri("list_connected_agents"); +} + +/** + * Record a self-hosted agent that already runs on `host`. + * + * `pubkey` accepts an npub or 64 hex characters and is normalized to hex by the + * backend. An nsec is refused with a specific message — a self-hosted agent's + * secret must never leave its own machine, and this call never transports one. + * `host` must be an alias present in `~/.ssh/config`, because it is also the + * reachability probe target. + */ +export async function connectRemoteAgent(input: { + host: string; + pubkey: string; + name: string; + harness?: string | null; +}): Promise { + return await invokeTauri("connect_remote_agent", { + host: input.host, + pubkey: input.pubkey, + name: input.name, + harness: input.harness ?? null, + }); +} + +/** + * Forget a connected agent. + * + * Local-only: this removes Buzz's pointer and nothing else. The remote process + * keeps running, and no tombstone or archive event is published — Buzz never + * claimed to own this agent, so it has nothing to revoke. + */ +export async function disconnectRemoteAgent(pubkey: string): Promise { + await invokeTauri("disconnect_remote_agent", { pubkey }); +} diff --git a/desktop/src/shared/api/remoteAgentTypes.ts b/desktop/src/shared/api/remoteAgentTypes.ts new file mode 100644 index 0000000000..31089882fb --- /dev/null +++ b/desktop/src/shared/api/remoteAgentTypes.ts @@ -0,0 +1,123 @@ +/** + * Types for the remote-agent surface: enumerating the user's own SSH hosts and + * probing them for agent harnesses. + * + * A separate module rather than more lines in `types.ts`, which is already over + * the desktop 1000-line limit and carries a documented "queued to be split" + * override. Import these from here directly — `types.ts` deliberately does not + * re-export them, because a re-export block would put it back over the limit + * and defeat the point of the split. + */ + +/** One `Host` stanza from the user's `~/.ssh/config`. */ +export type SshHost = { + /** The `Host` alias as written — this is what gets passed to `ssh`. */ + host: string; + hostname?: string | null; + user?: string | null; + port?: string | null; + identityFile?: string | null; +}; + +/** + * Why a host probe failed, when the cause is actionable. + * + * `password_required` means the host offered only interactive auth. Buzz never + * collects or stores an SSH password, so this is a status to render with a + * remedy, not a prompt to raise. + * + * `host_key_problem` covers both an untrusted first-seen key and a changed one. + * Buzz probes with strict host-key checking and never writes to `known_hosts`, + * so granting trust is always something the user does outside the app. + * + * `truncated` means the probe started but its output stopped early, so the facts + * are an unknown fraction of the real ones and are withheld rather than shown as + * a complete answer. + */ +export type HostProbeErrorKind = + | "password_required" + | "host_key_problem" + | "unreachable" + | "timed_out" + | "truncated"; + +/** + * One agent harness found on a probed host. + * + * Deliberately narrower than `AcpRuntime`: that type carries install and auth + * affordances that only apply to the local machine. Buzz does not install + * software on, or authenticate CLIs on, another host. + */ +export type RemoteHarness = { + id: string; + label: string; + source: "builtin" | "preset" | "custom"; + acpCommand?: string | null; + acpCommandPath?: string | null; + version?: string | null; + underlyingCliPath?: string | null; + /** + * True when the harness is usable on this host: its ACP command resolved and, + * if it is an adapter, the vendor CLI it wraps resolved too. An adapter + * without its CLI starts and then fails at first use. + */ + ready: boolean; + installHint: string; + installInstructionsUrl: string; +}; + +/** + * Result of probing one host for agent harnesses. + * + * A host-side problem comes back with `ok: false` and a classified + * `errorKind` rather than as a thrown error — the UI shows one row per host and + * needs a renderable status. + */ +export type HostProbeResult = { + /** The ssh alias probed, or `__localhost__` for this machine. */ + host: string; + ok: boolean; + durationMs: number; + error?: string | null; + errorKind?: HostProbeErrorKind | null; + user?: string | null; + hostname?: string | null; + os?: string | null; + /** Path of the `buzz` CLI on the host; a connected agent needs it. */ + buzzCliPath?: string | null; + buzzCliVersion?: string | null; + harnesses: RemoteHarness[]; +}; + +/** Host id the backend uses for the local machine. */ +export const LOCALHOST_HOST_ID = "__localhost__"; + +/** + * A self-hosted agent Buzz talks to but does not own: it runs on a machine the + * user owns, supervises itself, and holds its own signing key. + * + * Deliberately **not** a `ManagedAgent`. That type carries `status`, `pid`, + * `logPath`, `needsRestart`, and `startOnAppLaunch` — each one a claim about a + * process Buzz supervises. A connected agent has none of those, and the narrow + * shape is what makes "no start/stop button" a property of the type rather + * than a rule a component has to remember. Connected agents are not part of + * `listManagedAgents()` at all: they are a separate record type in a separate + * store, so they cannot reach a surface that renders lifecycle controls. + */ +export type ConnectedAgent = { + /** The agent's own pubkey, lowercase hex. Buzz holds only the public half. */ + pubkey: string; + /** Buzz-local label. The agent's own kind:10100 profile is what the relay sees. */ + name: string; + /** `~/.ssh/config` alias of the machine the agent and its key live on. */ + host: string; + /** + * Harness id observed on the host at connect time (e.g. `"claude"`). A + * record of what was there — nothing in Buzz executes it. + */ + harness: string | null; + /** Community where this connection was created, or `null` for legacy records. */ + community: string | null; + createdAt: string; + updatedAt: string; +};