From 86e2121abb2ebec2c4a96f37f5636619db5e5c89 Mon Sep 17 00:00:00 2001
From: dspury
Date: Fri, 31 Jul 2026 14:17:06 -0500
Subject: [PATCH 1/4] 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