From 86e2121abb2ebec2c4a96f37f5636619db5e5c89 Mon Sep 17 00:00:00 2001
From: dspury
Date: Fri, 31 Jul 2026 14:17:06 -0500
Subject: [PATCH 1/6] feat(desktop): discover remote agent harnesses
Signed-off-by: dspury
---
desktop/src-tauri/src/commands/mod.rs | 2 +
.../src/commands/remote_agent_discovery.rs | 57 ++
desktop/src-tauri/src/lib.rs | 3 +
.../src-tauri/src/managed_agents/discovery.rs | 151 +---
.../discovery/known_runtimes.rs | 149 ++++
.../src/managed_agents/discovery/presets.rs | 12 +-
.../managed_agents/discovery/probe_targets.rs | 77 +++
desktop/src-tauri/src/managed_agents/mod.rs | 3 +
.../src/managed_agents/remote_probe.rs | 649 ++++++++++++++++++
.../src/managed_agents/remote_probe_tests.rs | 578 ++++++++++++++++
.../src/managed_agents/ssh_config.rs | 334 +++++++++
desktop/src/shared/api/remoteAgentApi.ts | 29 +
desktop/src/shared/api/remoteAgentTypes.ts | 93 +++
13 files changed, 1992 insertions(+), 145 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/remote_agent_discovery.rs
create mode 100644 desktop/src-tauri/src/managed_agents/discovery/known_runtimes.rs
create mode 100644 desktop/src-tauri/src/managed_agents/discovery/probe_targets.rs
create mode 100644 desktop/src-tauri/src/managed_agents/remote_probe.rs
create mode 100644 desktop/src-tauri/src/managed_agents/remote_probe_tests.rs
create mode 100644 desktop/src-tauri/src/managed_agents/ssh_config.rs
create mode 100644 desktop/src/shared/api/remoteAgentApi.ts
create mode 100644 desktop/src/shared/api/remoteAgentTypes.ts
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 66ef7ef17b..2fc35ccf36 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -52,6 +52,7 @@ mod project_terminal;
mod qr_download;
mod relay_members;
mod relay_reconnect;
+mod remote_agent_discovery;
mod social;
mod team_snapshot;
mod teams;
@@ -103,6 +104,7 @@ pub use project_terminal::*;
pub use qr_download::*;
pub use relay_members::*;
pub use relay_reconnect::*;
+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_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/lib.rs b/desktop/src-tauri/src/lib.rs
index 6814008f0d..b62381d7dd 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -800,6 +800,9 @@ pub fn run() {
get_relay_self,
resolve_oa_owner,
list_relay_agents,
+ list_ssh_hosts,
+ probe_agent_host,
+ probe_local_agent_host,
list_managed_agents,
list_managed_agent_runtimes,
start_managed_agent_runtime,
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..adf826aa52 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -23,7 +23,9 @@ 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;
mod runtime;
@@ -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;
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/shared/api/remoteAgentApi.ts b/desktop/src/shared/api/remoteAgentApi.ts
new file mode 100644
index 0000000000..ed54a720cb
--- /dev/null
+++ b/desktop/src/shared/api/remoteAgentApi.ts
@@ -0,0 +1,29 @@
+import { invokeTauri } from "@/shared/api/tauri";
+import type { 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");
+}
diff --git a/desktop/src/shared/api/remoteAgentTypes.ts b/desktop/src/shared/api/remoteAgentTypes.ts
new file mode 100644
index 0000000000..78d9ce786a
--- /dev/null
+++ b/desktop/src/shared/api/remoteAgentTypes.ts
@@ -0,0 +1,93 @@
+/**
+ * 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__";
From 5f50f9e6862633f22f24599667705295cb757aff Mon Sep 17 00:00:00 2001
From: dspury
Date: Tue, 28 Jul 2026 13:16:54 -0500
Subject: [PATCH 2/6] feat(desktop): connect self-hosted agents Buzz does not
own
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Buzz has only ever had one answer to "where does this agent's key live" —
Buzz minted it and it is in this machine's keyring — so nothing in the code
asked. A self-hosted agent breaks that: a real agent with a real pubkey whose
secret was minted on, and never leaves, a machine the user owns. Every
lifecycle affordance Buzz offers (start, stop, deploy, tombstone, profile
republish) is predicated on holding that key.
`ConnectedAgentRecord` in `connected-agents.json`, beside `managed-agents.json`
and read by nothing else. The type carries a pubkey, a Buzz-local name, an
`~/.ssh/config` host, and an observed harness label — no key, no command, no
timeout, no auto-start flag, no pid.
Never-spawned is structural. It is not that the lifecycle paths skip these
records; it is that they cannot receive one. `load_managed_agents` — the reader
behind spawn, deploy, auto-start restore, owner-signed kind:30177 reconcile,
profile republish, and delete-with-tombstone — returns `ManagedAgentRecord`,
and a connected agent is a different type in a different file. There is no
filter to maintain and no field for a future reader to misinterpret.
An earlier cut of this got there differently: `KeyCustody { Local, Remote }` on
`ManagedAgentRecord`, plus a custody filter inside `load_managed_agents`. It
worked and it was tested, but it had three costs. It made "Buzz must not act on
this agent" a value every reader had to interpret correctly rather than a shape
they could not misuse. It required `save_managed_agents` to re-read the
connected third, because otherwise each of the dozens of existing
`load → mutate → save` sites would silently erase every connected agent — a
sharp edge that needed its own regression test. And it obliged all ~20
`ManagedAgentRecord { .. }` literals in the tree to name a custody field they
have no opinion on, which put eight already-oversized upstream files over the
desktop size ratchet.
Separating the type retires all three. `storage.rs`, `storage_tests.rs`,
`types.rs`, and `reconcile.rs` are byte-identical to upstream; the churn in the
other 23 files is gone; the erase-on-unrelated-save failure mode does not exist,
because the two stores share no payload to drop a half of. Nothing splits an
upstream file and nothing touches the ratchet.
What a type cannot state on its own is covered by cross-store tests: a
connected row does not satisfy `ManagedAgentRecord` and an owned row does not
satisfy `ConnectedAgentRecord` (which is why `host` is a plain `String` — as an
`Option` an owned row read from the wrong file would deserialize into a
connected agent that can never be probed); a connected save leaves
`managed-agents.json` byte-identical; the connected store holds nothing
key-shaped, which is what makes the ordinary non-`0o600` write correct.
Uniqueness is the one property that does not partition, so `connect` checks
both stores: one identity with a row in each would be two answers to "who is
this pubkey", and a duplicate name is ambiguous at every mention site. It also
covers the key-less definition half, whose names are just as mentionable.
Two publish paths stay cut. Connecting emits no kind:30177 — that event is the
owner asserting "I manage this agent" and is what `delete_managed_agent`
tombstones, so publishing it would let Buzz claim, 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. And `disconnect_remote_agent` is
not `delete_managed_agent`: it drops Buzz's local pointer with no process stop,
no keyring delete, and no tombstone or NIP-IA archive, because those would
remove a working agent from every member picker on the relay.
Desktop surface: a Connected Agents section listing host, harness, and
reachability, with a connect dialog driven by the parsed ssh config. It renders
no start/stop control — `ConnectedAgentSummary` has no `status`, `pid`, or
`needsRestart` to render one from, which is a property of the type rather than a
rule someone has to remember.
1910 backend tests and 3782 desktop JS tests pass; fmt, clippy, tsc, biome, and
all three `pnpm check` guards are clean against base `upstream/main`.
Signed-off-by: dspury
(cherry picked from commit 098711146b6ec4c7f6aacc70d3fd99eb9dbe361f)
Signed-off-by: dspury
(cherry picked from commit 4a202849b91b72b2c7a6ff7f76dabccdfd2136c0)
(cherry picked from commit f43c3790814f487cecf800f9e85028eafca4e5e9)
Signed-off-by: dspury
---
desktop/src-tauri/src/commands/mod.rs | 2 +
.../src/commands/remote_agent_connect.rs | 298 ++++++++++++++
.../commands/remote_agent_connect_tests.rs | 211 ++++++++++
desktop/src-tauri/src/lib.rs | 3 +
.../src/managed_agents/connected_agents.rs | 198 +++++++++
.../managed_agents/connected_agents_tests.rs | 239 +++++++++++
desktop/src-tauri/src/managed_agents/mod.rs | 5 +-
desktop/src/features/agents/ui/AgentsView.tsx | 22 +
.../features/agents/ui/ConnectAgentDialog.tsx | 378 ++++++++++++++++++
.../agents/ui/ConnectedAgentsSection.tsx | 222 ++++++++++
.../agents/ui/connectAgentIntent.test.mjs | 214 ++++++++++
.../features/agents/ui/connectAgentIntent.ts | 170 ++++++++
.../features/agents/ui/useConnectedAgents.ts | 82 ++++
desktop/src/shared/api/remoteAgentApi.ts | 45 ++-
desktop/src/shared/api/remoteAgentTypes.ts | 28 ++
15 files changed, 2115 insertions(+), 2 deletions(-)
create mode 100644 desktop/src-tauri/src/commands/remote_agent_connect.rs
create mode 100644 desktop/src-tauri/src/commands/remote_agent_connect_tests.rs
create mode 100644 desktop/src-tauri/src/managed_agents/connected_agents.rs
create mode 100644 desktop/src-tauri/src/managed_agents/connected_agents_tests.rs
create mode 100644 desktop/src/features/agents/ui/ConnectAgentDialog.tsx
create mode 100644 desktop/src/features/agents/ui/ConnectedAgentsSection.tsx
create mode 100644 desktop/src/features/agents/ui/connectAgentIntent.test.mjs
create mode 100644 desktop/src/features/agents/ui/connectAgentIntent.ts
create mode 100644 desktop/src/features/agents/ui/useConnectedAgents.ts
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 2fc35ccf36..9d2d6e90b2 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -52,6 +52,7 @@ 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;
@@ -104,6 +105,7 @@ 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::*;
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..6e2a5eeb81
--- /dev/null
+++ b/desktop/src-tauri/src/commands/remote_agent_connect.rs
@@ -0,0 +1,298 @@
+//! 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();
+ let record = connected_record(&host, &pubkey, &name, harness, &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,
+ now: &str,
+) -> ConnectedAgentRecord {
+ ConnectedAgentRecord {
+ pubkey: pubkey.to_string(),
+ name: name.to_string(),
+ host: host.to_string(),
+ harness,
+ 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..2c7b2f3c37
--- /dev/null
+++ b/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs
@@ -0,0 +1,211 @@
+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()),
+ "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,
+ [
+ "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,
+ "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/lib.rs b/desktop/src-tauri/src/lib.rs
index b62381d7dd..9a00ae3f24 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -803,6 +803,9 @@ pub fn run() {
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..aa039dcff4
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/connected_agents.rs
@@ -0,0 +1,198 @@
+//! 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 also no `relay_url`. Every agent relay lookup resolves the active
+/// workspace relay at read time (see
+/// [`crate::relay::effective_agent_relay_url`]), so a stored per-agent relay
+/// could only ever be a stale value the rest of the app ignores.
+#[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,
+ 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,
+ 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(),
+ 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))
+ });
+}
+
+#[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..67569f3e3a
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs
@@ -0,0 +1,239 @@
+//! 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, 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()),
+ 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.created_at, record.created_at);
+ assert_eq!(summary.updated_at, record.updated_at);
+}
diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index adf826aa52..0183b7b8fd 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;
@@ -25,7 +26,6 @@ pub(crate) mod reconcile;
mod relay_mesh;
pub mod remote_probe;
mod repos;
-
mod restore;
pub mod retention;
mod runtime;
@@ -50,6 +50,9 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> {
}
pub use backend::*;
+pub(crate) use connected_agents::{
+ load_connected_agents, save_connected_agents, ConnectedAgentRecord, ConnectedAgentSummary,
+};
pub use discovery::*;
pub use env_vars::*;
#[cfg(windows)]
diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx
index 3d1673c365..1d082f3ef6 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,18 @@ export function AgentsView() {
}}
/>
+ {
+ void connected.handleDisconnect(agent);
+ }}
+ />
+
+
+
{isCreateDialogOpen ? (
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.
+
+
+
+
+
+
+ Machine
+
+ {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,
+ host: event.target.value,
+ harness: "",
+ }))
+ }
+ value={draft.host}
+ >
+ {hosts.map((host) => (
+
+ {host.host}
+ {host.hostname ? ` — ${host.hostname}` : ""}
+
+ ))}
+
+ runProbe(draft.host)}
+ size="icon"
+ type="button"
+ variant="outline"
+ >
+ {draft.isProbing ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+
+
+ Agent identity
+
+
+ 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.
+
+ )}
+
+
+
+
+ Name
+
+
+ setDraft((current) => ({
+ ...current,
+ name: event.target.value,
+ }))
+ }
+ placeholder="Scout"
+ value={draft.name}
+ />
+ {nameProblem ? (
+
{nameProblem}
+ ) : null}
+
+
+ {readyHarnesses.length > 0 ? (
+
+
+ Harness
+
+
+ setDraft((current) => ({
+ ...current,
+ harness: event.target.value,
+ }))
+ }
+ value={draft.harness}
+ >
+ Not recorded
+ {readyHarnesses.map((harness) => (
+
+ {harness.label}
+ {harness.version ? ` ${harness.version}` : ""}
+
+ ))}
+
+
+ Recorded for reference. Buzz never runs it — the agent starts
+ itself.
+
+
+ ) : null}
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+ handleOpenChange(false)}
+ type="button"
+ variant="ghost"
+ >
+ Cancel
+
+ {
+ void handleSubmit();
+ }}
+ type="button"
+ >
+ {isSubmitting ? "Connecting…" : "Connect"}
+
+
+
+
+ );
+}
+
+/**
+ * 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..e4931ffc5e
--- /dev/null
+++ b/desktop/src/features/agents/ui/ConnectedAgentsSection.tsx
@@ -0,0 +1,222 @@
+import * as React from "react";
+import { Loader2, Plug, RefreshCw, Server, Unplug } 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 only actions offered are
+ * the two Buzz can actually perform: check whether the machine answers, and
+ * forget the agent locally.
+ */
+export function ConnectedAgentsSection({
+ agents,
+ error,
+ isLoading,
+ isPending,
+ noticeMessage,
+ onConnect,
+ onDisconnect,
+}: {
+ agents: ConnectedAgent[];
+ error: Error | null;
+ isLoading: boolean;
+ isPending: boolean;
+ noticeMessage: string | null;
+ 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) => (
+
checkHost(agent.host)}
+ onDisconnect={() => onDisconnect(agent)}
+ probe={probes[agent.host]}
+ />
+ ))}
+
+
+ );
+}
+
+function ConnectedAgentRow({
+ agent,
+ isPending,
+ onCheck,
+ onDisconnect,
+ probe,
+}: {
+ agent: ConnectedAgent;
+ isPending: boolean;
+ onCheck: () => void;
+ onDisconnect: () => void;
+ probe: HostProbeResult | "pending" | undefined;
+}) {
+ return (
+
+
+
+
+ {agent.name}
+
+ on {agent.host}
+
+ {agent.harness ? (
+
+ {agent.harness}
+
+ ) : null}
+
+
+
+
+
+ {probe === "pending" ? (
+
+ ) : (
+
+ )}
+ Check
+
+
+
+ Disconnect
+
+
+
+ );
+}
+
+/**
+ * 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/useConnectedAgents.ts b/desktop/src/features/agents/ui/useConnectedAgents.ts
new file mode 100644
index 0000000000..6b9e03f997
--- /dev/null
+++ b/desktop/src/features/agents/ui/useConnectedAgents.ts
@@ -0,0 +1,82 @@
+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";
+
+export const connectedAgentsQueryKey = ["connected-agents"] as const;
+
+/**
+ * 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,
+ });
+}
+
+/**
+ * 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();
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false);
+ 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],
+ );
+
+ return {
+ agents: query.data ?? [],
+ error: query.error instanceof Error ? query.error : null,
+ isLoading: query.isLoading,
+ isPending: disconnectMutation.isPending,
+ isDialogOpen,
+ noticeMessage,
+ openConnectDialog: () => setIsDialogOpen(true),
+ setIsDialogOpen,
+ setNoticeMessage,
+ handleConnected,
+ handleDisconnect,
+ };
+}
diff --git a/desktop/src/shared/api/remoteAgentApi.ts b/desktop/src/shared/api/remoteAgentApi.ts
index ed54a720cb..9e26e7ad17 100644
--- a/desktop/src/shared/api/remoteAgentApi.ts
+++ b/desktop/src/shared/api/remoteAgentApi.ts
@@ -1,5 +1,9 @@
import { invokeTauri } from "@/shared/api/tauri";
-import type { HostProbeResult, SshHost } from "@/shared/api/remoteAgentTypes";
+import type {
+ ConnectedAgent,
+ HostProbeResult,
+ SshHost,
+} from "@/shared/api/remoteAgentTypes";
/**
* Enumerate the user's `~/.ssh/config` host aliases. No connection is attempted;
@@ -27,3 +31,42 @@ export async function probeAgentHost(host: string): Promise {
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
index 78d9ce786a..f3b85c16e4 100644
--- a/desktop/src/shared/api/remoteAgentTypes.ts
+++ b/desktop/src/shared/api/remoteAgentTypes.ts
@@ -91,3 +91,31 @@ export type HostProbeResult = {
/** 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;
+ createdAt: string;
+ updatedAt: string;
+};
From 2aba36f820178fef6253f03f985b1401c81c28de Mon Sep 17 00:00:00 2001
From: dspury
Date: Sat, 1 Aug 2026 12:18:14 -0500
Subject: [PATCH 3/6] refactor(desktop): split initial window helpers
Signed-off-by: dspury
---
desktop/src-tauri/src/initial_window.rs | 70 ++++++++++++++++++++++++
desktop/src-tauri/src/lib.rs | 71 +++----------------------
2 files changed, 77 insertions(+), 64 deletions(-)
create mode 100644 desktop/src-tauri/src/initial_window.rs
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 9a00ae3f24..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
From 0ddc45daaf1753b964d4ddcd9ab98db7bd05eccc Mon Sep 17 00:00:00 2001
From: dspury
Date: Wed, 29 Jul 2026 15:22:20 -0500
Subject: [PATCH 4/6] feat(desktop): add connected agents to channels
Signed-off-by: dspury
(cherry picked from commit aa4d335f322736ec59b64fd5c2bf0a73b5fc24bf)
(cherry picked from commit a3cfcad901e5cb5bb0ad809303b1fece05479789)
Signed-off-by: dspury
---
.../agents/ui/AddAgentToChannelDialog.tsx | 93 ++++++++++++-------
desktop/src/features/agents/ui/AgentsView.tsx | 14 +++
.../agents/ui/ConnectedAgentsSection.tsx | 31 ++++++-
.../ui/connectedAgentChannelIntent.test.mjs | 48 ++++++++++
.../agents/ui/connectedAgentChannelIntent.ts | 35 +++++++
.../features/agents/ui/useConnectedAgents.ts | 79 ++++++++++++++++
6 files changed, 265 insertions(+), 35 deletions(-)
create mode 100644 desktop/src/features/agents/ui/connectedAgentChannelIntent.test.mjs
create mode 100644 desktop/src/features/agents/ui/connectedAgentChannelIntent.ts
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({
setChannelId(event.target.value)}
value={channelId}
@@ -166,7 +197,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 1d082f3ef6..29b18797bb 100644
--- a/desktop/src/features/agents/ui/AgentsView.tsx
+++ b/desktop/src/features/agents/ui/AgentsView.tsx
@@ -281,6 +281,7 @@ export function AgentsView() {
isLoading={connected.isLoading}
isPending={connected.isPending}
noticeMessage={connected.noticeMessage}
+ onAddToChannel={connected.setAgentToAddToChannel}
onConnect={connected.openConnectDialog}
onDisconnect={(agent) => {
void connected.handleDisconnect(agent);
@@ -356,6 +357,19 @@ export function AgentsView() {
open={agents.agentToAddToChannel !== null}
/>
) : null}
+ {connected.agentToAddToChannel ? (
+ {
+ if (!open) {
+ connected.setAgentToAddToChannel(null);
+ }
+ }}
+ open={connected.agentToAddToChannel !== null}
+ />
+ ) : null}
{agents.createdAgent ? (
void;
onConnect: () => void;
onDisconnect: (agent: ConnectedAgent) => void;
}) {
@@ -116,6 +125,7 @@ export function ConnectedAgentsSection({
agent={agent}
isPending={isPending}
key={agent.pubkey}
+ onAddToChannel={() => onAddToChannel(agent)}
onCheck={() => checkHost(agent.host)}
onDisconnect={() => onDisconnect(agent)}
probe={probes[agent.host]}
@@ -129,12 +139,14 @@ export function ConnectedAgentsSection({
function ConnectedAgentRow({
agent,
isPending,
+ onAddToChannel,
onCheck,
onDisconnect,
probe,
}: {
agent: ConnectedAgent;
isPending: boolean;
+ onAddToChannel: () => void;
onCheck: () => void;
onDisconnect: () => void;
probe: HostProbeResult | "pending" | undefined;
@@ -160,6 +172,17 @@ function ConnectedAgentRow({
+
+
+ Add to channel
+
{
+ 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/useConnectedAgents.ts b/desktop/src/features/agents/ui/useConnectedAgents.ts
index 6b9e03f997..1e77e8a019 100644
--- a/desktop/src/features/agents/ui/useConnectedAgents.ts
+++ b/desktop/src/features/agents/ui/useConnectedAgents.ts
@@ -6,9 +6,71 @@ import {
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 { normalizePubkey } from "@/shared/lib/pubkey";
+import { connectedAgentMembershipAdded } from "./connectedAgentChannelIntent";
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.
*
@@ -36,6 +98,8 @@ export function useConnectedAgents() {
const queryClient = useQueryClient();
const query = useConnectedAgentsQuery();
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
+ const [agentToAddToChannel, setAgentToAddToChannel] =
+ React.useState(null);
const [noticeMessage, setNoticeMessage] = React.useState(null);
const disconnectMutation = useMutation({
@@ -66,16 +130,31 @@ export function useConnectedAgents() {
[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: query.data ?? [],
+ 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,
};
From 73f46bccc76d85f1c3e79ea5ccd7ad3f01456ae5 Mon Sep 17 00:00:00 2001
From: dspury
Date: Sat, 1 Aug 2026 12:11:47 -0500
Subject: [PATCH 5/6] feat(desktop): scope connected agents by community
Signed-off-by: dspury
---
.../src/commands/remote_agent_connect.rs | 9 ++-
.../commands/remote_agent_connect_tests.rs | 3 +
.../src/managed_agents/connected_agents.rs | 22 +++++--
.../managed_agents/connected_agents_tests.rs | 13 +++-
desktop/src-tauri/src/managed_agents/mod.rs | 3 +-
.../agents/ui/connectedAgentScope.test.mjs | 65 +++++++++++++++++++
.../features/agents/ui/connectedAgentScope.ts | 19 ++++++
.../features/agents/ui/useConnectedAgents.ts | 18 ++++-
desktop/src/shared/api/remoteAgentTypes.ts | 2 +
9 files changed, 146 insertions(+), 8 deletions(-)
create mode 100644 desktop/src/features/agents/ui/connectedAgentScope.test.mjs
create mode 100644 desktop/src/features/agents/ui/connectedAgentScope.ts
diff --git a/desktop/src-tauri/src/commands/remote_agent_connect.rs b/desktop/src-tauri/src/commands/remote_agent_connect.rs
index 6e2a5eeb81..4798941427 100644
--- a/desktop/src-tauri/src/commands/remote_agent_connect.rs
+++ b/desktop/src-tauri/src/commands/remote_agent_connect.rs
@@ -225,7 +225,12 @@ pub async fn connect_remote_agent(
}
let now = now_iso();
- let record = connected_record(&host, &pubkey, &name, harness, &now);
+ // 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;
@@ -250,6 +255,7 @@ pub(crate) fn connected_record(
pubkey: &str,
name: &str,
harness: Option,
+ community: Option,
now: &str,
) -> ConnectedAgentRecord {
ConnectedAgentRecord {
@@ -257,6 +263,7 @@ pub(crate) fn connected_record(
name: name.to_string(),
host: host.to_string(),
harness,
+ community,
created_at: now.to_string(),
updated_at: now.to_string(),
}
diff --git a/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs b/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs
index 2c7b2f3c37..59c320e18a 100644
--- a/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs
+++ b/desktop/src-tauri/src/commands/remote_agent_connect_tests.rs
@@ -9,6 +9,7 @@ fn sample_record() -> ConnectedAgentRecord {
AGENT_HEX,
"Scout",
Some("claude".to_string()),
+ Some("wss://community.example".to_string()),
"2026-07-28T00:00:00Z",
)
}
@@ -107,6 +108,7 @@ fn a_connected_record_stores_the_identity_and_the_host_and_nothing_else() {
assert_eq!(
keys,
[
+ "community",
"created_at",
"harness",
"host",
@@ -130,6 +132,7 @@ fn a_probeless_connect_stores_no_harness_key_at_all() {
AGENT_HEX,
"Scout",
None,
+ None,
"2026-07-28T00:00:00Z",
);
let json = serde_json::to_value(&record).unwrap();
diff --git a/desktop/src-tauri/src/managed_agents/connected_agents.rs b/desktop/src-tauri/src/managed_agents/connected_agents.rs
index aa039dcff4..e8813e83b6 100644
--- a/desktop/src-tauri/src/managed_agents/connected_agents.rs
+++ b/desktop/src-tauri/src/managed_agents/connected_agents.rs
@@ -45,10 +45,10 @@ use super::storage::{atomic_write_json, backup_invalid_store, managed_agents_bas
/// 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 also no `relay_url`. Every agent relay lookup resolves the active
-/// workspace relay at read time (see
-/// [`crate::relay::effective_agent_relay_url`]), so a stored per-agent relay
-/// could only ever be a stale value the rest of the app ignores.
+/// 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
@@ -68,6 +68,11 @@ pub struct ConnectedAgentRecord {
/// 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,
}
@@ -94,6 +99,9 @@ pub struct ConnectedAgentSummary {
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,
}
@@ -108,6 +116,7 @@ impl From<&ConnectedAgentRecord> for ConnectedAgentSummary {
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(),
}
@@ -193,6 +202,11 @@ fn sort_for_stable_diffs(records: &mut [ConnectedAgentRecord]) {
});
}
+/// 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
index 67569f3e3a..a6973ae640 100644
--- a/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs
+++ b/desktop/src-tauri/src/managed_agents/connected_agents_tests.rs
@@ -15,7 +15,8 @@
use std::fs;
use super::{
- load_connected_agents_at, save_connected_agents_at, ConnectedAgentRecord, ConnectedAgentSummary,
+ load_connected_agents_at, normalize_community_url, save_connected_agents_at,
+ ConnectedAgentRecord, ConnectedAgentSummary,
};
use crate::managed_agents::ManagedAgentRecord;
@@ -28,6 +29,7 @@ fn connected(pubkey: &str, name: &str, host: &str) -> ConnectedAgentRecord {
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(),
}
@@ -234,6 +236,15 @@ fn the_summary_is_a_lossless_projection_of_the_record() {
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/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs
index 0183b7b8fd..2c3acb7840 100644
--- a/desktop/src-tauri/src/managed_agents/mod.rs
+++ b/desktop/src-tauri/src/managed_agents/mod.rs
@@ -51,7 +51,8 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> {
pub use backend::*;
pub(crate) use connected_agents::{
- load_connected_agents, save_connected_agents, ConnectedAgentRecord, ConnectedAgentSummary,
+ load_connected_agents, normalize_community_url, save_connected_agents, ConnectedAgentRecord,
+ ConnectedAgentSummary,
};
pub use discovery::*;
pub use env_vars::*;
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
index 1e77e8a019..6c56f0269b 100644
--- a/desktop/src/features/agents/ui/useConnectedAgents.ts
+++ b/desktop/src/features/agents/ui/useConnectedAgents.ts
@@ -10,8 +10,13 @@ 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;
@@ -88,6 +93,15 @@ export function useConnectedAgentsQuery() {
});
}
+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.
*
@@ -97,6 +111,8 @@ export function useConnectedAgentsQuery() {
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);
@@ -143,7 +159,7 @@ export function useConnectedAgents() {
);
return {
- agents: query.data ?? [],
+ agents: connectedAgentsForCommunity(query.data ?? [], activeRelayUrl),
agentToAddToChannel,
error: query.error instanceof Error ? query.error : null,
isLoading: query.isLoading,
diff --git a/desktop/src/shared/api/remoteAgentTypes.ts b/desktop/src/shared/api/remoteAgentTypes.ts
index f3b85c16e4..31089882fb 100644
--- a/desktop/src/shared/api/remoteAgentTypes.ts
+++ b/desktop/src/shared/api/remoteAgentTypes.ts
@@ -116,6 +116,8 @@ export type ConnectedAgent = {
* 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;
};
From 33a5142e4953d8f0924bd0a4f0537557cf904441 Mon Sep 17 00:00:00 2001
From: dspury
Date: Sat, 1 Aug 2026 12:28:41 -0500
Subject: [PATCH 6/6] feat(desktop): detect durable harness agent rosters
Signed-off-by: dspury
---
.../src/commands/remote_agent_discovery.rs | 38 +-
desktop/src-tauri/src/lib.rs | 2 +
.../src/managed_agents/remote_probe.rs | 5 +
.../src/managed_agents/remote_probe/roster.rs | 394 ++++++++++++++++++
.../remote_probe/roster_tests.rs | 299 +++++++++++++
desktop/src/shared/api/remoteAgentApi.ts | 22 +
desktop/src/shared/api/remoteAgentTypes.ts | 29 ++
7 files changed, 788 insertions(+), 1 deletion(-)
create mode 100644 desktop/src-tauri/src/managed_agents/remote_probe/roster.rs
create mode 100644 desktop/src-tauri/src/managed_agents/remote_probe/roster_tests.rs
diff --git a/desktop/src-tauri/src/commands/remote_agent_discovery.rs b/desktop/src-tauri/src/commands/remote_agent_discovery.rs
index 85e9f47a03..53a106f274 100644
--- a/desktop/src-tauri/src/commands/remote_agent_discovery.rs
+++ b/desktop/src-tauri/src/commands/remote_agent_discovery.rs
@@ -8,7 +8,10 @@
//! 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::remote_probe::{
+ probe_local_harness_agents, probe_localhost, probe_ssh_harness_agents, probe_ssh_host,
+ HarnessRosterResult, HostProbeResult,
+};
use crate::managed_agents::ssh_config::{parse_ssh_config, SshHost};
/// Enumerate the user's `~/.ssh/config` host aliases.
@@ -55,3 +58,36 @@ pub async fn probe_local_agent_host() -> Result {
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))
}
+
+/// List the durable, named agents one harness holds on a configured host.
+///
+/// Read-only: listing a roster starts nothing and changes no harness state. An
+/// unsupported harness returns `supported: false` so callers can offer manual
+/// identity entry instead of treating it as a host failure.
+#[tauri::command]
+pub async fn probe_harness_agents(
+ host: String,
+ harness: 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_harness_agents(&entry, &harness))
+ })
+ .await
+ .map_err(|e| format!("spawn_blocking failed: {e}"))?
+}
+
+/// List the durable agents of a harness on this machine.
+#[tauri::command]
+pub async fn probe_local_harness_agent_roster(
+ harness: String,
+) -> Result {
+ tokio::task::spawn_blocking(move || probe_local_harness_agents(&harness))
+ .await
+ .map_err(|e| format!("spawn_blocking failed: {e}"))
+}
diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs
index 3a4cf1ba09..f2f5b96fb9 100644
--- a/desktop/src-tauri/src/lib.rs
+++ b/desktop/src-tauri/src/lib.rs
@@ -746,6 +746,8 @@ pub fn run() {
list_ssh_hosts,
probe_agent_host,
probe_local_agent_host,
+ probe_harness_agents,
+ probe_local_harness_agent_roster,
list_connected_agents,
connect_remote_agent,
disconnect_remote_agent,
diff --git a/desktop/src-tauri/src/managed_agents/remote_probe.rs b/desktop/src-tauri/src/managed_agents/remote_probe.rs
index e2c582a72c..6f29f74393 100644
--- a/desktop/src-tauri/src/managed_agents/remote_probe.rs
+++ b/desktop/src-tauri/src/managed_agents/remote_probe.rs
@@ -45,6 +45,11 @@ use crate::managed_agents::discovery::{harness_probe_targets, HarnessProbeTarget
use crate::managed_agents::ssh_config::{resolve_ssh_binary, SshHost};
use crate::managed_agents::HarnessSource;
+/// Durable agent-roster enumeration for a harness already found by the probe.
+pub mod roster;
+
+pub use roster::{probe_local_harness_agents, probe_ssh_harness_agents, HarnessRosterResult};
+
/// Sentinel that brackets the probe's own output.
///
/// A login shell may print motd banners, shell-init chatter, or warnings before
diff --git a/desktop/src-tauri/src/managed_agents/remote_probe/roster.rs b/desktop/src-tauri/src/managed_agents/remote_probe/roster.rs
new file mode 100644
index 0000000000..16926025b7
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/remote_probe/roster.rs
@@ -0,0 +1,394 @@
+//! Durable agent-roster enumeration for a harness found on a probed host.
+//!
+//! Host discovery answers "which harnesses run here". This answers the next
+//! question: "which *named agents* does that harness hold, and which one is its
+//! primary". A resident harness commonly contains several durable agents, and
+//! connecting the host must expose them for selection without enrolling the
+//! whole stack — the user picks one, several, or none.
+//!
+//! Two boundaries keep this from becoming a harness-specific branch inside Buzz:
+//!
+//! - **Neutral output.** Callers see [`RemoteAgentCandidate`], which has no
+//! OpenClaw vocabulary in it. Nothing downstream — storage, UI, or the connect
+//! command — learns which harness produced a candidate beyond its id string.
+//! - **Table-driven input.** Everything harness-specific lives in
+//! [`ROSTER_RECIPES`]: one remote command and one parser per harness. Adding a
+//! harness is a row, not a code path, which is the same shape the host probe
+//! uses for its binary table.
+//!
+//! Ephemeral, per-turn workers are deliberately absent. A harness's durable
+//! roster is what it has *configured*; a worker spawned to service one request
+//! is internal to its parent and is not an enrollment candidate. For OpenClaw
+//! that distinction is free — `agents list` reports configured agents only.
+
+use std::process::Command;
+use std::time::{Duration, Instant};
+
+use serde::{Deserialize, Serialize};
+
+use super::{
+ classify_ssh_failure, failure_message, first_line, ssh_probe_args, wait_with_timeout,
+ HostProbeErrorKind,
+};
+use crate::managed_agents::ssh_config::{resolve_ssh_binary, SshHost};
+
+/// Wall-clock ceiling for one roster query.
+///
+/// Shorter than the host probe's budget: that one runs a loop over every known
+/// harness binary, while this runs a single command whose harness has already
+/// been proven present. A harness that cannot answer in this long is reported as
+/// timed out rather than allowed to wedge the connect dialog.
+const ROSTER_TIMEOUT: Duration = Duration::from_secs(15);
+
+/// Marks the start of parseable output, so a login shell's banners, MOTD, or
+/// rc-file chatter can be discarded.
+const ROSTER_START: &str = "__BUZZ_ROSTER_START__";
+
+/// Marks the end. Required, not optional: without it a session that died
+/// mid-command is indistinguishable from a harness that genuinely holds no
+/// agents, and "you have no agents" is a much worse lie than "the query was cut
+/// off". The host probe learned this the hard way.
+const ROSTER_END: &str = "__BUZZ_ROSTER_END__";
+
+/// One durable, named agent a harness reports.
+///
+/// Harness-neutral by construction. `agent_id` is the harness's own identifier
+/// for routing a message to exactly this agent; everything else is presentation.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct RemoteAgentCandidate {
+ /// Harness that reported this agent, matching `RemoteHarness::id`.
+ pub harness_id: String,
+ /// The harness's own agent identifier. This is the routing key: a reply must
+ /// be produced by this exact agent, never a parent or sibling.
+ pub agent_id: String,
+ /// Best available human label. Falls back to `agent_id` when the harness
+ /// reports no name, which is normal for a primary agent.
+ pub display_name: String,
+ /// True when the harness identifies this candidate as its primary. If the
+ /// harness reports no default and has no `main` candidate, none are marked.
+ pub is_primary: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub model: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub workspace: Option,
+ /// How many routing bindings the harness already has for this agent. Purely
+ /// informational: a bound agent is still a legal candidate.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub binding_count: Option,
+}
+
+/// Outcome of one roster query.
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct HarnessRosterResult {
+ pub host: String,
+ pub harness_id: String,
+ pub ok: bool,
+ pub duration_ms: u64,
+ /// False when Buzz has no recipe for this harness. Distinct from `ok:
+ /// false`: nothing is wrong with the host, Buzz simply cannot enumerate
+ /// that harness yet, and the UI should offer manual entry instead of an
+ /// error.
+ pub supported: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub error_kind: Option,
+ pub candidates: Vec,
+}
+
+/// How to enumerate one harness's durable agents.
+struct RosterRecipe {
+ harness_id: &'static str,
+ /// Remote command. Must be a literal containing no single quote: it is
+ /// single-quoted into a login-shell invocation, and no user input is ever
+ /// interpolated into it. [`recipes_are_quote_safe`] enforces both halves.
+ command: &'static str,
+ parse: fn(&str) -> Result, String>,
+}
+
+const ROSTER_RECIPES: &[RosterRecipe] = &[RosterRecipe {
+ harness_id: "openclaw",
+ command: "openclaw agents list --json",
+ parse: parse_openclaw_roster,
+}];
+
+fn recipe_for(harness_id: &str) -> Option<&'static RosterRecipe> {
+ ROSTER_RECIPES
+ .iter()
+ .find(|recipe| recipe.harness_id == harness_id)
+}
+
+/// Build the remote command for a recipe.
+///
+/// `exec $SHELL -lc` — login but *not* interactive. An interactive shell sources
+/// rc files where prompt frameworks and completion plugins live, several of
+/// which block without a TTY; the host probe hung on exactly that. Login alone
+/// still resolves the PATH a user's harness was installed into.
+/// The markers are emitted with `echo`, not `printf '%s\n'`: the whole inner
+/// command is single-quoted into the outer argv, so any single quote inside it
+/// would terminate that quoting early and hand the remainder to the shell as
+/// code. `echo` needs no quotes because a marker is a bare identifier.
+/// [`the_assembled_command_has_exactly_one_quoted_region`] holds this line.
+fn build_roster_command(recipe: &RosterRecipe) -> String {
+ let inner = format!(
+ "echo {ROSTER_START}; {} 2>/dev/null; echo {ROSTER_END}",
+ recipe.command
+ );
+ format!("exec $SHELL -lc '{inner}'")
+}
+
+/// Extract the payload between the markers.
+///
+/// Returns `None` when either marker is missing — the caller turns that into a
+/// truncation error rather than an empty roster.
+fn extract_payload(stdout: &str) -> Option<&str> {
+ let start = stdout.find(ROSTER_START)? + ROSTER_START.len();
+ let rest = &stdout[start..];
+ let end = rest.find(ROSTER_END)?;
+ Some(rest[..end].trim())
+}
+
+/// OpenClaw's `agents list --json` row.
+///
+/// Only the fields Buzz uses are named; the harness emits more and is free to
+/// add others. `identity_name` wins over `name` because it is what the harness
+/// itself renders, and a primary agent typically has neither.
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "camelCase")]
+struct OpenClawAgentRow {
+ id: String,
+ #[serde(default)]
+ name: Option,
+ #[serde(default)]
+ identity_name: Option,
+ #[serde(default)]
+ model: Option,
+ #[serde(default)]
+ workspace: Option,
+ #[serde(default)]
+ bindings: Option,
+ #[serde(default)]
+ is_default: Option,
+}
+
+/// Parse OpenClaw's durable roster.
+///
+/// Primary selection has a fallback: if no row is flagged `isDefault`, the row
+/// with id `main` is treated as primary. The specification says "the harness
+/// primary *or* `main`", and a roster with no preselection would push the choice
+/// onto a user who has no basis for making it.
+fn parse_openclaw_roster(payload: &str) -> Result, String> {
+ if payload.is_empty() {
+ return Err("harness returned no roster output".to_string());
+ }
+ let rows: Vec = serde_json::from_str(payload)
+ .map_err(|error| format!("could not parse the harness agent list: {error}"))?;
+
+ let mut candidates: Vec = Vec::with_capacity(rows.len());
+ for row in rows {
+ let agent_id = row.id.trim().to_string();
+ // A row with no id cannot be routed to, so it is not a candidate. Being
+ // silent about it is correct: it is malformed harness output, not a
+ // user-actionable condition.
+ if agent_id.is_empty() {
+ continue;
+ }
+ if candidates
+ .iter()
+ .any(|existing| existing.agent_id == agent_id)
+ {
+ continue;
+ }
+ let display_name = row
+ .identity_name
+ .as_deref()
+ .or(row.name.as_deref())
+ .map(str::trim)
+ .filter(|value| !value.is_empty())
+ .unwrap_or(&agent_id)
+ .to_string();
+ candidates.push(RemoteAgentCandidate {
+ harness_id: "openclaw".to_string(),
+ agent_id,
+ display_name,
+ is_primary: row.is_default.unwrap_or(false),
+ model: row.model.filter(|value| !value.trim().is_empty()),
+ workspace: row.workspace.filter(|value| !value.trim().is_empty()),
+ binding_count: row.bindings,
+ });
+ }
+
+ if candidates.is_empty() {
+ return Err("the harness reported no configured agents".to_string());
+ }
+
+ if !candidates.iter().any(|candidate| candidate.is_primary) {
+ if let Some(main) = candidates
+ .iter_mut()
+ .find(|candidate| candidate.agent_id == "main")
+ {
+ main.is_primary = true;
+ }
+ }
+
+ // Primary first, then alphabetical. Stable order matters for a list the user
+ // reads twice: once to choose, once to confirm.
+ candidates.sort_by(|a, b| {
+ b.is_primary.cmp(&a.is_primary).then_with(|| {
+ a.display_name
+ .to_lowercase()
+ .cmp(&b.display_name.to_lowercase())
+ })
+ });
+ Ok(candidates)
+}
+
+/// Enumerate durable agents for `harness_id` on an ssh host.
+///
+/// Never returns `Err` for a host-side problem, matching the host probe: an
+/// unreachable host is a reportable outcome the dialog renders, not an
+/// exception.
+pub fn probe_ssh_harness_agents(host: &SshHost, harness_id: &str) -> HarnessRosterResult {
+ let started = Instant::now();
+ let Some(recipe) = recipe_for(harness_id) else {
+ return unsupported(&host.host, harness_id, started);
+ };
+
+ let mut command = Command::new(resolve_ssh_binary());
+ // The same argument list the host probe uses, so trust behaviour cannot
+ // drift between the two: `BatchMode=yes` and `StrictHostKeyChecking=yes`
+ // mean this cannot prompt for a password or write a host key.
+ command
+ .args(ssh_probe_args(host))
+ .arg(build_roster_command(recipe));
+
+ run_roster(command, &host.host, recipe, started)
+}
+
+/// Enumerate durable agents for `harness_id` on this machine, using the
+/// identical command so the result shape cannot diverge.
+pub fn probe_local_harness_agents(harness_id: &str) -> HarnessRosterResult {
+ let started = Instant::now();
+ let Some(recipe) = recipe_for(harness_id) else {
+ return unsupported(super::LOCALHOST_ID, harness_id, started);
+ };
+
+ let mut command = Command::new("/bin/sh");
+ command.arg("-c").arg(build_roster_command(recipe));
+
+ run_roster(command, super::LOCALHOST_ID, recipe, started)
+}
+
+fn unsupported(host: &str, harness_id: &str, started: Instant) -> HarnessRosterResult {
+ HarnessRosterResult {
+ host: host.to_string(),
+ harness_id: harness_id.to_string(),
+ ok: false,
+ supported: false,
+ duration_ms: started.elapsed().as_millis() as u64,
+ error: Some(format!(
+ "Buzz cannot list the agents of a '{harness_id}' harness yet. Enter the agent's \
+ identity manually instead."
+ )),
+ error_kind: None,
+ candidates: Vec::new(),
+ }
+}
+
+fn run_roster(
+ mut command: Command,
+ host: &str,
+ recipe: &RosterRecipe,
+ started: Instant,
+) -> HarnessRosterResult {
+ command
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped());
+
+ let base = |ok: bool| HarnessRosterResult {
+ host: host.to_string(),
+ harness_id: recipe.harness_id.to_string(),
+ ok,
+ supported: true,
+ duration_ms: started.elapsed().as_millis() as u64,
+ error: None,
+ error_kind: None,
+ candidates: Vec::new(),
+ };
+
+ let output = match wait_with_timeout(command, ROSTER_TIMEOUT) {
+ Ok(Some(output)) => output,
+ Ok(None) => {
+ let kind = HostProbeErrorKind::TimedOut;
+ return HarnessRosterResult {
+ error: Some(failure_message(&kind, host, "")),
+ error_kind: Some(kind),
+ ..base(false)
+ };
+ }
+ Err(error) => {
+ return HarnessRosterResult {
+ error: Some(format!("could not list agents on '{host}': {error}")),
+ ..base(false)
+ };
+ }
+ };
+
+ let stdout = String::from_utf8_lossy(&output.stdout).to_string();
+ let stderr = String::from_utf8_lossy(&output.stderr).to_string();
+
+ let Some(payload) = extract_payload(&stdout) else {
+ // No opening marker at all means ssh itself failed; a missing closing
+ // marker means the session died partway. Classify the first case, since
+ // that is the one with an actionable remedy.
+ let kind = if stdout.contains(ROSTER_START) {
+ HostProbeErrorKind::Truncated
+ } else {
+ classify_ssh_failure(&stderr).unwrap_or(HostProbeErrorKind::Truncated)
+ };
+ return HarnessRosterResult {
+ error: Some(failure_message(&kind, host, &stderr)),
+ error_kind: Some(kind),
+ ..base(false)
+ };
+ };
+
+ match (recipe.parse)(payload) {
+ Ok(candidates) => HarnessRosterResult {
+ candidates,
+ ..base(true)
+ },
+ Err(error) => {
+ let detail = if stderr.trim().is_empty() {
+ error
+ } else {
+ format!("{error} ({})", first_line(&stderr))
+ };
+ HarnessRosterResult {
+ error: Some(format!(
+ "could not read the agent list on '{host}': {detail}"
+ )),
+ ..base(false)
+ }
+ }
+ }
+}
+
+/// Every recipe is safe to embed in a single-quoted remote command.
+///
+/// Exposed for the test module: the guarantee that no roster command can escape
+/// its quoting is what makes the "no user input reaches the remote shell" claim
+/// checkable rather than aspirational.
+#[cfg(test)]
+pub(crate) fn recipes_are_quote_safe() -> bool {
+ ROSTER_RECIPES
+ .iter()
+ .all(|recipe| !recipe.command.contains('\'') && !recipe.command.contains('\n'))
+}
+
+#[cfg(test)]
+#[path = "roster_tests.rs"]
+mod tests;
diff --git a/desktop/src-tauri/src/managed_agents/remote_probe/roster_tests.rs b/desktop/src-tauri/src/managed_agents/remote_probe/roster_tests.rs
new file mode 100644
index 0000000000..f9faa1af5a
--- /dev/null
+++ b/desktop/src-tauri/src/managed_agents/remote_probe/roster_tests.rs
@@ -0,0 +1,299 @@
+//! Tests for durable agent-roster enumeration.
+//!
+//! The JSON in `EXAMPLE_ROSTER` mirrors the shape returned by
+//! `openclaw agents list --json`: a primary with no display name plus named
+//! agents carrying optional identity metadata.
+
+use super::*;
+
+/// Verbatim excerpt of a real OpenClaw 2026.7.1 roster: a primary with no name,
+/// plus named agents carrying identity metadata.
+const EXAMPLE_ROSTER: &str = r#"[
+ {
+ "id": "main",
+ "workspace": "/home/example/.openclaw/workspace",
+ "agentDir": "/home/example/.openclaw/agents/main/agent",
+ "model": "provider/model-primary",
+ "bindings": 0,
+ "isDefault": true
+ },
+ {
+ "id": "worker-alpha",
+ "name": "Worker Alpha",
+ "identityName": "Worker Alpha",
+ "identitySource": "identity",
+ "workspace": "/home/example/.openclaw/agents/worker-alpha/workspace",
+ "model": "provider/model-worker",
+ "bindings": 0,
+ "isDefault": false
+ },
+ {
+ "id": "worker-beta",
+ "name": "Worker Beta",
+ "identityName": "Worker Beta",
+ "workspace": "/home/example/.openclaw/agents/worker-beta/workspace",
+ "model": "provider/model-worker",
+ "bindings": 1,
+ "isDefault": false
+ }
+]"#;
+
+#[test]
+fn parses_a_real_openclaw_roster() {
+ let candidates = parse_openclaw_roster(EXAMPLE_ROSTER).expect("roster parses");
+
+ assert_eq!(candidates.len(), 3);
+ // Primary first, then alphabetical — the order the selector renders.
+ assert_eq!(
+ candidates
+ .iter()
+ .map(|c| c.agent_id.as_str())
+ .collect::>(),
+ vec!["main", "worker-alpha", "worker-beta"]
+ );
+ assert!(candidates[0].is_primary);
+ assert!(!candidates[1].is_primary);
+ assert!(!candidates[2].is_primary);
+ assert_eq!(
+ candidates.iter().filter(|c| c.is_primary).count(),
+ 1,
+ "exactly one candidate is preselected"
+ );
+ assert!(candidates.iter().all(|c| c.harness_id == "openclaw"));
+}
+
+#[test]
+fn a_primary_with_no_name_falls_back_to_its_id() {
+ let candidates = parse_openclaw_roster(EXAMPLE_ROSTER).expect("roster parses");
+ let main = &candidates[0];
+ assert_eq!(main.agent_id, "main");
+ assert_eq!(main.display_name, "main");
+}
+
+#[test]
+fn identity_name_wins_over_name() {
+ let payload = r#"[{"id":"a","name":"Config Name","identityName":"Identity Name"}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert_eq!(candidates[0].display_name, "Identity Name");
+}
+
+#[test]
+fn name_is_used_when_identity_name_is_absent() {
+ let payload = r#"[{"id":"a","name":"Config Name"}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert_eq!(candidates[0].display_name, "Config Name");
+}
+
+#[test]
+fn blank_names_fall_back_rather_than_rendering_empty() {
+ let payload = r#"[{"id":"steve","name":" ","identityName":""}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert_eq!(candidates[0].display_name, "steve");
+}
+
+#[test]
+fn main_is_primary_when_the_harness_flags_nothing() {
+ // A harness that reports no default must still yield a preselection, or the
+ // dialog pushes a choice onto a user with no basis for making it.
+ let payload = r#"[{"id":"worker-alpha","name":"Worker Alpha"},{"id":"main"}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ let main = candidates
+ .iter()
+ .find(|c| c.agent_id == "main")
+ .expect("main present");
+ assert!(main.is_primary);
+ assert_eq!(candidates[0].agent_id, "main", "primary sorts first");
+}
+
+#[test]
+fn no_primary_is_claimed_when_there_is_no_default_and_no_main() {
+ // Inventing one would be worse than none: the user would enroll an agent
+ // Buzz guessed at.
+ let payload = r#"[{"id":"worker-alpha","name":"Worker Alpha"},{"id":"worker-beta","name":"Worker Beta"}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert!(candidates.iter().all(|c| !c.is_primary));
+}
+
+#[test]
+fn an_explicit_default_beats_the_main_fallback() {
+ let payload = r#"[{"id":"main"},{"id":"worker-alpha","isDefault":true}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert_eq!(candidates[0].agent_id, "worker-alpha");
+ assert!(candidates[0].is_primary);
+ let main = candidates
+ .iter()
+ .find(|c| c.agent_id == "main")
+ .expect("main present");
+ assert!(!main.is_primary, "the fallback must not double-mark");
+}
+
+#[test]
+fn duplicate_ids_collapse() {
+ let payload = r#"[{"id":"main"},{"id":"main","name":"Second"}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert_eq!(candidates.len(), 1);
+}
+
+#[test]
+fn rows_without_a_routable_id_are_dropped() {
+ let payload = r#"[{"id":" "},{"id":"worker-alpha","name":"Worker Alpha"}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert_eq!(candidates.len(), 1);
+ assert_eq!(candidates[0].agent_id, "worker-alpha");
+}
+
+#[test]
+fn blank_optional_details_become_none_rather_than_empty_strings() {
+ let payload = r#"[{"id":"worker-alpha","model":"","workspace":" "}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert_eq!(candidates[0].model, None);
+ assert_eq!(candidates[0].workspace, None);
+}
+
+#[test]
+fn binding_count_is_carried_through() {
+ let candidates = parse_openclaw_roster(EXAMPLE_ROSTER).expect("parses");
+ let worker = candidates
+ .iter()
+ .find(|c| c.agent_id == "worker-beta")
+ .expect("worker present");
+ assert_eq!(worker.binding_count, Some(1));
+}
+
+#[test]
+fn an_empty_roster_is_an_error_not_an_empty_list() {
+ // "This harness has no agents" and "the query returned nothing useful" must
+ // not render identically.
+ let error = parse_openclaw_roster("[]").expect_err("empty roster rejected");
+ assert!(error.contains("no configured agents"), "{error}");
+}
+
+#[test]
+fn empty_output_is_rejected() {
+ let error = parse_openclaw_roster("").expect_err("empty payload rejected");
+ assert!(error.contains("no roster output"), "{error}");
+}
+
+#[test]
+fn malformed_json_is_reported_as_a_parse_failure() {
+ let error = parse_openclaw_roster("not json").expect_err("malformed rejected");
+ assert!(error.contains("could not parse"), "{error}");
+}
+
+#[test]
+fn unknown_harness_fields_do_not_break_parsing() {
+ // The harness is free to add fields; Buzz names only what it uses.
+ let payload = r#"[{"id":"main","isDefault":true,"somethingNew":{"nested":1}}]"#;
+ let candidates = parse_openclaw_roster(payload).expect("parses");
+ assert_eq!(candidates.len(), 1);
+}
+
+#[test]
+fn the_remote_command_is_bounded_by_both_markers() {
+ let recipe = recipe_for("openclaw").expect("openclaw recipe exists");
+ let command = build_roster_command(recipe);
+ assert!(command.contains(ROSTER_START));
+ assert!(command.contains(ROSTER_END));
+ assert!(command.contains("openclaw agents list --json"));
+}
+
+#[test]
+fn the_remote_command_uses_a_login_but_not_interactive_shell() {
+ // `-lic` hangs on a real zsh host with prompt plugins; `-lc` still resolves
+ // the login PATH. The host probe was fixed for this and the roster query
+ // must not reintroduce it.
+ let recipe = recipe_for("openclaw").expect("openclaw recipe exists");
+ let command = build_roster_command(recipe);
+ assert!(command.contains("$SHELL -lc"), "{command}");
+ assert!(!command.contains("-lic"), "{command}");
+}
+
+#[test]
+fn every_recipe_is_safe_inside_single_quotes() {
+ assert!(recipes_are_quote_safe());
+}
+
+#[test]
+fn the_assembled_command_has_exactly_one_quoted_region() {
+ // Regression: the first version emitted markers with `printf '%s\n'`, whose
+ // quotes closed the outer quoting early and handed the rest of the command
+ // to the shell as code. Asserting the markers were merely *present* passed
+ // happily. Two quotes total — the wrapper pair — is the invariant.
+ for recipe in ROSTER_RECIPES {
+ let command = build_roster_command(recipe);
+ assert_eq!(
+ command.matches('\'').count(),
+ 2,
+ "command must contain only the wrapping quote pair: {command}"
+ );
+ let opened = command.find('\'').expect("opening quote");
+ let closed = command.rfind('\'').expect("closing quote");
+ assert_eq!(closed, command.len() - 1, "quoting must close at the end");
+ assert!(opened < closed);
+ }
+}
+
+#[test]
+fn the_assembled_command_is_accepted_by_a_real_shell() {
+ // Parse-check the exact string that reaches the remote shell. A quoting bug
+ // is otherwise invisible until it runs on someone's host, where it surfaces
+ // as an unexplained empty roster.
+ for recipe in ROSTER_RECIPES {
+ let command = build_roster_command(recipe);
+ let status = std::process::Command::new("/bin/sh")
+ .arg("-n")
+ .arg("-c")
+ .arg(&command)
+ .status()
+ .expect("sh runs");
+ assert!(status.success(), "shell rejected: {command}");
+ }
+}
+
+#[test]
+fn payload_extraction_discards_login_shell_noise() {
+ let stdout = format!(
+ "Welcome to the machine\nLast login: whenever\n{ROSTER_START}\n[]\n{ROSTER_END}\nbye\n"
+ );
+ assert_eq!(extract_payload(&stdout), Some("[]"));
+}
+
+#[test]
+fn a_missing_closing_marker_yields_no_payload() {
+ let stdout = format!("{ROSTER_START}\n[{{\"id\":\"main\"}}");
+ assert_eq!(extract_payload(&stdout), None);
+}
+
+#[test]
+fn a_missing_opening_marker_yields_no_payload() {
+ let stdout = format!("[]\n{ROSTER_END}");
+ assert_eq!(extract_payload(&stdout), None);
+}
+
+#[test]
+fn an_unknown_harness_is_unsupported_rather_than_failed() {
+ // The distinction drives the UI: unsupported offers manual entry, failure
+ // offers a retry.
+ let result = probe_local_harness_agents("hermes");
+ assert!(!result.supported);
+ assert!(!result.ok);
+ assert!(result.candidates.is_empty());
+ assert_eq!(result.harness_id, "hermes");
+ assert!(
+ result
+ .error
+ .as_deref()
+ .is_some_and(|error| error.contains("manually")),
+ "{:?}",
+ result.error
+ );
+}
+
+#[test]
+fn a_supported_harness_reports_supported_even_when_absent() {
+ // OpenClaw need not be installed on the machine running the tests: the point
+ // is that "Buzz knows how to ask" is independent of "the answer succeeded".
+ let result = probe_local_harness_agents("openclaw");
+ assert!(result.supported);
+ assert_eq!(result.harness_id, "openclaw");
+}
diff --git a/desktop/src/shared/api/remoteAgentApi.ts b/desktop/src/shared/api/remoteAgentApi.ts
index 9e26e7ad17..5b45a2250b 100644
--- a/desktop/src/shared/api/remoteAgentApi.ts
+++ b/desktop/src/shared/api/remoteAgentApi.ts
@@ -1,6 +1,7 @@
import { invokeTauri } from "@/shared/api/tauri";
import type {
ConnectedAgent,
+ HarnessRosterResult,
HostProbeResult,
SshHost,
} from "@/shared/api/remoteAgentTypes";
@@ -32,6 +33,27 @@ export async function probeLocalAgentHost(): Promise {
return await invokeTauri("probe_local_agent_host");
}
+/** List the durable agents held by one harness on a configured host. */
+export async function probeHarnessAgents(
+ host: string,
+ harness: string,
+): Promise {
+ return await invokeTauri("probe_harness_agents", {
+ host,
+ harness,
+ });
+}
+
+/** List the durable agents held by one harness on this machine. */
+export async function probeLocalHarnessAgentRoster(
+ harness: string,
+): Promise {
+ return await invokeTauri(
+ "probe_local_harness_agent_roster",
+ { harness },
+ );
+}
+
/** The self-hosted agents this machine is connected to. */
export async function listConnectedAgents(): Promise {
return await invokeTauri("list_connected_agents");
diff --git a/desktop/src/shared/api/remoteAgentTypes.ts b/desktop/src/shared/api/remoteAgentTypes.ts
index 31089882fb..0a4783c124 100644
--- a/desktop/src/shared/api/remoteAgentTypes.ts
+++ b/desktop/src/shared/api/remoteAgentTypes.ts
@@ -92,6 +92,35 @@ export type HostProbeResult = {
/** Host id the backend uses for the local machine. */
export const LOCALHOST_HOST_ID = "__localhost__";
+/** One durable, named agent reported by a harness. */
+export type RemoteAgentCandidate = {
+ /** Harness that reported this agent, matching `RemoteHarness.id`. */
+ harnessId: string;
+ /** Harness-owned routing key for this exact agent. */
+ agentId: string;
+ /** Best available label; falls back to `agentId`. */
+ displayName: string;
+ /** Whether the harness identifies this candidate as its primary agent. */
+ isPrimary: boolean;
+ model?: string;
+ workspace?: string;
+ /** Existing harness routing bindings, when reported. */
+ bindingCount?: number;
+};
+
+/** Outcome of listing one harness's durable agents. */
+export type HarnessRosterResult = {
+ host: string;
+ harnessId: string;
+ ok: boolean;
+ durationMs: number;
+ /** False when Buzz has no roster recipe for this harness. */
+ supported: boolean;
+ error?: string;
+ errorKind?: HostProbeErrorKind;
+ candidates: RemoteAgentCandidate[];
+};
+
/**
* 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.