diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index c23263de7e..3a1df40481 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -554,6 +554,46 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn auth_handshake_uses_command_specific_hermes_acp_args() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let acp_path = temp.path().join("buzz-acp"); + fs::write( + &acp_path, + "#!/bin/sh\nprintf '%s' \"$BUZZ_ACP_AGENT_ARGS\"\n", + ) + .expect("write fake buzz-acp"); + fs::set_permissions(&acp_path, fs::Permissions::from_mode(0o755)) + .expect("chmod fake buzz-acp"); + let adapter_path = temp.path().join("adapter"); + + let hermes = run_buzz_acp_auth_command_with_paths( + &acp_path, + "hermes", + &adapter_path, + ["auth-methods", "--json"], + None, + ) + .expect("run Hermes auth handshake"); + assert!(hermes.status.success()); + assert_eq!(hermes.stdout, b"acp"); + + let hermes_acp = run_buzz_acp_auth_command_with_paths( + &acp_path, + "hermes-acp", + &adapter_path, + ["auth-methods", "--json"], + None, + ) + .expect("run hermes-acp auth handshake"); + assert!(hermes_acp.status.success()); + assert!(hermes_acp.stdout.is_empty()); + } + #[test] fn shell_join_escapes_spaces_and_quotes() { assert_eq!( diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 05fb179125..975eba447b 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -20,7 +20,6 @@ use crate::{ }; /// Subset of the goose file config exposed to the frontend for gate evaluation. -/// /// Only the fields the dialog gate needs — not the full `RuntimeConfigSurface`. /// The gate uses this to know which requirements are already satisfied in the /// harness config file, so it can show "Set in goose config" rather than @@ -599,7 +598,7 @@ mod tests { use std::collections::BTreeMap; use super::*; - use crate::managed_agents::{BackendKind, RespondTo}; + use crate::managed_agents::{BackendKind, RespondTo, RuntimeAuthProbe}; fn goose_runtime() -> &'static KnownAcpRuntime { &KnownAcpRuntime { @@ -631,7 +630,8 @@ mod tests { context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), required_normalized_fields: &["model", "provider"], login_hint: None, - auth_probe_args: None, + readiness_probe_suffix: None, + auth_probe: RuntimeAuthProbe::NotApplicable, } } diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index f5fc7c3899..f5d9141b27 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -57,7 +57,8 @@ fn test_runtime() -> &'static KnownAcpRuntime { context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), required_normalized_fields: &["model", "provider"], login_hint: None, - auth_probe_args: None, + readiness_probe_suffix: None, + auth_probe: crate::managed_agents::discovery::RuntimeAuthProbe::NotApplicable, } } @@ -632,7 +633,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), required_normalized_fields: &["model", "provider"], login_hint: None, - auth_probe_args: None, + readiness_probe_suffix: None, + auth_probe: crate::managed_agents::discovery::RuntimeAuthProbe::NotApplicable, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 50206567ad..6b410e8117 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1,23 +1,27 @@ -use std::io::Read; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::OnceLock; -use std::time::{Duration, Instant}; use crate::managed_agents::{ buzz_managed_command_path, buzz_managed_node_bin_dir, buzz_managed_npm_bin_dir, AcpAvailabilityStatus, AcpRuntimeCatalogEntry, AuthStatus, CommandAvailabilityInfo, }; +mod probes; mod runtime_metadata; -pub(crate) use runtime_metadata::KnownAcpRuntime; +#[cfg(test)] +use probes::run_probe; +use probes::{auth_status_without_probe, availability_after_readiness_probe, probe_auth_status}; +pub(crate) use runtime_metadata::{KnownAcpRuntime, RuntimeAuthProbe}; 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"; +const HERMES_AVATAR_URL: &str = + "https://raw.githubusercontent.com/NousResearch/hermes-agent/refs/heads/main/apps/desktop/public/hermes.png"; fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); @@ -92,7 +96,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), required_normalized_fields: &["model", "provider"], login_hint: None, - auth_probe_args: None, + readiness_probe_suffix: None, + auth_probe: RuntimeAuthProbe::NotApplicable, }, KnownAcpRuntime { id: "claude", @@ -123,7 +128,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ 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"]), + readiness_probe_suffix: None, + auth_probe: RuntimeAuthProbe::Cli(&["claude", "auth", "status"]), }, KnownAcpRuntime { id: "codex", @@ -154,8 +160,47 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ context_limit_env_var: None, required_normalized_fields: &[], login_hint: Some("Run `codex login` to authenticate."), + readiness_probe_suffix: None, // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. - auth_probe_args: Some(&["codex", "login", "status"]), + auth_probe: RuntimeAuthProbe::Cli(&["codex", "login", "status"]), + }, + KnownAcpRuntime { + id: "hermes", + label: "Hermes Agent", + commands: &["hermes", "hermes-acp"], + aliases: &[], + avatar_url: HERMES_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[ + "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", + ], + cli_install_commands_windows: &[ + "powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"iex (irm https://hermes-agent.nousresearch.com/install.ps1)\"", + ], + adapter_install_commands: &[], + install_instructions_url: "https://hermes-agent.nousresearch.com/docs/getting-started/installation", + cli_install_hint: "Install Hermes Agent with the official installer.", + adapter_install_hint: "", + skill_dir: Some(".hermes/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.hermes/config.yaml"), + config_file_format: Some("yaml"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: None, + // Appended after the command-specific ACP args: `hermes acp --check` + // or `hermes-acp --check`. This validates only dependency readiness. + readiness_probe_suffix: Some(&["--check"]), + auth_probe: RuntimeAuthProbe::AcpHandshake, }, KnownAcpRuntime { id: "buzz-agent", @@ -186,7 +231,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ context_limit_env_var: Some("BUZZ_AGENT_MAX_CONTEXT_TOKENS"), required_normalized_fields: &["model", "provider"], login_hint: None, - auth_probe_args: None, + readiness_probe_suffix: None, + auth_probe: RuntimeAuthProbe::NotApplicable, }, ]; @@ -342,9 +388,9 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "hermes" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" - | "claudecode" | "buzz-agent" => Some(Vec::new()), + | "claudecode" | "hermes-acp" | "buzz-agent" => Some(Vec::new()), _ => None, } } @@ -896,102 +942,11 @@ pub(crate) fn is_npm_global_install(cmd: &str) -> bool { || t.starts_with("npm uninstall -g ") } -/// Run a CLI auth probe with a 10-second process-level timeout. -/// -/// Spawns the probe CLI as a child process. Stdout and stderr are drained on -/// background threads to prevent pipe-buffer deadlock. On timeout the child is -/// killed and `Unknown` is returned; no orphaned threads or processes are left -/// behind. Returns `Unknown` on timeout. -fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { - use crate::managed_agents::readiness::cli_probe; - - let augmented_path = cli_probe::augmented_path(); - - let mut command = std::process::Command::new(binary_path); - command.args(&probe_args[1..]); - if let Some(ref path) = augmented_path { - command.env("PATH", path); - } - command - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()); - - let mut child = match command.spawn() { - Ok(c) => c, - Err(_) => return AuthStatus::Unknown, - }; - - // Drain stdout/stderr on background threads to prevent pipe-buffer deadlock. - let stdout_pipe = child.stdout.take(); - let stderr_pipe = child.stderr.take(); - - let stdout_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stdout_pipe { - let _ = pipe.read_to_end(&mut buf); - } - }); - let stderr_thread = std::thread::spawn(move || { - let mut buf = Vec::new(); - if let Some(mut pipe) = stderr_pipe { - let _ = pipe.read_to_end(&mut buf); - } - buf - }); - - // Save PID for kill-on-timeout before moving child into the wait thread. - let child_pid = child.id(); - let (tx, rx) = std::sync::mpsc::channel(); - let wait_thread = std::thread::spawn(move || { - let _ = tx.send(child.wait()); - }); - - // 10-second timeout for auth probes. - let deadline = Instant::now() + Duration::from_secs(10); - let exit_status = loop { - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - #[cfg(unix)] - unsafe { - libc::kill(child_pid as i32, libc::SIGTERM); - } - #[cfg(not(unix))] - let _ = child_pid; - drop(rx); - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - match rx.recv_timeout(Duration::from_millis(100).min(remaining)) { - Ok(Ok(status)) => break status, - Ok(Err(_)) => { - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - let _ = stdout_thread.join(); - let _ = stderr_thread.join(); - return AuthStatus::Unknown; - } - } - }; - - let _ = wait_thread.join(); - let _ = stdout_thread.join(); - let stderr_bytes = stderr_thread.join().unwrap_or_default(); - - match cli_probe::classify_probe_output(&stderr_bytes, exit_status.success()) { - cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn, - cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut, - cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid { - diagnostic: stderr_excerpt, - }, - } +fn runtime_readiness_probe_args(command: &str, probe_suffix: &[&str]) -> Vec { + let mut args = vec![command.to_string()]; + args.extend(normalize_agent_args(command, Vec::new())); + args.extend(probe_suffix.iter().map(|arg| (*arg).to_string())); + args } pub fn command_availability(command: &str) -> CommandAvailabilityInfo { @@ -1182,6 +1137,27 @@ pub fn discover_acp_runtimes() -> Vec { } } + // Runtime dependency checks are distinct from authentication. + // Append the readiness suffix after command-specific ACP args so + // `hermes` runs `acp --check` while `hermes-acp` runs `--check`. + if availability == AcpAvailabilityStatus::Available { + if let (Some(probe_suffix), Some(command_name)) = + (runtime.readiness_probe_suffix, command.as_deref()) + { + let owned_probe_args = runtime_readiness_probe_args(command_name, probe_suffix); + let probe_arg_refs = owned_probe_args + .iter() + .map(String::as_str) + .collect::>(); + let probe_binary = binary_path.as_deref().map(Path::new); + availability = availability_after_readiness_probe( + availability, + probe_binary, + &probe_arg_refs, + ); + } + } + // Warm the adapter-availability cache for the badge fallback. // The cache is scoped to the codex runtime; other runtimes leave it // unchanged. Invalidated by `clear_resolve_cache`. @@ -1209,6 +1185,7 @@ pub fn discover_acp_runtimes() -> Vec { AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), + AcpAvailabilityStatus::DependencyMissing => cli_hint.to_string(), AcpAvailabilityStatus::NotInstalled => { if !cli_hint.is_empty() && !adapter_hint.is_empty() { format!("{cli_hint} {adapter_hint}") @@ -1267,7 +1244,9 @@ pub fn discover_acp_runtimes() -> Vec { if partial.entry.availability != AcpAvailabilityStatus::Available { return None; } - let probe_args = partial.runtime.auth_probe_args?; + let RuntimeAuthProbe::Cli(probe_args) = partial.runtime.auth_probe else { + return None; + }; // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). let binary_path = resolve_command(probe_args[0])?; let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); @@ -1296,14 +1275,8 @@ pub fn discover_acp_runtimes() -> Vec { // Fill NotApplicable / Unknown for non-probed entries. for partial in &mut partials { if partial.entry.auth_status == AuthStatus::Unknown { - partial.entry.auth_status = if partial.entry.availability - == AcpAvailabilityStatus::Available - && partial.runtime.auth_probe_args.is_none() - { - AuthStatus::NotApplicable - } else { - AuthStatus::Unknown - }; + partial.entry.auth_status = + auth_status_without_probe(&partial.entry.availability, &partial.runtime.auth_probe); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/probes.rs b/desktop/src-tauri/src/managed_agents/discovery/probes.rs new file mode 100644 index 0000000000..03a22b4b74 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/probes.rs @@ -0,0 +1,100 @@ +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; +use std::process::ExitStatus; +use std::time::{Duration, Instant}; + +use crate::managed_agents::readiness::cli_probe; +use crate::managed_agents::{AcpAvailabilityStatus, AuthStatus}; + +use super::RuntimeAuthProbe; + +pub(super) fn run_probe( + binary_path: &Path, + probe_args: &[&str], + timeout: Duration, +) -> Option<(ExitStatus, Vec)> { + let augmented_path = cli_probe::augmented_path(); + let mut stderr = tempfile::tempfile().ok()?; + let mut command = std::process::Command::new(binary_path); + command.args(&probe_args[1..]); + if let Some(ref path) = augmented_path { + command.env("PATH", path); + } + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(stderr.try_clone().ok()?); + + let mut child = command.spawn().ok()?; + let deadline = Instant::now() + timeout; + let exit_status = loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => std::thread::sleep(Duration::from_millis(100).min(remaining)), + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + }; + + stderr.seek(SeekFrom::Start(0)).ok()?; + let mut stderr_bytes = Vec::with_capacity(256); + let _ = (&mut stderr as &mut dyn Read) + .take(4096) + .read_to_end(&mut stderr_bytes); + Some((exit_status, stderr_bytes)) +} + +/// Run a CLI authentication probe with a 10-second process-level timeout. +pub(super) fn probe_auth_status(binary_path: &Path, probe_args: &[&str]) -> AuthStatus { + use crate::managed_agents::readiness::cli_probe; + + let Some((exit_status, stderr_bytes)) = + run_probe(binary_path, probe_args, Duration::from_secs(10)) + else { + return AuthStatus::Unknown; + }; + match cli_probe::classify_probe_output(&stderr_bytes, exit_status.success()) { + cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn, + cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut, + cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => AuthStatus::ConfigInvalid { + diagnostic: stderr_excerpt, + }, + } +} + +pub(super) fn availability_after_readiness_probe( + availability: AcpAvailabilityStatus, + binary_path: Option<&Path>, + probe_args: &[&str], +) -> AcpAvailabilityStatus { + if availability != AcpAvailabilityStatus::Available { + return availability; + } + match binary_path.and_then(|path| run_probe(path, probe_args, Duration::from_secs(10))) { + Some((status, _)) if status.success() => AcpAvailabilityStatus::Available, + _ => AcpAvailabilityStatus::DependencyMissing, + } +} + +pub(super) fn auth_status_without_probe( + availability: &AcpAvailabilityStatus, + auth_probe: &RuntimeAuthProbe, +) -> AuthStatus { + if *availability != AcpAvailabilityStatus::Available { + return AuthStatus::Unknown; + } + match auth_probe { + RuntimeAuthProbe::AcpHandshake => AuthStatus::CheckedOnLaunch, + RuntimeAuthProbe::NotApplicable => AuthStatus::NotApplicable, + RuntimeAuthProbe::Cli(_) => AuthStatus::Unknown, + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 4dfcad4256..859e914872 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -55,11 +55,22 @@ pub(crate) struct KnownAcpRuntime { /// Keys match the camelCase names used in `NormalizedConfig` (e.g. "model", "provider"). pub required_normalized_fields: &'static [&'static str], /// Human-readable hint shown in Doctor when the runtime is available but not - /// authenticated. `None` for runtimes that have no login step (goose, buzz-agent). + /// authenticated. `None` when authentication is not established by a CLI probe. pub login_hint: Option<&'static str>, - /// CLI args for probing authentication status. `args[0]` is the binary name; - /// the remainder are the subcommand. `None` for runtimes with no login step. - pub auth_probe_args: Option<&'static [&'static str]>, + /// Args appended to the runtime's normal ACP command for probing dependency + /// readiness. This must not be used to infer provider authentication. + pub readiness_probe_suffix: Option<&'static [&'static str]>, + pub auth_probe: RuntimeAuthProbe, +} + +/// How Buzz can truthfully discover authentication/setup for a runtime. +#[derive(Clone, Copy)] +pub(crate) enum RuntimeAuthProbe { + NotApplicable, + /// CLI args for probing authentication status. `args[0]` is the binary. + Cli(&'static [&'static str]), + /// Use the bounded ACP initialize/auth-method handshake at launch. + AcpHandshake, } impl KnownAcpRuntime { diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..b65359a9c7 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -5,44 +5,13 @@ use super::{ apply_agent_command_update, classify_runtime, codex_adapter_availability, codex_adapter_is_outdated, create_time_agent_command_override, default_agent_command, effective_agent_command, find_nvm_default_bin, find_via_login_shell, - is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, - parse_semver_tag, probe_codex_acp_major_version, record_agent_command, - refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, - GOOSE_AVATAR_URL, + is_login_shell_path_uninit, is_safe_nvm_tag, normalize_agent_args, parse_semver_tag, + probe_codex_acp_major_version, record_agent_command, refresh_login_shell_path, }; use crate::managed_agents::AcpAvailabilityStatus; -#[test] -fn resolves_known_avatar_for_bare_command() { - let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); - - assert_eq!(avatar_url, GOOSE_AVATAR_URL); -} - -#[test] -fn resolves_known_avatar_for_command_paths_and_aliases() { - assert_eq!( - managed_agent_avatar_url("/usr/local/bin/codex-acp"), - Some(CODEX_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url("Claude Code"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url("/usr/local/bin/claude-code-acp"), - Some(CLAUDE_CODE_AVATAR_URL.to_string()) - ); -} - -#[test] -fn returns_none_for_unknown_commands() { - assert!(managed_agent_avatar_url("custom-agent").is_none()); -} +mod catalog; +mod hermes; #[test] fn default_agent_command_resolves_bundled_buzz_agent() { @@ -72,18 +41,6 @@ fn normalizes_claude_and_codex_args_to_empty() { ); } -#[test] -fn resolves_buzz_agent_avatar() { - assert_eq!( - managed_agent_avatar_url("buzz-agent"), - Some(BUZZ_AGENT_AVATAR_URL.to_string()) - ); - assert_eq!( - managed_agent_avatar_url("/usr/local/bin/buzz-agent"), - Some(BUZZ_AGENT_AVATAR_URL.to_string()) - ); -} - #[test] fn normalizes_buzz_agent_args_to_empty() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/catalog.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/catalog.rs new file mode 100644 index 0000000000..396c5ab5a0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/catalog.rs @@ -0,0 +1,48 @@ +use super::super::{ + managed_agent_avatar_url, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, + GOOSE_AVATAR_URL, +}; + +#[test] +fn resolves_known_avatar_for_bare_command() { + let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); + + assert_eq!(avatar_url, GOOSE_AVATAR_URL); +} + +#[test] +fn resolves_known_avatar_for_command_paths_and_aliases() { + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/codex-acp"), + Some(CODEX_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("Claude Code"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url(r"C:\Tools\claude-agent-acp.exe"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/claude-code-acp"), + Some(CLAUDE_CODE_AVATAR_URL.to_string()) + ); +} + +#[test] +fn returns_none_for_unknown_commands() { + assert!(managed_agent_avatar_url("custom-agent").is_none()); +} + +#[test] +fn resolves_buzz_agent_avatar() { + assert_eq!( + managed_agent_avatar_url("buzz-agent"), + Some(BUZZ_AGENT_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/buzz-agent"), + Some(BUZZ_AGENT_AVATAR_URL.to_string()) + ); +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/hermes.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/hermes.rs new file mode 100644 index 0000000000..eaf026be9c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/hermes.rs @@ -0,0 +1,132 @@ +use super::super::{ + auth_status_without_probe, availability_after_readiness_probe, managed_agent_avatar_url, + normalize_agent_args, run_probe, runtime_readiness_probe_args, RuntimeAuthProbe, + HERMES_AVATAR_URL, +}; +use crate::managed_agents::{AcpAvailabilityStatus, AuthStatus}; +use std::time::{Duration, Instant}; + +#[test] +fn resolves_supported_hermes_commands() { + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/hermes"), + Some(HERMES_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url(r"C:\Tools\hermes-acp.exe"), + Some(HERMES_AVATAR_URL.to_string()) + ); +} + +#[test] +fn normalizes_args_for_each_supported_command() { + assert_eq!( + normalize_agent_args("hermes", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("/usr/local/bin/hermes", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("hermes-acp", vec!["acp".into()]), + Vec::::new() + ); + assert_eq!( + runtime_readiness_probe_args("hermes", &["--check"]), + vec!["hermes", "acp", "--check"] + ); + assert_eq!( + runtime_readiness_probe_args("hermes-acp", &["--check"]), + vec!["hermes-acp", "--check"] + ); +} + +#[test] +fn metadata_separates_dependency_readiness_from_authentication() { + let hermes = super::super::known_acp_runtime_exact("hermes").expect("Hermes metadata"); + + assert_eq!(hermes.commands, &["hermes", "hermes-acp"]); + assert_eq!(hermes.underlying_cli, None); + assert_eq!(hermes.readiness_probe_suffix, Some(&["--check"][..])); + assert!(matches!(hermes.auth_probe, RuntimeAuthProbe::AcpHandshake)); + assert_eq!(hermes.skill_dir, Some(".hermes/skills")); + assert_eq!(hermes.config_file_path, Some("~/.hermes/config.yaml")); + assert_eq!(hermes.config_file_format, Some("yaml")); + assert_eq!(hermes.model_env_var, None); + assert_eq!(hermes.provider_env_var, None); + assert_eq!( + auth_status_without_probe(&AcpAvailabilityStatus::Available, &hermes.auth_probe), + AuthStatus::CheckedOnLaunch + ); + assert_ne!( + auth_status_without_probe(&AcpAvailabilityStatus::Available, &hermes.auth_probe), + AuthStatus::LoggedIn, + "dependency readiness must never become an authentication claim" + ); +} + +#[test] +fn readiness_probe_distinguishes_success_failure_and_unavailable() { + let executable = std::env::current_exe().expect("current test executable"); + let executable_str = executable.to_string_lossy(); + + assert_eq!( + availability_after_readiness_probe( + AcpAvailabilityStatus::Available, + Some(&executable), + &[executable_str.as_ref(), "--list"], + ), + AcpAvailabilityStatus::Available + ); + assert_eq!( + availability_after_readiness_probe( + AcpAvailabilityStatus::Available, + Some(&executable), + &[executable_str.as_ref(), "--buzz-invalid-readiness-probe"], + ), + AcpAvailabilityStatus::DependencyMissing + ); + assert_eq!( + availability_after_readiness_probe( + AcpAvailabilityStatus::Available, + None, + &["missing", "--check"], + ), + AcpAvailabilityStatus::DependencyMissing + ); + assert_eq!( + availability_after_readiness_probe( + AcpAvailabilityStatus::NotInstalled, + Some(&executable), + &[executable_str.as_ref(), "--list"], + ), + AcpAvailabilityStatus::NotInstalled + ); +} + +#[test] +fn readiness_probe_timeout_is_bounded() { + let executable = std::env::current_exe().expect("current test executable"); + let executable_str = executable.to_string_lossy(); + let started = Instant::now(); + let result = run_probe( + &executable, + &[ + executable_str.as_ref(), + "--exact", + "managed_agents::discovery::tests::hermes::timeout_probe_fixture", + "--ignored", + ], + Duration::from_millis(100), + ); + + assert!(result.is_none()); + assert!(started.elapsed() < Duration::from_secs(2)); +} + +#[test] +#[ignore = "subprocess fixture for readiness_probe_timeout_is_bounded"] +fn timeout_probe_fixture() { + std::thread::sleep(Duration::from_secs(5)); +} diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..e22a424d54 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -886,7 +886,8 @@ mod tests { context_limit_env_var: None, required_normalized_fields: &[], login_hint: None, - auth_probe_args: None, + readiness_probe_suffix: None, + auth_probe: crate::managed_agents::discovery::RuntimeAuthProbe::NotApplicable, } } @@ -1080,7 +1081,8 @@ mod tests { context_limit_env_var: None, required_normalized_fields: &[], login_hint: None, - auth_probe_args: None, + readiness_probe_suffix: None, + auth_probe: crate::managed_agents::discovery::RuntimeAuthProbe::NotApplicable, } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 90da759af0..dd220c333b 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -539,6 +539,8 @@ pub enum AcpAvailabilityStatus { AdapterMissing, /// Adapter binary is present but is from the deprecated package (< 1.0). Reinstall required. AdapterOutdated, + /// Runtime binary is present but its dependency readiness probe failed. + DependencyMissing, CliMissing, NotInstalled, } @@ -559,8 +561,12 @@ pub enum AuthStatus { /// Trimmed excerpt of the stderr message. diagnostic: String, }, - /// This runtime does not have a login step (e.g. goose, buzz-agent). + /// No command-level authentication probe applies. Authentication may be + /// handled by the ACP initialize/auth-method handshake at launch. NotApplicable, + /// Provider authentication/setup is unverified until the bounded ACP + /// initialize/auth-method handshake runs. + CheckedOnLaunch, /// Probe was not attempted (runtime unavailable or probe timed out). Unknown, } diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index d69028cfd1..ccb4f4f96f 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -44,6 +44,7 @@ import { getPersonaProviderOptions, getRuntimePersonaModelOptions, NO_RUNTIME_DROPDOWN_VALUE, + runtimeUnavailableMessage, runtimeSupportsLlmProviderSelection, type PersonaDropdownOption, PERSONA_FIELD_CONTROL_CLASS, @@ -617,14 +618,8 @@ export function AgentDefinitionDialog({ const runtimeWarning = selectedRuntime && selectedRuntime.availability !== "available" ? (

