From 10eba0b5bc7788a202a259aeba3e0f9cbcc6be02 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 2 Aug 2026 05:01:46 +0200 Subject: [PATCH] feat(desktop): enforce managed ACP runtime contracts Signed-off-by: Vincent Koc --- crates/buzz-acp/src/acp.rs | 17 + crates/buzz-acp/src/setup_mode.rs | 84 ++++- .../src-tauri/src/commands/agent_config.rs | 5 +- .../src-tauri/src/commands/agent_discovery.rs | 97 ++--- .../agent_discovery/install_planning.rs | 60 +++ .../commands/agent_discovery/managed_node.rs | 20 +- .../post_install_verification.rs | 2 + .../config_bridge/reader_tests.rs | 6 +- .../src-tauri/src/managed_agents/discovery.rs | 349 +++++------------- .../src/managed_agents/discovery/builtins.rs | 151 ++++++++ .../managed_agents/discovery/compatibility.rs | 304 +++++++++++++++ .../discovery/runtime_metadata.rs | 18 +- .../src-tauri/src/managed_agents/readiness.rs | 76 ++-- .../src/managed_agents/readiness/cli_login.rs | 45 ++- .../src/managed_agents/readiness/cli_probe.rs | 311 +++++++++++++++- .../src-tauri/src/managed_agents/runtime.rs | 10 +- desktop/src-tauri/src/managed_agents/types.rs | 14 +- .../agents/ui/agentConfigOptions.test.mjs | 3 + .../features/agents/ui/agentConfigOptions.tsx | 19 +- .../ui/runtimeAvailabilityWarning.test.mjs | 17 + .../agents/ui/runtimeAvailabilityWarning.ts | 4 + .../src/features/onboarding/ui/SetupStep.tsx | 42 +++ .../settings/ui/HarnessCatalogDialog.tsx | 42 ++- .../src/features/settings/ui/HarnessRow.tsx | 10 +- .../settings/ui/harnessCatalogLogic.test.mjs | 41 ++ .../settings/ui/harnessCatalogLogic.ts | 14 +- desktop/src/shared/api/types.ts | 4 +- desktop/src/shared/lib/configNudge.test.mjs | 41 ++ desktop/src/shared/lib/configNudge.ts | 4 + .../src/shared/ui/config-nudge-attachment.tsx | 4 + desktop/tests/e2e/harness-management.spec.ts | 48 ++- 31 files changed, 1432 insertions(+), 430 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_discovery/install_planning.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/builtins.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/compatibility.rs diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..4874c69d49 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -2894,6 +2894,23 @@ mod tests { observed } + #[cfg(unix)] + #[tokio::test] + async fn spawn_inherits_parent_buzz_context() { + const VAR: &str = "BUZZ_ACP_PARENT_CONTEXT_TEST"; + const VALUE: &str = "inherited"; + let previous = std::env::var_os(VAR); + std::env::set_var(VAR, VALUE); + + let observed = spawn_named_and_read_child_env("test-runtime", VAR, &[]).await; + + match previous { + Some(value) => std::env::set_var(VAR, value), + None => std::env::remove_var(VAR), + } + assert_eq!(observed, VALUE); + } + /// Buzz-owned Hermes processes get the configured-MCP isolation default, /// and an explicit persona entry still overrides it (defaults are applied /// before `extra_env`, so the later `Command::env` write wins). diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea4..5d01446208 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -64,6 +64,10 @@ pub(crate) enum AcpAvailabilityStatus { AdapterMissing, /// ACP adapter binary is from the deprecated package (< 1.0). Reinstall required. AdapterOutdated, + /// Runtime CLI is installed but does not implement the required ACP host contract. + CliOutdated, + /// Runtime contract verification could not complete; retry without mutating the install. + CompatibilityUnknown, /// CLI binary missing; ACP adapter may be present. CliMissing, /// Neither adapter nor CLI found. @@ -101,8 +105,8 @@ pub(crate) enum RequirementPayload { setup_copy: String, /// Granular install/auth state — determines copy and CTA routing on /// the desktop card. `Available` means tooling is present but login - /// is needed; the other three variants mean the tooling itself is - /// missing and the probe was skipped. + /// is needed; other variants describe install or compatibility gates + /// that prevented the auth probe from running. availability: AcpAvailabilityStatus, }, /// The CLI is installed but its config file could not be parsed. @@ -115,6 +119,8 @@ pub(crate) enum RequirementPayload { }, /// Git for Windows is missing; open Agent runtimes for the installation guide. GitBash, + /// A custom harness command cannot be resolved on PATH. + MissingBinary { command: String }, } impl RequirementPayload { @@ -153,6 +159,26 @@ impl RequirementPayload { harness ) } + AcpAvailabilityStatus::CliOutdated => { + let harness = probe_args + .first() + .map(String::as_str) + .unwrap_or("the agent"); + format!( + "update the {} CLI — the installed version lacks Buzz's required ACP runtime contract (open Agent runtimes in Settings to diagnose)", + harness + ) + } + AcpAvailabilityStatus::CompatibilityUnknown => { + let harness = probe_args + .first() + .map(String::as_str) + .unwrap_or("the agent"); + format!( + "check the {} runtime again — Buzz could not verify its ACP runtime contract", + harness + ) + } AcpAvailabilityStatus::CliMissing => { let harness = probe_args .first() @@ -189,6 +215,9 @@ impl RequirementPayload { RequirementPayload::GitBash => { "install Git for Windows (open Agent runtimes in Settings to diagnose)".to_string() } + RequirementPayload::MissingBinary { command } => { + format!("install `{command}` or add it to PATH") + } } } } @@ -257,14 +286,20 @@ impl SetupPayload { .requirements .iter() .any(|r| matches!(r, RequirementPayload::GitBash)); - let all_external = self - .requirements - .iter() - .all(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); - let any_external = self - .requirements - .iter() - .any(|r| matches!(r, RequirementPayload::CliConfigInvalid { .. })); + let all_external = self.requirements.iter().all(|r| { + matches!( + r, + RequirementPayload::CliConfigInvalid { .. } + | RequirementPayload::MissingBinary { .. } + ) + }); + let any_external = self.requirements.iter().any(|r| { + matches!( + r, + RequirementPayload::CliConfigInvalid { .. } + | RequirementPayload::MissingBinary { .. } + ) + }); let footer = if has_doctor_requirement { "Open Agent runtimes in Settings, install Git for Windows, then re-check and restart the agent.".to_string() @@ -756,6 +791,7 @@ mod tests { for availability in [ AcpAvailabilityStatus::AdapterMissing, AcpAvailabilityStatus::AdapterOutdated, + AcpAvailabilityStatus::CliOutdated, AcpAvailabilityStatus::CliMissing, AcpAvailabilityStatus::NotInstalled, ] { @@ -1105,6 +1141,34 @@ mod tests { ); } + #[test] + fn cli_login_availability_contract_states_survive_sentinel_round_trip() { + for availability in ["cli_outdated", "compatibility_unknown"] { + let raw = make_desktop_cli_login_json(availability); + let payload = SetupPayload::from_raw_env_value(Some(raw)) + .unwrap() + .expect("must parse"); + let body = payload.nudge_body(); + let sentinel_json = extract_sentinel_json(&body); + assert!( + sentinel_json.contains(&format!(r#""availability":"{availability}""#)), + "sentinel must carry availability={availability}; got: {sentinel_json:?}" + ); + } + } + + #[test] + fn setup_payload_deserializes_missing_binary_requirement() { + let payload: SetupPayload = serde_json::from_str( + r#"{"agent_name":"Custom","agent_pubkey":"test","requirements":[{"surface":"missing_binary","command":"my-agent"}]}"#, + ) + .unwrap(); + assert!(matches!( + payload.requirements.as_slice(), + [RequirementPayload::MissingBinary { command }] if command == "my-agent" + )); + } + #[test] fn cli_login_availability_cli_missing_survives_sentinel_round_trip() { let raw = make_desktop_cli_login_json("cli_missing"); diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 5a26f0f645..aaa79378f6 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. This tracks which requirements are already satisfied in the /// harness config file, so it can show "Set in goose config" rather than /// surfacing a false missing-key marker. @@ -636,10 +635,10 @@ mod tests { context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), required_normalized_fields: &["model", "provider"], login_hint: None, - auth_probe_args: None, + auth_probe: None, + compatibility_probe_args: None, } } - fn agent_record() -> ManagedAgentRecord { ManagedAgentRecord { pubkey: "agent".to_string(), diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index cbbf4ce351..2c450ef7b6 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -11,8 +11,11 @@ use crate::{ relay::query_relay, }; +mod install_planning; mod post_install_verification; +use install_planning::{plan_adapter_install, should_install_cli}; + fn active_installs() -> &'static std::sync::Mutex> { use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; @@ -20,54 +23,6 @@ fn active_installs() -> &'static std::sync::Mutex( - runtime_id: &str, - adapter_path: Option<&std::path::Path>, - adapter_install_commands: &'c [&'c str], - adapter_probe_path: Option<&str>, -) -> Option> { - match adapter_path { - // Adapter present and current — no install needed. - Some(_) if runtime_id != "codex" => None, - Some(path) - if !crate::managed_agents::codex_adapter_is_outdated_with_path( - path, - adapter_probe_path, - ) => - { - None - } - // Codex adapter is outdated: uninstall the old package first so npm - // doesn't hit EEXIST on the shared `codex-acp` bin-link, then install. - Some(_) => Some(vec![ - "npm uninstall -g @zed-industries/codex-acp", - "npm install -g @agentclientprotocol/codex-acp", - ]), - // Adapter missing: use the catalog's install commands directly. - None => Some(adapter_install_commands.to_vec()), - } -} - #[tauri::command] pub async fn discover_acp_providers( app: tauri::AppHandle, @@ -312,14 +267,37 @@ fn install_acp_runtime_blocking( let mut steps = Vec::new(); - // Phase 1: Install CLI if missing and commands are available. - // Today every entry in `cli_install_commands` is a curl-pipe; npm-backed - // adapter installs live in Phase 2 below where they are rewritten to a - // Buzz-private prefix before execution. + // Phase 1: Install or update the CLI when missing or incompatible. if let Some(cli) = runtime.underlying_cli { - if crate::managed_agents::resolve_command(cli).is_none() { - for cmd in runtime.cli_install_commands_for_os() { - let result = run_install_command_with_retry("cli", cmd, &reporter); + let availability = crate::managed_agents::discover_acp_runtime_availability(runtime_id); + if should_install_cli( + crate::managed_agents::resolve_command(cli).is_some(), + availability, + ) { + let commands = runtime.cli_install_commands_for_os(); + let use_managed_npm = commands.iter().any(|cmd| is_npm_global_install(cmd)) + && managed_node_runtime_supported(); + if use_managed_npm { + if let Err(step) = ensure_managed_node_runtime_blocking() { + reporter.record_step(&mut steps, *step); + return Ok(reporter.failed(steps)); + } + } + + for cmd in commands { + let planned = match if use_managed_npm { + managed_npm_command("cli", cmd) + } else { + Ok(None) + } { + Ok(Some(command)) => command, + Ok(None) => cmd.to_string(), + Err(step) => { + reporter.record_step(&mut steps, *step); + return Ok(reporter.failed(steps)); + } + }; + let result = run_install_command_with_retry("cli", &planned, &reporter); let success = result.success; steps.push(result); if !success { @@ -355,7 +333,7 @@ fn install_acp_runtime_blocking( for cmd in cmds { let planned = match if use_managed_npm { - managed_npm_command(cmd) + managed_npm_command("adapter", cmd) } else { Ok(None) } { @@ -1091,6 +1069,13 @@ mod tests { )); } + #[test] + fn test_is_npm_global_install_accepts_cli_package() { + assert!(is_npm_global_install( + "npm install -g example-acp-runtime@latest" + )); + } + #[test] fn test_is_npm_global_install_accepts_short_flag() { assert!(is_npm_global_install("npm i -g some-package")); diff --git a/desktop/src-tauri/src/commands/agent_discovery/install_planning.rs b/desktop/src-tauri/src/commands/agent_discovery/install_planning.rs new file mode 100644 index 0000000000..0816379a5e --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/install_planning.rs @@ -0,0 +1,60 @@ +use std::path::Path; + +use crate::managed_agents::AcpAvailabilityStatus; + +/// Plans adapter installation without spawning npm. +pub(super) fn plan_adapter_install<'c>( + runtime_id: &str, + adapter_path: Option<&Path>, + adapter_install_commands: &'c [&'c str], + adapter_probe_path: Option<&str>, +) -> Option> { + match adapter_path { + Some(_) if runtime_id != "codex" => None, + Some(path) + if !crate::managed_agents::codex_adapter_is_outdated_with_path( + path, + adapter_probe_path, + ) => + { + None + } + Some(_) => Some(vec![ + "npm uninstall -g @zed-industries/codex-acp", + "npm install -g @agentclientprotocol/codex-acp", + ]), + None => Some(adapter_install_commands.to_vec()), + } +} + +pub(super) fn should_install_cli( + cli_found: bool, + availability: Option, +) -> bool { + !cli_found || availability == Some(AcpAvailabilityStatus::CliOutdated) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn incompatible_cli_requires_update() { + assert!(should_install_cli( + true, + Some(AcpAvailabilityStatus::CliOutdated), + )); + assert!(should_install_cli( + false, + Some(AcpAvailabilityStatus::NotInstalled), + )); + assert!(!should_install_cli( + true, + Some(AcpAvailabilityStatus::Available), + )); + assert!(!should_install_cli( + true, + Some(AcpAvailabilityStatus::CompatibilityUnknown), + )); + } +} diff --git a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs index 72108f0291..ca0b01e2fe 100644 --- a/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs +++ b/desktop/src-tauri/src/commands/agent_discovery/managed_node.rs @@ -469,14 +469,17 @@ fn managed_npm_prefix_hint() -> String { "Buzz could not create its private Node tools directory. Check app-data directory permissions, restart Buzz, then click Install again.".to_string() } -pub(super) fn managed_npm_command(command: &str) -> Result, Box> { +pub(super) fn managed_npm_command( + step: &str, + command: &str, +) -> Result, Box> { if !is_npm_global_install(command) { return Ok(None); } let Some(prefix) = crate::managed_agents::buzz_managed_npm_prefix() else { return Err(Box::new(InstallStepResult { - step: "adapter".to_string(), + step: step.to_string(), command: command.to_string(), success: false, stdout: String::new(), @@ -487,7 +490,7 @@ pub(super) fn managed_npm_command(command: &str) -> Result, Box String { AcpAvailabilityStatus::Available => "available", AcpAvailabilityStatus::AdapterMissing => "ACP adapter missing", AcpAvailabilityStatus::AdapterOutdated => "ACP adapter outdated", + AcpAvailabilityStatus::CliOutdated => "CLI outdated", + AcpAvailabilityStatus::CompatibilityUnknown => "runtime compatibility unknown", AcpAvailabilityStatus::CliMissing => "CLI missing", AcpAvailabilityStatus::NotInstalled => "not installed", } 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 4ee4ec79c3..78ff9b8cbc 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 @@ -58,7 +58,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, + auth_probe: None, + compatibility_probe_args: None, } } @@ -636,7 +637,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, + auth_probe: None, + compatibility_probe_args: None, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8d1b8a5013..a5f643b67d 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1,8 +1,6 @@ -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, @@ -10,18 +8,22 @@ use crate::managed_agents::{ HarnessSource, }; +mod builtins; +mod compatibility; mod presets; mod runtime_metadata; +use builtins::KNOWN_ACP_RUNTIMES; +#[cfg(test)] +use builtins::{BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL}; +use compatibility::{ + cached_runtime_compatibility, clear_compatibility_cache, probe_runtime_compatibility, + probe_runtime_compatibility_at, RuntimeCompatibility, +}; use presets::{preset_catalog_entry, PRESET_HARNESSES}; pub(crate) use presets::{preset_harness_definitions, preset_harness_ids}; -pub(crate) use runtime_metadata::KnownAcpRuntime; +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"; fn common_binary_paths() -> &'static [PathBuf] { static PATHS: OnceLock> = OnceLock::new(); PATHS.get_or_init(|| { @@ -72,140 +74,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) @@ -600,6 +468,7 @@ pub fn clear_resolve_cache() { // Also invalidate the adapter-availability cache so a freshly-installed // adapter is reflected the next time the summary builder checks the badge. clear_adapter_availability_cache(); + clear_compatibility_cache(); } // ── Adapter availability cache (Phase-2 badge fallback) ───────────────────── @@ -1001,12 +870,12 @@ pub(crate) fn find_command(command: &str) -> Option { resolve_command(command) } -/// Returns true when the runtime has at least one adapter install step that -/// is an npm global install. Used to determine whether Node.js is required. +/// Returns true when the runtime has at least one install step that uses npm. fn runtime_needs_npm(runtime: &KnownAcpRuntime) -> bool { runtime - .adapter_install_commands + .cli_install_commands_for_os() .iter() + .chain(runtime.adapter_install_commands.iter()) .any(|cmd| is_npm_global_install(cmd)) } @@ -1022,105 +891,6 @@ 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()); - crate::util::configure_no_window(&mut command); - - 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, - }, - } -} - pub fn command_availability(command: &str) -> CommandAvailabilityInfo { let resolved_path = resolve_command(command).map(|path| path.display().to_string()); CommandAvailabilityInfo { @@ -1166,6 +936,61 @@ pub(crate) fn classify_runtime( } } +fn availability_with_compatibility( + availability: AcpAvailabilityStatus, + compatibility: RuntimeCompatibility, +) -> AcpAvailabilityStatus { + if availability != AcpAvailabilityStatus::Available { + return availability; + } + match compatibility { + RuntimeCompatibility::Compatible => AcpAvailabilityStatus::Available, + RuntimeCompatibility::Incompatible => AcpAvailabilityStatus::CliOutdated, + RuntimeCompatibility::Unknown => AcpAvailabilityStatus::CompatibilityUnknown, + } +} + +pub(crate) fn availability_with_cached_compatibility( + runtime: &KnownAcpRuntime, + availability: AcpAvailabilityStatus, +) -> AcpAvailabilityStatus { + // A missing cache entry means discovery has not probed yet. Let launch + // reach the shared boundary, which verifies the exact executable. + cached_runtime_compatibility(runtime) + .map(|compatibility| availability_with_compatibility(availability.clone(), compatibility)) + .unwrap_or(availability) +} + +pub(crate) fn verify_runtime_contract( + runtime: Option<&'static KnownAcpRuntime>, + effective_command: &str, +) -> Result { + let resolved_path = resolve_command(effective_command); + let resolved_command = resolved_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| effective_command.to_string()); + let Some(runtime) = runtime else { + return Ok(resolved_command); + }; + let compatibility = match resolved_path { + Some(path) => probe_runtime_compatibility_at(runtime, &path), + None if runtime.compatibility_probe_args.is_none() => RuntimeCompatibility::Compatible, + None => RuntimeCompatibility::Unknown, + }; + match compatibility { + RuntimeCompatibility::Compatible => Ok(resolved_command), + RuntimeCompatibility::Incompatible => Err(format!( + "{} does not provide Buzz's required self-contained ACP stdio runtime. Update the CLI.", + runtime.label + )), + RuntimeCompatibility::Unknown => Err(format!( + "Buzz could not verify {}'s self-contained ACP runtime contract. Check the CLI and try again.", + runtime.label + )), + } +} + /// The oldest `codex-acp` version supported by Buzz managed agents. /// /// Older 1.x adapters are detected successfully, but can still bundle a Codex runtime @@ -1342,6 +1167,9 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr } } + availability = + availability_with_compatibility(availability, probe_runtime_compatibility(runtime)); + // 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`. @@ -1369,6 +1197,13 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), + AcpAvailabilityStatus::CliOutdated => cli_hint.to_string(), + AcpAvailabilityStatus::CompatibilityUnknown => { + format!( + "Buzz could not verify the {} runtime contract. Check again.", + runtime.label + ) + } AcpAvailabilityStatus::NotInstalled => { if !cli_hint.is_empty() && !adapter_hint.is_empty() { format!("{cli_hint} {adapter_hint}") @@ -1384,6 +1219,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr runtime.adapter_install_instructions_url } AcpAvailabilityStatus::Available + | AcpAvailabilityStatus::CliOutdated + | AcpAvailabilityStatus::CompatibilityUnknown | AcpAvailabilityStatus::CliMissing | AcpAvailabilityStatus::NotInstalled => runtime.cli_install_instructions_url, }; @@ -1474,14 +1311,32 @@ pub fn discover_acp_runtimes_from( if partial.entry.availability != AcpAvailabilityStatus::Available { return None; } - let probe_args = partial.runtime.auth_probe_args?; + let probe = partial.runtime.auth_probe?; + let probe_args = probe.args; // 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(); + let usable_exit_codes = probe.usable_exit_codes; let handle = std::thread::spawn(move || { + use crate::managed_agents::readiness::cli_probe; + let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); - probe_auth_status(&binary_path, &refs) + match cli_probe::login_probe( + &binary_path, + &refs, + usable_exit_codes, + cli_probe::augmented_path().as_deref(), + ) { + cli_probe::ProbeOutcome::LoggedIn => AuthStatus::LoggedIn, + cli_probe::ProbeOutcome::Unknown => AuthStatus::Unknown, + cli_probe::ProbeOutcome::LoggedOut => AuthStatus::LoggedOut, + cli_probe::ProbeOutcome::ConfigInvalid { stderr_excerpt } => { + AuthStatus::ConfigInvalid { + diagnostic: stderr_excerpt, + } + } + } }); Some((idx, handle)) }) @@ -1505,7 +1360,7 @@ pub fn discover_acp_runtimes_from( if partial.entry.auth_status == AuthStatus::Unknown { partial.entry.auth_status = if partial.entry.availability == AcpAvailabilityStatus::Available - && partial.runtime.auth_probe_args.is_none() + && partial.runtime.auth_probe.is_none() { AuthStatus::NotApplicable } else { diff --git a/desktop/src-tauri/src/managed_agents/discovery/builtins.rs b/desktop/src-tauri/src/managed_agents/discovery/builtins.rs new file mode 100644 index 0000000000..56badfcd37 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/builtins.rs @@ -0,0 +1,151 @@ +use super::{KnownAcpRuntime, RuntimeAuthProbe}; + +pub(super) const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; +pub(super) 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(super) const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +pub(super) const BUZZ_AGENT_AVATAR_URL: &str = + "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; + +pub(super) 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: None, + compatibility_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: Some(RuntimeAuthProbe { + args: &["claude", "auth", "status"], + usable_exit_codes: &[0], + }), + compatibility_probe_args: None, + }, + 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: Some(RuntimeAuthProbe { + args: &["codex", "login", "status"], + usable_exit_codes: &[0], + }), + compatibility_probe_args: None, + }, + 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: None, + compatibility_probe_args: None, + }, +]; diff --git a/desktop/src-tauri/src/managed_agents/discovery/compatibility.rs b/desktop/src-tauri/src/managed_agents/discovery/compatibility.rs new file mode 100644 index 0000000000..5a69d60af9 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/compatibility.rs @@ -0,0 +1,304 @@ +use std::path::Path; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use serde::Deserialize; + +use super::{resolve_command, KnownAcpRuntime}; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_CONTRACT_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RuntimeCompatibility { + Compatible, + Incompatible, + Unknown, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct RuntimeContract { + schema_version: u32, + protocol: String, + transport: String, + execution: String, +} + +fn compatibility_cache( +) -> &'static Mutex> { + static CACHE: OnceLock>> = + OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +pub(super) fn clear_compatibility_cache() { + compatibility_cache() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .clear(); +} + +pub(super) fn cached_runtime_compatibility( + runtime: &KnownAcpRuntime, +) -> Option { + if runtime.compatibility_probe_args.is_none() { + return Some(RuntimeCompatibility::Compatible); + } + compatibility_cache() + .lock() + .ok() + .and_then(|cache| cache.get(runtime.id).copied()) +} + +pub(super) fn probe_runtime_compatibility( + runtime: &'static KnownAcpRuntime, +) -> RuntimeCompatibility { + let Some(probe_args) = runtime.compatibility_probe_args else { + return RuntimeCompatibility::Compatible; + }; + let Some(binary_path) = resolve_command(probe_args[0]) else { + return RuntimeCompatibility::Unknown; + }; + probe_runtime_compatibility_at(runtime, &binary_path) +} + +pub(super) fn probe_runtime_compatibility_at( + runtime: &'static KnownAcpRuntime, + binary_path: &Path, +) -> RuntimeCompatibility { + let Some(probe_args) = runtime.compatibility_probe_args else { + return RuntimeCompatibility::Compatible; + }; + let outcome = probe_command( + binary_path, + probe_args, + crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), + PROBE_TIMEOUT, + ); + if let Ok(mut cache) = compatibility_cache().lock() { + cache.insert(runtime.id, outcome); + } + outcome +} + +fn probe_command( + binary_path: &Path, + probe_args: &[&str], + augmented_path: Option<&str>, + timeout: Duration, +) -> RuntimeCompatibility { + let Ok(output) = crate::managed_agents::readiness::cli_probe::run_bounded_probe( + binary_path, + probe_args, + augmented_path, + timeout, + crate::managed_agents::readiness::cli_probe::ProbeOutputStream::Stdout, + MAX_CONTRACT_BYTES, + ) else { + return RuntimeCompatibility::Unknown; + }; + if output.truncated { + return RuntimeCompatibility::Incompatible; + } + if output.status.success() { + classify_contract(&output.bytes) + } else if output.status.code().is_some() { + RuntimeCompatibility::Incompatible + } else { + RuntimeCompatibility::Unknown + } +} + +fn classify_contract(stdout: &[u8]) -> RuntimeCompatibility { + let Ok(contract) = serde_json::from_slice::(stdout) else { + return RuntimeCompatibility::Incompatible; + }; + if contract.schema_version == 1 + && contract.protocol == "acp" + && contract.transport == "stdio" + && contract.execution == "embedded" + { + RuntimeCompatibility::Compatible + } else { + RuntimeCompatibility::Incompatible + } +} + +#[cfg(all(test, unix))] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use super::*; + use crate::managed_agents::AcpAvailabilityStatus; + + #[test] + fn compatibility_unknown_blocks_an_otherwise_available_runtime() { + assert_eq!( + super::super::availability_with_compatibility( + AcpAvailabilityStatus::Available, + RuntimeCompatibility::Unknown, + ), + AcpAvailabilityStatus::CompatibilityUnknown + ); + assert_eq!( + super::super::availability_with_compatibility( + AcpAvailabilityStatus::Available, + RuntimeCompatibility::Incompatible, + ), + AcpAvailabilityStatus::CliOutdated + ); + assert_eq!( + super::super::availability_with_compatibility( + AcpAvailabilityStatus::Available, + RuntimeCompatibility::Compatible, + ), + AcpAvailabilityStatus::Available + ); + } + + #[test] + fn compatibility_probe_requires_successful_runtime_contract() { + let dir = tempfile::tempdir().expect("temp dir"); + let bin = dir.path().join("runtime"); + std::fs::write( + &bin, + r#"#!/bin/sh +if [ "$1" = acp ] && [ "$2" = info ]; then + printf '%s\n' '{"schemaVersion":1,"protocol":"acp","transport":"stdio","execution":"embedded"}' + exit 0 +fi +exit 1 +"#, + ) + .expect("write probe runtime"); + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("chmod probe runtime"); + + assert_eq!( + probe_command( + &bin, + &["runtime", "acp", "info"], + None, + Duration::from_secs(1), + ), + RuntimeCompatibility::Compatible + ); + assert_eq!( + probe_command( + &bin, + &["runtime", "acp", "legacy"], + None, + Duration::from_secs(1), + ), + RuntimeCompatibility::Incompatible + ); + } + + #[test] + fn exit_zero_requires_the_exact_self_contained_contract() { + assert_eq!( + classify_contract( + br#"{"schemaVersion":1,"protocol":"acp","transport":"stdio","execution":"embedded"}"#, + ), + RuntimeCompatibility::Compatible + ); + assert_eq!( + classify_contract( + br#"{"schemaVersion":1,"protocol":"acp","transport":"stdio","execution":"gateway"}"#, + ), + RuntimeCompatibility::Incompatible + ); + assert_eq!( + classify_contract(br#"{"status":"ok"}"#), + RuntimeCompatibility::Incompatible + ); + assert_eq!( + classify_contract(b"not json"), + RuntimeCompatibility::Incompatible + ); + } + + #[test] + fn probe_bounds_output_without_waiting_for_stdout_owners() { + let dir = tempfile::tempdir().expect("temp dir"); + let oversized = dir.path().join("oversized-runtime"); + std::fs::write(&oversized, "#!/bin/sh\nhead -c 20000 /dev/zero\n") + .expect("write oversized runtime"); + std::fs::set_permissions(&oversized, std::fs::Permissions::from_mode(0o755)) + .expect("chmod oversized runtime"); + assert_eq!( + probe_command( + &oversized, + &["runtime", "acp", "info"], + None, + Duration::from_secs(1), + ), + RuntimeCompatibility::Incompatible + ); + + let inherited = dir.path().join("inherited-runtime"); + std::fs::write( + &inherited, + r#"#!/bin/sh +(sleep 2) & +printf '%s\n' '{"schemaVersion":1,"protocol":"acp","transport":"stdio","execution":"embedded"}' +"#, + ) + .expect("write inherited runtime"); + std::fs::set_permissions(&inherited, std::fs::Permissions::from_mode(0o755)) + .expect("chmod inherited runtime"); + assert_eq!( + probe_command( + &inherited, + &["runtime", "acp", "info"], + None, + Duration::from_secs(1), + ), + RuntimeCompatibility::Compatible + ); + } + + #[test] + fn operational_probe_failures_are_unknown() { + let dir = tempfile::tempdir().expect("temp dir"); + let missing = dir.path().join("missing-runtime"); + assert_eq!( + probe_command( + &missing, + &["runtime", "acp", "info"], + None, + Duration::from_millis(10), + ), + RuntimeCompatibility::Unknown + ); + + let slow = dir.path().join("slow-runtime"); + std::fs::write(&slow, "#!/bin/sh\nwhile :; do :; done\n").expect("write slow runtime"); + std::fs::set_permissions(&slow, std::fs::Permissions::from_mode(0o755)) + .expect("chmod slow runtime"); + assert_eq!( + probe_command( + &slow, + &["runtime", "acp", "info"], + None, + Duration::from_millis(10), + ), + RuntimeCompatibility::Unknown + ); + + let signaled = dir.path().join("signaled-runtime"); + std::fs::write(&signaled, "#!/bin/sh\nkill -TERM $$\n").expect("write signaled runtime"); + std::fs::set_permissions(&signaled, std::fs::Permissions::from_mode(0o755)) + .expect("chmod signaled runtime"); + assert_eq!( + probe_command( + &signaled, + &["runtime", "acp", "info"], + None, + Duration::from_secs(1), + ), + RuntimeCompatibility::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 fdfe9b8be7..db4286c9ce 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -1,3 +1,12 @@ +/// Authentication probe contract for a known ACP runtime. +#[derive(Clone, Copy)] +pub(crate) struct RuntimeAuthProbe { + /// CLI args for probing authentication status. `args[0]` is the binary name. + pub args: &'static [&'static str], + /// Exit codes that mean the configured credentials are currently usable. + pub usable_exit_codes: &'static [i32], +} + /// Static capabilities and installation metadata for a known ACP runtime. pub(crate) struct KnownAcpRuntime { pub id: &'static str, @@ -59,9 +68,12 @@ pub(crate) struct KnownAcpRuntime { /// 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). 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]>, + /// Authentication status contract. `None` for runtimes with no login step. + pub auth_probe: Option, + /// CLI args for proving that the installed runtime supports Buzz's required + /// hosting contract. Exit zero must emit the exact supported JSON contract; + /// other completed results are incompatible, while operational failures are unknown. + pub compatibility_probe_args: Option<&'static [&'static str]>, } impl KnownAcpRuntime { diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index fa8eb36fa1..02e60da8ff 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -1,5 +1,4 @@ //! Agent readiness evaluation. -//! //! # Overview //! //! Before spawning a managed agent (or before deciding whether to enter @@ -435,13 +434,16 @@ fn collect_missing_requirements( let file_cfg = read_goose_file_config(); goose_requirements(effective, file_cfg.as_ref()) } - "claude" => cli_login::requirements( - &["claude", "auth", "status"], - "complete Claude Code authentication by running the Claude CLI", - rt, - ), - "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), - _ => vec![], + _ => rt + .auth_probe + .map(|probe| { + cli_login::requirements( + probe, + rt.login_hint.unwrap_or("authenticate the runtime CLI"), + rt, + ) + }) + .unwrap_or_default(), } } @@ -990,31 +992,6 @@ mod tests { ); } - // ── codex tests ─────────────────────────────────────────────────────── - - #[test] - fn codex_not_ready_copy_does_not_mention_openai_api_key() { - // codex uses its own credential store via `codex login` (OAuth or API key). - // The nudge copy must NOT say "set OPENAI_API_KEY". - // Use a not-installed runtime so the requirement is always emitted - // regardless of whether codex is on the test machine's PATH. - let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], None); - let reqs = cli_login::requirements(&["codex", "login", "status"], "run `codex login`", &rt); - // Whether codex is installed or not, the copy (if any) must not mention OPENAI_API_KEY. - for req in &reqs { - if let Requirement::CliLogin { setup_copy, .. } = req { - assert!( - !setup_copy.contains("OPENAI_API_KEY"), - "codex nudge copy must not mention OPENAI_API_KEY; got: {setup_copy:?}" - ); - assert!( - setup_copy.contains("codex login"), - "codex nudge copy should mention `codex login`; got: {setup_copy:?}" - ); - } - } - } - // ── cli_login_requirements: resolve_command integration ───────────── /// Construct a minimal `KnownAcpRuntime` stub for testing cli_login_requirements. @@ -1053,7 +1030,8 @@ mod tests { context_limit_env_var: None, required_normalized_fields: &[], login_hint: None, - auth_probe_args: None, + auth_probe: None, + compatibility_probe_args: None, } } @@ -1083,7 +1061,7 @@ mod tests { Some("__buzz_nonexistent_cli_abc123__"), ); let reqs = cli_login::requirements( - &["__buzz_nonexistent_binary_abc123__", "status"], + cli_login::test_probe(&["__buzz_nonexistent_binary_abc123__", "status"]), "install the tool first", &rt, ); @@ -1116,7 +1094,11 @@ mod tests { // → AdapterMissing state → no probe run → CliLogin{AdapterMissing}. let exe = present_binary_str(); let rt = make_cli_runtime(&["__buzz_nonexistent_adapter_xyz789__"], Some(exe)); - let reqs = cli_login::requirements(&[exe, "--list"], "install the adapter", &rt); + let reqs = cli_login::requirements( + cli_login::test_probe(&[exe, "--list"]), + "install the adapter", + &rt, + ); assert!( !reqs.is_empty(), "adapter missing must produce a CliLogin requirement" @@ -1143,7 +1125,11 @@ mod tests { static_commands(vec![exe]), // adapter found via absolute path Some("__buzz_nonexistent_cli_abc123__"), // underlying CLI missing ); - let reqs = cli_login::requirements(&[exe, "--list"], "install the CLI", &rt); + let reqs = cli_login::requirements( + cli_login::test_probe(&[exe, "--list"]), + "install the CLI", + &rt, + ); assert!( !reqs.is_empty(), "CLI missing must produce a CliLogin requirement" @@ -1169,7 +1155,7 @@ mod tests { let exe = present_binary_str(); let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe)); let reqs = cli_login::requirements( - &[exe, "--list"], + cli_login::test_probe(&[exe, "--list"]), "this should not show (probe exits 0)", &rt, ); @@ -1189,8 +1175,11 @@ mod tests { // → CliLogin{Available} (tooling installed, needs login). let exe = present_binary_str(); let rt = make_cli_runtime(static_commands(vec![exe]), Some(exe)); - let reqs = - cli_login::requirements(&[exe, "--buzz-probe-fail-xyz"], "run `tool login`", &rt); + let reqs = cli_login::requirements( + cli_login::test_probe(&[exe, "--buzz-probe-fail-xyz"]), + "run `tool login`", + &rt, + ); assert!( !reqs.is_empty(), "non-zero probe must produce a CliLogin requirement (logged out)" @@ -1248,7 +1237,8 @@ mod tests { context_limit_env_var: None, required_normalized_fields: &[], login_hint: None, - auth_probe_args: None, + auth_probe: None, + compatibility_probe_args: None, } } @@ -1303,7 +1293,7 @@ mod tests { Some(exe), ); let reqs = cli_login::requirements( - &[exe, "--buzz-probe-must-not-run-xyz"], + cli_login::test_probe(&[exe, "--buzz-probe-must-not-run-xyz"]), "run `codex login`", &rt, ); @@ -1343,7 +1333,7 @@ mod tests { Some(exe), ); let reqs = cli_login::requirements( - &[exe, "--buzz-probe-must-not-run-xyz"], + cli_login::test_probe(&[exe, "--buzz-probe-must-not-run-xyz"]), "run `codex login`", &rt, ); diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 4036d9f239..f53bf0c707 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -2,20 +2,29 @@ use std::path::Path; use crate::managed_agents::{ discovery::{ - classify_runtime, codex_adapter_availability, find_command, resolve_command, - KnownAcpRuntime, + availability_with_cached_compatibility, classify_runtime, codex_adapter_availability, + find_command, resolve_command, KnownAcpRuntime, RuntimeAuthProbe, }, AcpAvailabilityStatus, }; use super::{cli_probe, Requirement}; +#[cfg(test)] +pub(super) fn test_probe(args: &[&'static str]) -> RuntimeAuthProbe { + RuntimeAuthProbe { + args: Box::leak(args.to_vec().into_boxed_slice()), + usable_exit_codes: &[0], + } +} + /// Requirements for CLI-login runtimes (claude, codex). pub(super) fn requirements( - probe_args: &[&str], + probe: RuntimeAuthProbe, setup_copy: &str, runtime: &KnownAcpRuntime, ) -> Vec { + let probe_args = probe.args; let adapter_result = runtime .commands .iter() @@ -36,6 +45,7 @@ pub(super) fn requirements( } else { availability }; + let availability = availability_with_cached_compatibility(runtime, availability); match availability { AcpAvailabilityStatus::Available => { @@ -47,8 +57,16 @@ pub(super) fn requirements( )]; }; let augmented_path = cli_probe::augmented_path(); - match cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) { + match cli_probe::login_probe( + &binary_path, + probe_args, + probe.usable_exit_codes, + augmented_path.as_deref(), + ) { cli_probe::ProbeOutcome::LoggedIn => vec![], + // Authentication readiness is advisory. Operational probe + // failures must not masquerade as invalid credentials. + cli_probe::ProbeOutcome::Unknown => vec![], cli_probe::ProbeOutcome::LoggedOut => vec![missing_requirement( probe_args, setup_copy, @@ -78,3 +96,22 @@ fn missing_requirement( availability, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codex_setup_copy_does_not_mention_openai_api_key() { + let requirement = missing_requirement( + &["codex", "login", "status"], + "run `codex login`", + AcpAvailabilityStatus::Available, + ); + let Requirement::CliLogin { setup_copy, .. } = requirement else { + panic!("expected CLI login requirement"); + }; + assert!(!setup_copy.contains("OPENAI_API_KEY")); + assert!(setup_copy.contains("codex login")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs index 513da4e2a8..bcdadacd23 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_probe.rs @@ -1,7 +1,175 @@ +use std::io::Read; use std::path::Path; +use std::process::{ExitStatus, Stdio}; +use std::sync::mpsc::{sync_channel, Receiver, TryRecvError}; +use std::time::{Duration, Instant}; use crate::managed_agents::runtime::build_augmented_path; +const LOGIN_PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_PROBE_STDERR_BYTES: usize = 16 * 1024; +const OUTPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(100); + +#[derive(Clone, Copy)] +pub(crate) enum ProbeOutputStream { + Stdout, + Stderr, +} + +pub(crate) struct BoundedProbeOutput { + pub status: ExitStatus, + pub bytes: Vec, + pub truncated: bool, +} + +enum CaptureEvent { + Bytes(Vec), + Finished, + Failed, +} + +fn bounded_capture(reader: impl Read + Send + 'static, max_bytes: usize) -> Receiver { + let (sender, receiver) = sync_channel(1); + std::thread::spawn(move || { + let mut reader = reader.take((max_bytes + 1) as u64); + let mut buffer = [0_u8; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => { + let _ = sender.send(CaptureEvent::Finished); + return; + } + Ok(read) => { + if sender + .send(CaptureEvent::Bytes(buffer[..read].to_vec())) + .is_err() + { + return; + } + } + Err(_) => { + let _ = sender.send(CaptureEvent::Failed); + return; + } + } + } + }); + receiver +} + +pub(crate) fn run_bounded_probe( + binary_path: &Path, + probe_args: &[&str], + augmented_path: Option<&str>, + timeout: Duration, + stream: ProbeOutputStream, + max_bytes: usize, +) -> Result { + let mut command = std::process::Command::new(binary_path); + command + .args(&probe_args[1..]) + .stdin(Stdio::null()) + .stdout(if matches!(stream, ProbeOutputStream::Stdout) { + Stdio::piped() + } else { + Stdio::null() + }) + .stderr(if matches!(stream, ProbeOutputStream::Stderr) { + Stdio::piped() + } else { + Stdio::null() + }); + if let Some(path) = augmented_path { + command.env("PATH", path); + } + crate::util::configure_no_window(&mut command); + + let mut child = command.spawn().map_err(|_| ())?; + let reader: Box = match stream { + ProbeOutputStream::Stdout => Box::new(child.stdout.take().ok_or(())?), + ProbeOutputStream::Stderr => Box::new(child.stderr.take().ok_or(())?), + }; + let capture = bounded_capture(reader, max_bytes); + let deadline = Instant::now() + timeout; + let mut settle_deadline = None; + let mut status = None; + let mut capture_finished = false; + let mut bytes = Vec::with_capacity(max_bytes.min(4096)); + + loop { + if !capture_finished { + loop { + match capture.try_recv() { + Ok(CaptureEvent::Bytes(chunk)) => { + bytes.extend_from_slice(&chunk); + if bytes.len() > max_bytes { + let _ = child.kill(); + let status = child.wait().ok().or(status).ok_or(())?; + return Ok(BoundedProbeOutput { + status, + bytes, + truncated: true, + }); + } + } + Ok(CaptureEvent::Finished) => { + capture_finished = true; + break; + } + Ok(CaptureEvent::Failed) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(()); + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(()); + } + } + } + } + + if status.is_none() { + match child.try_wait() { + Ok(Some(exit_status)) => { + status = Some(exit_status); + settle_deadline = Some((Instant::now() + OUTPUT_SETTLE_TIMEOUT).min(deadline)); + } + Ok(None) => {} + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(()); + } + } + } + + let now = Instant::now(); + if let Some(status) = status.filter(|_| capture_finished) { + return Ok(BoundedProbeOutput { + status, + bytes, + truncated: false, + }); + } + if status.is_some_and(|_| settle_deadline.is_some_and(|settle| now >= settle)) { + return Ok(BoundedProbeOutput { + status: status.expect("status checked"), + bytes, + truncated: false, + }); + } + if now >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(()); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + /// Build the augmented PATH for CLI probes and other native child processes /// (auth commands, `buzz-acp models` discovery), including nvm's default /// Node.js bin directory so `#!/usr/bin/env node` shims (e.g. codex-acp) @@ -26,6 +194,8 @@ pub(crate) fn augmented_path() -> Option { pub(crate) enum ProbeOutcome { /// The CLI reported a successful login (exit 0). LoggedIn, + /// Buzz could not determine auth state because the probe failed operationally. + Unknown, /// The CLI exited non-zero without a config-parse signal — treat as /// "not authenticated." LoggedOut, @@ -56,27 +226,48 @@ const CONFIG_PARSE_SIGNALS: &[&str] = &["error loading configuration", "unknown pub(crate) fn login_probe( binary_path: &Path, probe_args: &[&str], + usable_exit_codes: &[i32], augmented_path: Option<&str>, ) -> ProbeOutcome { - let mut command = std::process::Command::new(binary_path); - command.args(&probe_args[1..]); - if let Some(path) = augmented_path { - command.env("PATH", path); - } - crate::util::configure_no_window(&mut command); + login_probe_with_timeout( + binary_path, + probe_args, + usable_exit_codes, + augmented_path, + LOGIN_PROBE_TIMEOUT, + ) +} - match command.output() { - Ok(o) if o.status.success() => ProbeOutcome::LoggedIn, - Ok(o) => classify_probe_output(&o.stderr, false), - Err(_) => ProbeOutcome::LoggedOut, +fn login_probe_with_timeout( + binary_path: &Path, + probe_args: &[&str], + usable_exit_codes: &[i32], + augmented_path: Option<&str>, + timeout: Duration, +) -> ProbeOutcome { + let Ok(output) = run_bounded_probe( + binary_path, + probe_args, + augmented_path, + timeout, + ProbeOutputStream::Stderr, + MAX_PROBE_STDERR_BYTES, + ) else { + return ProbeOutcome::Unknown; + }; + if output.truncated || output.status.code().is_none() { + return ProbeOutcome::Unknown; } + let usable = output + .status + .code() + .is_some_and(|code| usable_exit_codes.contains(&code)); + classify_probe_output(&output.bytes, usable) } /// Classify collected probe output into a `ProbeOutcome`. /// -/// Shared between `login_probe` (which has the full `Output`) and the -/// process-level timeout path in `probe_auth_status` (which drains stderr -/// on a background thread and collects it separately). +/// Shared by the bounded native probe and ACP authentication status handling. pub(crate) fn classify_probe_output(stderr_bytes: &[u8], exit_success: bool) -> ProbeOutcome { if exit_success { return ProbeOutcome::LoggedIn; @@ -153,6 +344,7 @@ mod tests { super::login_probe( &script_path, &["fake-codex", "login", "status"], + &[0], Some(&augmented_path), ), ProbeOutcome::LoggedIn, @@ -186,6 +378,7 @@ mod tests { let outcome = super::login_probe( &script_path, &["fake-codex-bad-config", "login", "status"], + &[0], None, ); assert!( @@ -224,6 +417,7 @@ mod tests { let outcome = super::login_probe( &script_path, &["fake-codex-logged-out", "login", "status"], + &[0], None, ); assert_eq!( @@ -233,6 +427,97 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn login_probe_accepts_runtime_declared_usable_exit_code() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let script_path = temp.path().join("expiring-auth"); + fs::write(&script_path, "#!/bin/sh\nexit 2\n").expect("write script"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).expect("chmod script"); + + assert_eq!( + super::login_probe(&script_path, &["expiring-auth"], &[0, 2], None), + ProbeOutcome::LoggedIn + ); + assert_eq!( + super::login_probe(&script_path, &["expiring-auth"], &[0], None), + ProbeOutcome::LoggedOut + ); + } + + #[cfg(unix)] + #[test] + fn login_probe_times_out() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::time::Duration; + + let temp = tempfile::tempdir().expect("temp dir"); + let script_path = temp.path().join("hung-auth"); + fs::write(&script_path, "#!/bin/sh\nwhile :; do :; done\n").expect("write script"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).expect("chmod script"); + + assert_eq!( + super::login_probe_with_timeout( + &script_path, + &["hung-auth"], + &[0], + None, + Duration::from_millis(10), + ), + ProbeOutcome::Unknown + ); + } + + #[cfg(unix)] + #[test] + fn login_probe_times_out_after_stderr_closes() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + use std::time::Duration; + + let temp = tempfile::tempdir().expect("temp dir"); + let script_path = temp.path().join("silent-hung-auth"); + fs::write(&script_path, "#!/bin/sh\nexec 2>&-\nwhile :; do :; done\n") + .expect("write script"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).expect("chmod script"); + + assert_eq!( + super::login_probe_with_timeout( + &script_path, + &["silent-hung-auth"], + &[0], + None, + Duration::from_millis(10), + ), + ProbeOutcome::Unknown + ); + } + + #[cfg(unix)] + #[test] + fn login_probe_rejects_oversized_stderr() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let script_path = temp.path().join("noisy-auth"); + fs::write( + &script_path, + "#!/bin/sh\nhead -c 20000 /dev/zero >&2\nexit 1\n", + ) + .expect("write script"); + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).expect("chmod script"); + + assert_eq!( + super::login_probe(&script_path, &["noisy-auth"], &[0], None), + ProbeOutcome::Unknown + ); + } + /// Verify that every string in CONFIG_PARSE_SIGNALS is lowercased so the /// case-insensitive match works correctly. #[test] diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 37927961ed..b26f764990 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -506,7 +506,9 @@ pub fn spawn_agent_child( })?; let effective_command = &descriptor.command; let agent_args = &descriptor.args; - + let runtime_meta = known_acp_runtime(effective_command); + let resolved_agent_command = + crate::managed_agents::verify_runtime_contract(runtime_meta, effective_command)?; let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( &log_path, @@ -540,11 +542,6 @@ pub fn spawn_agent_child( } } }; - // Resolve agent command to a full path (DMG launches have minimal PATH). - let resolved_agent_command = resolve_command(effective_command) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| effective_command.clone()); - // The caller supplies the explicit canonical pair relay. This is the only // relay this child may connect to, regardless of the record/workspace default. let effective_relay_url = runtime_key.relay_url.clone(); @@ -592,7 +589,6 @@ pub fn spawn_agent_child( } // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". - let runtime_meta = known_acp_runtime(effective_command); if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index fcd8b13fc9..165f5784dc 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -587,15 +587,16 @@ pub struct ManagedAgentLogResponse { pub enum AcpAvailabilityStatus { Available, AdapterMissing, - /// Adapter binary is present but unsupported — either the deprecated - /// package or a version below the supported floor. Reinstall required. + /// Adapter binary is present but unsupported. Reinstall required. AdapterOutdated, + /// Runtime CLI does not implement the required hosting contract. + CliOutdated, + /// The runtime contract could not be verified due to an operational probe failure. + CompatibilityUnknown, CliMissing, NotInstalled, } - /// Authentication/login status for a CLI-based ACP runtime. -/// /// Serializes as a tagged union `{ status: "...", diagnostic?: "..." }` so /// the TypeScript side can exhaustively switch on `status`. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -615,15 +616,14 @@ pub enum AuthStatus { /// Probe was not attempted (runtime unavailable or probe timed out). Unknown, } - /// Origin of an ACP runtime catalog entry. Serializes as a lowercase string /// so the TypeScript consumer can switch on it without numeric comparisons. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum HarnessSource { - /// Compiled into the app — one of the four first-class runtimes. + /// Compiled into the app as a first-class runtime. Builtin, - /// Static preset entry with bundled logo, PATH-probed, not editable/deletable. + /// Static bundled preset, PATH-probed and not editable/deletable. Preset, /// Loaded at runtime from the user's `custom_harnesses/` directory. Custom, diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index d0690cefc3..95e56b25ef 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -231,6 +231,9 @@ test("getPersonaModelOptions for buzz-agent with no provider returns default mod test("formatModelDiscoveryErrorStatus returns a non-null status for runtime unavailable errors", () => { for (const availability of [ "adapter_missing", + "adapter_outdated", + "cli_outdated", + "compatibility_unknown", "cli_missing", "not_installed", ]) { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index d51c970f29..8c2965eabb 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -425,11 +425,15 @@ 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 === "cli_outdated" + ? " (CLI outdated)" + : runtime.availability === "compatibility_unknown" + ? " (verification failed)" + : runtime.availability === "cli_missing" + ? " (CLI missing)" + : runtime.availability === "not_installed" + ? " (not installed)" + : ""; return `${runtime.label}${suffix}`; } @@ -498,9 +502,12 @@ function runtimeAvailabilitySortRank( case "not_installed": return 2; case "adapter_missing": - return 3; case "adapter_outdated": return 3; + case "cli_outdated": + return 1; + case "compatibility_unknown": + return 1; } } diff --git a/desktop/src/features/agents/ui/runtimeAvailabilityWarning.test.mjs b/desktop/src/features/agents/ui/runtimeAvailabilityWarning.test.mjs index 3506714309..7d5f9c6841 100644 --- a/desktop/src/features/agents/ui/runtimeAvailabilityWarning.test.mjs +++ b/desktop/src/features/agents/ui/runtimeAvailabilityWarning.test.mjs @@ -87,3 +87,20 @@ test("adapter-outdated warning stays hint-free reinstall copy", () => { ); assert.equal(warning, "Amp ACP adapter is outdated — reinstall to continue."); }); + +test("cli-outdated warning requests a CLI update", () => { + const warning = runtimeAvailabilityWarning( + entry({ availability: "cli_outdated", label: "Example Runtime" }), + ); + assert.equal(warning, "Example Runtime CLI is outdated — update to continue."); +}); + +test("unknown compatibility asks for another check, not an update", () => { + const warning = runtimeAvailabilityWarning( + entry({ availability: "compatibility_unknown", label: "Example Runtime" }), + ); + assert.equal( + warning, + "Example Runtime couldn't be verified. Check again before starting it.", + ); +}); diff --git a/desktop/src/features/agents/ui/runtimeAvailabilityWarning.ts b/desktop/src/features/agents/ui/runtimeAvailabilityWarning.ts index 6f6a033005..6040449cab 100644 --- a/desktop/src/features/agents/ui/runtimeAvailabilityWarning.ts +++ b/desktop/src/features/agents/ui/runtimeAvailabilityWarning.ts @@ -24,6 +24,10 @@ export function runtimeAvailabilityWarning( ); case "adapter_outdated": return `${runtime.label} ACP adapter is outdated — reinstall to continue.`; + case "cli_outdated": + return `${runtime.label} CLI is outdated — update to continue.`; + case "compatibility_unknown": + return `${runtime.label} couldn't be verified. Check again before starting it.`; default: return runtime.requiresExternalCli ? withHint(`${runtime.label} CLI is missing.`) diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 911ddaf362..9d6f83db2a 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -253,6 +253,21 @@ function RuntimeStatus({ ); } + if (runtime.availability === "compatibility_unknown") { + return ( + + ); + } + const installLabel = installError ? "RETRY INSTALL" : "INSTALL"; if (runtime.canAutoInstall) { return ( @@ -346,6 +361,27 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ); } + if (runtime.availability === "cli_outdated") { + return ( + <> +