- {selectedRuntime.availability === "adapter_missing" - ? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.` - : selectedRuntime.availability === "adapter_outdated" - ? `${selectedRuntime.label} ACP adapter is outdated — reinstall to continue.` - : selectedRuntime.availability === "cli_missing" - ? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.` - : `${selectedRuntime.label} is not installed.`}{" "} - Visit Settings > Agents to set it up. + {runtimeUnavailableMessage(selectedRuntime)} Visit Settings > Agents + to set it up.

) : null; const advancedFieldsTransition = shouldReduceMotion diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..b59cd0290a 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -418,14 +418,31 @@ export function formatRuntimeOptionLabel(runtime: AcpRuntimeCatalogEntry) { ? " (adapter missing)" : runtime.availability === "adapter_outdated" ? " (adapter outdated)" - : runtime.availability === "cli_missing" - ? " (CLI missing)" - : runtime.availability === "not_installed" - ? " (not installed)" - : ""; + : runtime.availability === "dependency_missing" + ? " (setup needed)" + : runtime.availability === "cli_missing" + ? " (CLI missing)" + : runtime.availability === "not_installed" + ? " (not installed)" + : ""; return `${runtime.label}${suffix}`; } +export function runtimeUnavailableMessage(runtime: AcpRuntimeCatalogEntry) { + switch (runtime.availability) { + case "adapter_missing": + return `${runtime.label} CLI is installed but the ACP adapter is missing.`; + case "adapter_outdated": + return `${runtime.label} ACP adapter is outdated — reinstall to continue.`; + case "dependency_missing": + return `${runtime.label} is installed but its ACP dependencies need setup.`; + case "cli_missing": + return `${runtime.label} ACP adapter is installed but the CLI is missing.`; + default: + return `${runtime.label} is not installed.`; + } +} + function runtimeAvailabilitySortRank( availability: AcpRuntimeCatalogEntry["availability"], ) { @@ -434,6 +451,8 @@ function runtimeAvailabilitySortRank( return 0; case "cli_missing": return 1; + case "dependency_missing": + return 1; case "not_installed": return 2; case "adapter_missing": diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 96e7322d14..5d1119c366 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -345,6 +345,19 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ); } + if (runtime.availability === "dependency_missing") { + return ( + <> +

+ Runtime detected; ACP dependencies need setup. +

+

+ {runtime.installHint} +

+ + ); + } + if (runtime.availability === "cli_missing") { return ( <> @@ -384,6 +397,9 @@ function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string { if (runtime.availability === "adapter_outdated") { return "ACP adapter detected but outdated — reinstall required."; } + if (runtime.availability === "dependency_missing") { + return "Runtime detected; ACP dependencies need setup."; + } if (runtime.availability === "cli_missing") { return "ACP adapter detected; CLI missing."; } diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index cee156ece7..65d8eff23b 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -202,13 +202,17 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { const label = runtime.authStatus.status === "config_invalid" ? "Config error" - : runtime.availability === "adapter_missing" - ? "Adapter needed" - : runtime.availability === "adapter_outdated" - ? "Update needed" - : runtime.availability === "cli_missing" - ? "CLI needed" - : null; + : runtime.authStatus.status === "checked_on_launch" + ? "Provider setup checked on launch" + : runtime.availability === "adapter_missing" + ? "Adapter needed" + : runtime.availability === "adapter_outdated" + ? "Update needed" + : runtime.availability === "dependency_missing" + ? "Setup needed" + : runtime.availability === "cli_missing" + ? "CLI needed" + : null; if (!label) { return null; @@ -297,7 +301,8 @@ function RuntimeRow({ const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false); const canConnectAccount = runtime.availability === "available" && - runtime.authStatus.status === "logged_out"; + (runtime.authStatus.status === "logged_out" || + runtime.authStatus.status === "checked_on_launch"); const authMethodsQuery = useAcpAuthMethodsQuery(runtime.id, { enabled: canConnectAccount, }); diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d28f2d0cf1..441cc7c4e3 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -519,6 +519,7 @@ export type AcpAvailabilityStatus = | "available" | "adapter_missing" | "adapter_outdated" + | "dependency_missing" | "cli_missing" | "not_installed"; @@ -528,6 +529,7 @@ export type AuthStatus = | { status: "logged_out" } | { status: "config_invalid"; diagnostic: string } | { status: "not_applicable" } + | { status: "checked_on_launch" } | { status: "unknown" }; export type AcpRuntimeCatalogEntry = { diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts index 7893236c28..082183892b 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -33,6 +33,7 @@ export type ConfigNudgeRequirement = * - "available" → tooling installed, needs login * - "adapter_missing" → CLI installed but ACP adapter missing * - "adapter_outdated" → ACP adapter present but from deprecated package; reinstall required + * - "dependency_missing" → runtime present but its dependency readiness check failed * - "cli_missing" → ACP adapter installed but CLI missing * - "not_installed" → neither adapter nor CLI found */ @@ -143,6 +144,7 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement { (r.availability === "available" || r.availability === "adapter_missing" || r.availability === "adapter_outdated" || + r.availability === "dependency_missing" || r.availability === "cli_missing" || r.availability === "not_installed") ); diff --git a/desktop/src/shared/ui/config-nudge-attachment.tsx b/desktop/src/shared/ui/config-nudge-attachment.tsx index e1294b7dde..560370864b 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -109,6 +109,8 @@ function cliLoginMessage( return `${harness} ACP adapter isn't installed`; case "adapter_outdated": return `${harness} ACP adapter is outdated — reinstall required`; + case "dependency_missing": + return `${harness} dependencies need setup`; case "available": // Tooling is present but authentication is needed — fall back to // the backend-supplied copy which has the exact login command.