+ CLI detected but outdated — update required. +

+

+ {runtime.installHint} +

+ + ); + } + + if (runtime.availability === "compatibility_unknown") { + return ( +

+ Runtime compatibility couldn't be verified. Check again. +

+ ); + } + if (runtime.availability === "cli_missing") { return ( <> @@ -385,6 +421,12 @@ function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string { if (runtime.availability === "adapter_outdated") { return "ACP adapter detected but outdated — reinstall required."; } + if (runtime.availability === "cli_outdated") { + return "CLI detected but outdated — update required."; + } + if (runtime.availability === "compatibility_unknown") { + return "Runtime compatibility couldn't be verified."; + } if ( runtime.availability === "cli_missing" || runtime.availability === "not_installed" diff --git a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx index 8511d26d57..1df5cac2bc 100644 --- a/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx +++ b/desktop/src/features/settings/ui/HarnessCatalogDialog.tsx @@ -1,5 +1,11 @@ import * as React from "react"; -import { ChevronRight, ExternalLink, Plus, Search } from "lucide-react"; +import { + ChevronRight, + ExternalLink, + Plus, + RefreshCw, + Search, +} from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { @@ -229,7 +235,12 @@ export function HarnessCatalogDialog({ {selectedId === CUSTOM_ENTRY_ID ? ( onOpenChange(false)} /> ) : selectedEntry ? ( - + void runtimesQuery.refetch()} + /> ) : isLoading ? (
@@ -391,7 +402,15 @@ function CatalogListItem({ ); } -function CatalogDetail({ entry }: { entry: AcpRuntimeCatalogEntry }) { +function CatalogDetail({ + checking, + entry, + onCheckAgain, +}: { + checking: boolean; + entry: AcpRuntimeCatalogEntry; + onCheckAgain: () => void; +}) { const install = useInstallAcpRuntimeMutation(); const [installError, setInstallError] = React.useState(null); const [isUpdateWarningOpen, setIsUpdateWarningOpen] = React.useState(false); @@ -432,7 +451,10 @@ function CatalogDetail({ entry }: { entry: AcpRuntimeCatalogEntry }) { // The outline docs button only shows when the entry needs no setup action // (already ready) — pair it with a hint so the muted styling reads as // "nothing to do here" rather than a broken primary button. - const isSecondaryCta = action.kind !== "install" && action.kind !== "docs"; + const isSecondaryCta = + action.kind !== "install" && + action.kind !== "retry" && + action.kind !== "docs"; const primaryCta = action.kind === "install" ? ( @@ -445,6 +467,18 @@ function CatalogDetail({ entry }: { entry: AcpRuntimeCatalogEntry }) { {install.isPending ? : null} {action.label} + ) : action.kind === "retry" ? ( + ) : docsUrl ? ( ) : null}
diff --git a/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs b/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs index 28843bdfd2..7263b65a01 100644 --- a/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs +++ b/desktop/src/features/settings/ui/harnessCatalogLogic.test.mjs @@ -65,6 +65,18 @@ describe("isYourHarnessEntry", () => { ); }); + it("excludes compatibility failures from installable rows", () => { + assert.equal( + isYourHarnessEntry( + entry({ + availability: "compatibility_unknown", + canAutoInstall: true, + }), + ), + false, + ); + }); + it("excludes presets needing manual setup", () => { assert.equal( isYourHarnessEntry( @@ -269,6 +281,14 @@ describe("entryStatusLabel", () => { entryStatusLabel(entry({ availability: "adapter_outdated" })), "Update needed", ); + assert.equal( + entryStatusLabel(entry({ availability: "cli_outdated" })), + "Update needed", + ); + assert.equal( + entryStatusLabel(entry({ availability: "compatibility_unknown" })), + "Check failed", + ); assert.equal( entryStatusLabel(entry({ availability: "cli_missing" })), "CLI needed", @@ -363,6 +383,27 @@ describe("catalogPrimaryAction", () => { ); }); + it("update label for outdated CLIs", () => { + assert.deepEqual( + catalogPrimaryAction( + entry({ availability: "cli_outdated", canAutoInstall: true }), + ), + { kind: "install", label: "Update" }, + ); + }); + + it("does not offer install for unknown compatibility", () => { + assert.deepEqual( + catalogPrimaryAction( + entry({ + availability: "compatibility_unknown", + canAutoInstall: true, + }), + ), + { kind: "retry", label: "Check again" }, + ); + }); + it("docs fallback when auto-install is unavailable", () => { assert.deepEqual( catalogPrimaryAction( diff --git a/desktop/src/features/settings/ui/harnessCatalogLogic.ts b/desktop/src/features/settings/ui/harnessCatalogLogic.ts index 2627135a77..6cba061408 100644 --- a/desktop/src/features/settings/ui/harnessCatalogLogic.ts +++ b/desktop/src/features/settings/ui/harnessCatalogLogic.ts @@ -29,6 +29,7 @@ const ROW_SORT_PRIORITY: Record = { export function isYourHarnessEntry(entry: AcpRuntimeCatalogEntry): boolean { if (entry.source === "custom") return true; if (entry.availability === "available") return true; + if (entry.availability === "compatibility_unknown") return false; return entry.canAutoInstall && !entry.nodeRequired; } @@ -142,7 +143,10 @@ export function entryStatusLabel(entry: AcpRuntimeCatalogEntry): string | null { case "adapter_missing": return "Adapter needed"; case "adapter_outdated": + case "cli_outdated": return "Update needed"; + case "compatibility_unknown": + return "Check failed"; case "cli_missing": case "not_installed": return "CLI needed"; @@ -178,6 +182,7 @@ export function adapterUpdateWarning(entry: AcpRuntimeCatalogEntry): string { export type CatalogPrimaryAction = | { kind: "install"; label: string } + | { kind: "retry"; label: string } | { kind: "docs"; label: string } | { kind: "none" }; @@ -213,10 +218,17 @@ export function catalogPrimaryAction( entry: AcpRuntimeCatalogEntry, ): CatalogPrimaryAction { if (entry.availability === "available") return { kind: "none" }; + if (entry.availability === "compatibility_unknown") { + return { kind: "retry", label: "Check again" }; + } if (entry.canAutoInstall && !entry.nodeRequired) { return { kind: "install", - label: entry.availability === "adapter_outdated" ? "Update" : "Install", + label: + entry.availability === "adapter_outdated" || + entry.availability === "cli_outdated" + ? "Update" + : "Install", }; } if (entry.installInstructionsUrl.trim().length > 0) { diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 3f07e9ad9a..9934ce3f74 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -491,10 +491,10 @@ export type AcpAvailabilityStatus = | "available" | "adapter_missing" | "adapter_outdated" + | "cli_outdated" + | "compatibility_unknown" | "cli_missing" | "not_installed"; - -/** Authentication/login status for a CLI-based ACP runtime. */ export type AuthStatus = | { status: "logged_in" } | { status: "logged_out" } diff --git a/desktop/src/shared/lib/configNudge.test.mjs b/desktop/src/shared/lib/configNudge.test.mjs index f616d78298..6c4d22e397 100644 --- a/desktop/src/shared/lib/configNudge.test.mjs +++ b/desktop/src/shared/lib/configNudge.test.mjs @@ -102,6 +102,47 @@ test("extractConfigNudge parses cli_login with adapter_outdated availability", ( ); }); +test("extractConfigNudge parses cli_login with cli_outdated availability", () => { + const payload = { + agent_name: "Example Runtime", + agent_pubkey: ATLAS_PUBKEY, + requirements: [ + { + surface: "cli_login", + probe_args: ["example-runtime", "auth", "status"], + setup_copy: "run model setup", + availability: "cli_outdated", + }, + ], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + +test("extractConfigNudge parses cli_login with unknown compatibility", () => { + const payload = { + agent_name: "Example Runtime", + agent_pubkey: ATLAS_PUBKEY, + requirements: [ + { + surface: "cli_login", + probe_args: ["example-runtime", "auth", "status"], + setup_copy: "run model setup", + availability: "compatibility_unknown", + }, + ], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + +test("extractConfigNudge parses missing_binary requirement", () => { + const payload = { + agent_name: "Custom", + agent_pubkey: ATLAS_PUBKEY, + requirements: [{ surface: "missing_binary", command: "custom-agent" }], + }; + assert.deepEqual(extractConfigNudge(withSentinel("prose", payload)), payload); +}); + test("extractConfigNudge returns null for cli_login without availability", () => { // availability is required — old-format payloads (no availability field) // must not parse so stale nudge JSON from before the Doctor-CTA update diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts index 86c0e16c13..2fd37234c4 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -33,6 +33,8 @@ export type ConfigNudgeRequirement = * - "available" → tooling installed, needs login * - "adapter_missing" → CLI installed but ACP adapter missing * - "adapter_outdated" → ACP adapter present but unsupported/outdated; reinstall required + * - "cli_outdated" → CLI present but missing the required hosting contract + * - "compatibility_unknown" → runtime contract check failed; retry required * - "cli_missing" → ACP adapter installed but CLI missing * - "not_installed" → neither adapter nor CLI found */ @@ -153,6 +155,8 @@ function isConfigNudgeRequirement(v: unknown): v is ConfigNudgeRequirement { (r.availability === "available" || r.availability === "adapter_missing" || r.availability === "adapter_outdated" || + r.availability === "cli_outdated" || + r.availability === "compatibility_unknown" || 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 cc5931e23e..2ede93db63 100644 --- a/desktop/src/shared/ui/config-nudge-attachment.tsx +++ b/desktop/src/shared/ui/config-nudge-attachment.tsx @@ -111,6 +111,10 @@ function cliLoginMessage( return `${harness} ACP adapter isn't installed`; case "adapter_outdated": return `${harness} ACP adapter is outdated — reinstall required`; + case "cli_outdated": + return `${harness} CLI is outdated — update required`; + case "compatibility_unknown": + return `${harness} runtime compatibility couldn't be verified`; case "available": // Tooling is present but authentication is needed — fall back to // the backend-supplied copy which has the exact login command. diff --git a/desktop/tests/e2e/harness-management.spec.ts b/desktop/tests/e2e/harness-management.spec.ts index c5b441ad21..6adc44adf6 100644 --- a/desktop/tests/e2e/harness-management.spec.ts +++ b/desktop/tests/e2e/harness-management.spec.ts @@ -66,6 +66,28 @@ const OPENCLAW_NOT_INSTALLED = { source: "preset", } as const; +/** Claude Code builtin with an installed but outdated ACP adapter. */ +const CLAUDE_ADAPTER_OUTDATED = { + id: "claude", + label: "Claude Code", + avatar_url: "", + availability: "adapter_outdated", + command: "claude-agent-acp", + binary_path: "/usr/local/bin/claude-agent-acp", + default_args: [], + mcp_command: null, + install_hint: + "Buzz talks to the Claude Code CLI through an ACP adapter. Install the current adapter.", + install_instructions_url: + "https://github.com/agentclientprotocol/claude-agent-acp", + can_auto_install: true, + requires_external_cli: true, + underlying_cli_path: "/usr/local/bin/claude", + node_required: false, + auth_status: { status: "logged_in" }, + source: "builtin", +} as const; + /** Cursor preset — deliberately has NO bundled logo (brand assets not * licensed for redistribution). Must render the terminal glyph, never * initials. */ @@ -269,15 +291,7 @@ test.describe("your harnesses split", () => { // same machine-wide replacement confirmation the runtime row shows — // never mutate straight from the catalog CTA. await installMockBridge(page, { - acpRuntimesCatalog: [ - HERMES_AVAILABLE, - { - ...OPENCLAW_NOT_INSTALLED, - availability: "adapter_outdated", - binary_path: "/usr/local/bin/openclaw", - can_auto_install: true, - }, - ], + acpRuntimesCatalog: [HERMES_AVAILABLE, CLAUDE_ADAPTER_OUTDATED], }); await openHarnessSettings(page); await openCatalog(page); @@ -291,19 +305,19 @@ test.describe("your harnesses split", () => { ).filter((command) => command === "install_acp_runtime").length, ); - await page.getByTestId("harness-catalog-list-item-openclaw").click(); - await expect( - page.getByTestId("harness-catalog-status-openclaw"), - ).toHaveText("Update needed"); - const updateButton = page.getByTestId("harness-catalog-install-openclaw"); + await page.getByTestId("harness-catalog-list-item-claude").click(); + await expect(page.getByTestId("harness-catalog-status-claude")).toHaveText( + "Update needed", + ); + const updateButton = page.getByTestId("harness-catalog-install-claude"); await expect(updateButton).toHaveText("Update"); // Cancel path: clicking Update opens the warning, no mutation fires. await updateButton.click(); const dialog = page.getByRole("alertdialog"); - await expect(dialog).toContainText("Update OpenClaw adapter?"); + await expect(dialog).toContainText("Update Claude Code adapter?"); await expect(dialog).toContainText( - "This replaces the machine-wide openclaw adapter.", + "This replaces the machine-wide claude-agent-acp adapter.", ); // Generic runtimes must never get Codex's package copy. await expect(dialog).not.toContainText("codex-acp"); @@ -314,7 +328,7 @@ test.describe("your harnesses split", () => { // Confirm path: exactly one install fires after confirmation. await updateButton.click(); - await page.getByTestId("harness-catalog-confirm-update-openclaw").click(); + await page.getByTestId("harness-catalog-confirm-update-claude").click(); await expect(page.getByRole("alertdialog")).toHaveCount(0); await expect.poll(installCalls).toBe(1); // No duplicate mutation after the flow settles.