diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index b63370b95f8..c4b4dca6cfe 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -61,6 +61,7 @@ fn goose_runtime() -> &'static KnownAcpRuntime { required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + auth_evidence: crate::managed_agents::AuthEvidenceStrategy::None, } } diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9609db5f2df..74ff309ae18 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -11,8 +11,8 @@ use crate::{ relay::query_relay, }; +mod auth_env; mod post_install_verification; - fn active_installs() -> &'static std::sync::Mutex> { use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; @@ -22,7 +22,6 @@ fn active_installs() -> &'static std::sync::Mutex Vec> { + let personas = load_personas(app).unwrap_or_else(|error| { + tracing::warn!(%error, "runtime auth discovery could not load personas"); + vec![] + }); + let records = load_managed_agent_configs(app).unwrap_or_else(|error| { + tracing::warn!(%error, "runtime auth discovery could not load agent configs"); + vec![] + }); + let global = load_global_agent_config(app).unwrap_or_else(|error| { + tracing::warn!(%error, "runtime auth discovery could not load global config"); + GlobalAgentConfig::default() + }); + configured(&personas, &records, &global) +} + +fn configured( + personas: &[AgentDefinition], + records: &[ManagedAgentRecord], + global: &GlobalAgentConfig, +) -> Vec> { + let mut base = baked_build_env(); + base.extend(merged_user_env(&BTreeMap::new(), &global.env_vars)); + + let persona_envs = personas.iter().cloned().map(|persona| { + let record = persona.into_agent_record(); + let command = record_agent_command(&record, &[]); + resolve_effective_agent_env(&record, &[], known_acp_runtime(&command), global).env + }); + let agent_envs = records.iter().map(|record| { + let command = record_agent_command(record, personas); + resolve_effective_agent_env(record, personas, known_acp_runtime(&command), global).env + }); + + std::iter::once(base) + .chain(persona_envs) + .chain(agent_envs) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn persona(env_vars: BTreeMap) -> AgentDefinition { + AgentDefinition { + id: "persona".into(), + display_name: "Persona".into(), + avatar_url: None, + system_prompt: "Help".into(), + runtime: Some("codex".into()), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars, + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + #[test] + fn collects_global_persona_and_agent_env_with_last_wins_precedence() { + let global = GlobalAgentConfig { + env_vars: BTreeMap::from([ + ("GLOBAL_KEY".into(), "global".into()), + ("SHARED_KEY".into(), "global".into()), + ]), + ..Default::default() + }; + let persona = persona(BTreeMap::from([ + ("PERSONA_KEY".into(), "persona".into()), + ("SHARED_KEY".into(), "persona".into()), + ])); + let mut agent = persona.clone().into_agent_record(); + agent.persona_id = Some(persona.id.clone()); + agent.env_vars = BTreeMap::from([ + ("AGENT_KEY".into(), "agent".into()), + ("SHARED_KEY".into(), String::new()), + ]); + + let envs = configured(std::slice::from_ref(&persona), &[agent], &global); + + assert!(envs + .iter() + .any(|env| env.get("GLOBAL_KEY") == Some(&"global".into()))); + assert!(envs + .iter() + .any(|env| env.get("PERSONA_KEY") == Some(&"persona".into()))); + let agent_env = envs.last().expect("agent environment"); + assert_eq!( + agent_env.get("AGENT_KEY").map(String::as_str), + Some("agent") + ); + assert_eq!(agent_env.get("SHARED_KEY").map(String::as_str), Some("")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs index c7c7135ccb4..7327e727948 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/codex.rs @@ -1,12 +1,66 @@ use super::types::{ExtensionEntry, RuntimeFileConfig}; +use std::collections::BTreeMap; /// Read Codex config from `~/.codex/config.toml` (or `$CODEX_HOME/config.toml`). pub(super) fn read_config_file() -> Option { - let path = codex_config_path()?; + let path = codex_config_path(&BTreeMap::new())?; let raw = std::fs::read_to_string(path).ok()?; parse_codex_config(&raw) } +/// Return whether the active custom Codex provider has a usable env-backed +/// credential. Codex accepts provider API keys through +/// `[model_providers.] env_key`, but `codex login status` only checks its +/// persisted credential store. Buzz must account for both authentication +/// paths before deciding that the runtime is logged out. +pub(crate) fn env_key_auth_satisfied(effective_env: &BTreeMap) -> bool { + let Some(env_key) = read_active_provider_env_key(effective_env) else { + return false; + }; + + env_key_value_is_set(effective_env, &env_key) +} + +fn env_key_value_is_set(effective_env: &BTreeMap, env_key: &str) -> bool { + env_key_value_is_set_from(effective_env, env_key, std::env::var_os(env_key)) +} + +fn env_key_value_is_set_from( + effective_env: &BTreeMap, + env_key: &str, + process_value: Option, +) -> bool { + match effective_env.get(env_key) { + Some(value) => !value.is_empty(), + None => process_value.is_some_and(|value| !value.is_empty()), + } +} + +fn read_active_provider_env_key(effective_env: &BTreeMap) -> Option { + let path = codex_config_path(effective_env)?; + let raw = std::fs::read_to_string(path).ok()?; + parse_active_provider_env_key(&raw) +} + +fn parse_active_provider_env_key(toml_str: &str) -> Option { + let table: toml::Table = toml_str.parse().ok()?; + let provider = toml_string(&table, "model_provider")?; + let env_key = table + .get("model_providers")? + .as_table()? + .get(&provider)? + .as_table()? + .get("env_key")? + .as_str()? + .trim(); + + if crate::managed_agents::env_vars::is_well_formed_env_key(env_key) { + Some(env_key.to_string()) + } else { + None + } +} + fn parse_codex_config(toml_str: &str) -> Option { let table: toml::Table = toml_str.parse().ok()?; @@ -122,12 +176,28 @@ fn toml_string(table: &toml::Table, key: &str) -> Option { .map(str::to_string) } -pub(crate) fn codex_config_path() -> Option { - if let Ok(home) = std::env::var("CODEX_HOME") { - return Some(std::path::PathBuf::from(home).join("config.toml")); - } - let home = dirs::home_dir()?; - Some(home.join(".codex").join("config.toml")) +pub(crate) fn codex_config_path( + effective_env: &BTreeMap, +) -> Option { + codex_config_path_from( + effective_env, + std::env::var_os("CODEX_HOME"), + dirs::home_dir(), + ) +} + +fn codex_config_path_from( + effective_env: &BTreeMap, + process_codex_home: Option, + default_home: Option, +) -> Option { + let root = match effective_env.get("CODEX_HOME") { + Some(home) => std::path::PathBuf::from(home), + None => process_codex_home + .map(std::path::PathBuf::from) + .or_else(|| default_home.map(|home| home.join(".codex")))?, + }; + Some(root.join("config.toml")) } #[cfg(test)] @@ -180,6 +250,108 @@ base_url = "http://localhost:8080" assert!(cfg.extra.contains_key("model_providers.custom-provider")); } + #[test] + fn parses_active_custom_provider_env_key() { + let toml = r#" +model_provider = "custom-provider" + +[model_providers.custom-provider] +env_key = "CUSTOM_API_KEY" +"#; + + assert_eq!( + parse_active_provider_env_key(toml).as_deref(), + Some("CUSTOM_API_KEY") + ); + } + + #[test] + fn ignores_env_key_for_inactive_provider() { + let toml = r#" +model_provider = "active-provider" + +[model_providers.inactive-provider] +env_key = "INACTIVE_API_KEY" +"#; + + assert_eq!(parse_active_provider_env_key(toml), None); + } + + #[test] + fn rejects_malformed_provider_env_key() { + let toml = r#" +model_provider = "custom-provider" + +[model_providers.custom-provider] +env_key = "CUSTOM_API_KEY=secret" +"#; + + assert_eq!(parse_active_provider_env_key(toml), None); + } + + #[test] + fn accepts_non_empty_effective_env_credential() { + let env = BTreeMap::from([("BUZZ_TEST_CODEX_API_KEY".to_string(), "secret".to_string())]); + + assert!(env_key_value_is_set(&env, "BUZZ_TEST_CODEX_API_KEY")); + } + + #[test] + fn rejects_empty_effective_env_credential() { + let env = BTreeMap::from([("BUZZ_TEST_CODEX_API_KEY".to_string(), String::new())]); + + assert!(!env_key_value_is_set_from( + &env, + "BUZZ_TEST_CODEX_API_KEY", + Some("parent-secret".into()) + )); + } + + #[test] + fn effective_codex_home_wins_over_process_home() { + let env = BTreeMap::from([("CODEX_HOME".to_string(), "/child/codex".to_string())]); + + assert_eq!( + codex_config_path_from(&env, Some("/desktop/codex".into()), Some("/user".into())), + Some(std::path::PathBuf::from("/child/codex/config.toml")) + ); + } + + #[test] + fn process_codex_home_is_used_when_effective_env_omits_it() { + assert_eq!( + codex_config_path_from( + &BTreeMap::new(), + Some("/desktop/codex".into()), + Some("/user".into()) + ), + Some(std::path::PathBuf::from("/desktop/codex/config.toml")) + ); + } + + #[test] + fn env_auth_reads_config_and_credential_from_effective_env() { + let codex_home = tempfile::tempdir().expect("temp CODEX_HOME"); + std::fs::write( + codex_home.path().join("config.toml"), + r#"model_provider = "custom" + +[model_providers.custom] +env_key = "CUSTOM_API_KEY" +"#, + ) + .expect("write config"); + let env = BTreeMap::from([ + ( + "CODEX_HOME".to_string(), + codex_home.path().to_string_lossy().into_owned(), + ), + ("CUSTOM_API_KEY".to_string(), "secret".to_string()), + ]); + + assert!(env_key_auth_satisfied(&env)); + } + #[test] fn approval_only_mode() { let toml = r#"approval_policy = "on-failure""#; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72f..20c9564368e 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -16,3 +16,11 @@ pub(crate) use types::*; pub(crate) fn read_goose_file_config() -> Option { goose::read_config_file() } + +/// Whether the active custom Codex provider is authenticated by the env key +/// declared in its config. +pub(crate) fn codex_env_key_auth_satisfied( + effective_env: &std::collections::BTreeMap, +) -> bool { + codex::env_key_auth_satisfied(effective_env) +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index c51f325cf3b..8a1c42b5eb5 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -198,9 +198,8 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option super::goose::goose_config_path().map(|path| path.to_string_lossy().into_owned()) } "claude" => Some(resolve_tilde("~/.claude.json")), - "codex" => { - super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) - } + "codex" => super::codex::codex_config_path(&Default::default()) + .map(|path| path.to_string_lossy().into_owned()), _ => None, } } 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 62caffeb2e4..44d47469e65 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 @@ -60,6 +60,7 @@ fn test_runtime() -> &'static KnownAcpRuntime { required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + auth_evidence: crate::managed_agents::AuthEvidenceStrategy::None, } } @@ -649,6 +650,7 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + auth_evidence: crate::managed_agents::AuthEvidenceStrategy::None, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index bc0e3a6cdae..7a0ff5ec3e3 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,6 +10,7 @@ use crate::managed_agents::{ HarnessSource, }; mod presets; +pub(crate) mod runtime_auth; mod runtime_metadata; #[macro_use] mod windows_install; @@ -18,8 +19,7 @@ pub(crate) use presets::{ preset_harness_ids, }; use presets::{preset_catalog_entry, PRESET_HARNESSES}; -pub(crate) use runtime_metadata::KnownAcpRuntime; - +pub(crate) use runtime_metadata::{AuthEvidenceStrategy, KnownAcpRuntime}; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; @@ -110,6 +110,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + auth_evidence: AuthEvidenceStrategy::None, }, KnownAcpRuntime { id: "claude", @@ -143,6 +144,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ required_normalized_fields: &[], login_hint: Some("Run the Claude CLI to complete authentication."), auth_probe_args: Some(&["claude", "auth", "status"]), + auth_evidence: AuthEvidenceStrategy::StaticEnvKeys(&["CLAUDE_CODE_OAUTH_TOKEN"]), }, KnownAcpRuntime { id: "codex", @@ -175,8 +177,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_rounds_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"]), + auth_evidence: AuthEvidenceStrategy::CodexProviderEnvKey, }, KnownAcpRuntime { id: "buzz-agent", @@ -210,6 +212,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + auth_evidence: AuthEvidenceStrategy::None, }, ]; @@ -1295,7 +1298,10 @@ struct PartialEntry { entry: AcpRuntimeCatalogEntry, } -fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntry { +fn discover_acp_runtime_phase1( + runtime: &'static KnownAcpRuntime, + auth_envs: &[std::collections::BTreeMap], +) -> PartialEntry { let adapter_result = runtime .commands .iter() @@ -1382,7 +1388,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr id: runtime.id.to_string(), label: runtime.label.to_string(), avatar_url: runtime.avatar_url.to_string(), - availability, + availability: availability.clone(), command, binary_path, default_args, @@ -1399,8 +1405,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr requires_external_cli: runtime.underlying_cli.is_some(), underlying_cli_path, node_required, - // Filled in by the auth-probe phase in full catalog discovery. - auth_status: AuthStatus::Unknown, + auth_status: runtime_auth::initial_status(runtime, &availability, auth_envs), login_hint: None, source: HarnessSource::Builtin, definition_env: Default::default(), @@ -1415,34 +1420,29 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr /// resolves, so it should not pay the cost of authenticating every catalog entry. pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option { known_acp_runtime_exact(runtime_id) - .map(discover_acp_runtime_phase1) + .map(|runtime| discover_acp_runtime_phase1(runtime, &[])) .map(|partial| partial.entry.availability) } -/// Discover all ACP runtimes, optionally merging user-defined custom harnesses -/// from `custom_harnesses_dir`. -/// -/// This is the primary entry point used by the Tauri command layer. It: -/// 1. Builds entries for all compiled-in (`Builtin`) runtimes. -/// 2. Runs auth probes in parallel. -/// 3. Inserts static `Preset` entries (PATH-probed, `source: Preset`). -/// 4. If `custom_harnesses_dir` is `Some`, loads `*.json` files from that -/// directory and appends `Custom` entries — no auth probe, command resolved -/// via PATH, availability is `Available` or `NotInstalled`. +/// Discover all ACP runtimes, optionally merging custom harnesses from disk. +/// This is the primary entry point used by the Tauri command layer. It builds +/// builtin entries, settles auth evidence/probes, inserts static presets, then +/// loads `*.json` files when `custom_harnesses_dir` is `Some` and +/// appends `Custom` entries without auth probes. /// /// The custom dir is re-scanned on every call (goose `refresh_custom_providers` /// pattern) — no caching, no restart needed to pick up new files. -/// /// After building the catalog, updates the loaded-harness registry so spawn /// and readiness paths can resolve preset/custom harness commands without /// re-running discovery. pub fn discover_acp_runtimes_from( custom_harnesses_dir: Option<&Path>, + auth_envs: &[std::collections::BTreeMap], ) -> Vec { // Phase 1: build all builtin entries (fast — no probes yet). let mut partials: Vec = KNOWN_ACP_RUNTIMES .iter() - .map(discover_acp_runtime_phase1) + .map(|runtime| discover_acp_runtime_phase1(runtime, auth_envs)) .collect(); // Phase 2: run auth probes in parallel for entries that need them. @@ -1451,7 +1451,7 @@ pub fn discover_acp_runtimes_from( .iter() .enumerate() .filter_map(|(idx, partial)| { - if partial.entry.availability != AcpAvailabilityStatus::Available { + if !runtime_auth::needs_probe(&partial.entry) { return None; } let probe_args = partial.runtime.auth_probe_args?; diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index d86e5f33f05..7087f76139f 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -336,7 +336,7 @@ mod tests { let _path_guard = crate::managed_agents::lock_path_mutex(); let _registry_guard = registry_test_lock(); - let entry = super::super::discover_acp_runtimes_from(None) + let entry = super::super::discover_acp_runtimes_from(None, &[]) .into_iter() .find(|entry| entry.id == "devin") .expect("Devin preset should appear in the runtime catalog"); diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_auth.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_auth.rs new file mode 100644 index 00000000000..75cefd73bd3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_auth.rs @@ -0,0 +1,107 @@ +use std::collections::BTreeMap; + +use crate::managed_agents::{ + config_bridge::codex_env_key_auth_satisfied, AcpAvailabilityStatus, AcpRuntimeCatalogEntry, + AuthStatus, +}; + +use super::{AuthEvidenceStrategy, KnownAcpRuntime}; + +/// Return the auth state that can be established without invoking a CLI. +pub(super) fn initial_status( + runtime: &KnownAcpRuntime, + availability: &AcpAvailabilityStatus, + effective_envs: &[BTreeMap], +) -> AuthStatus { + if *availability == AcpAvailabilityStatus::Available + && auth_evidence_satisfied(runtime.auth_evidence, effective_envs) + { + AuthStatus::LoggedIn + } else { + AuthStatus::Unknown + } +} + +pub(crate) fn auth_evidence_satisfied( + strategy: AuthEvidenceStrategy, + effective_envs: &[BTreeMap], +) -> bool { + if effective_envs.is_empty() { + auth_evidence_satisfied_for_env(strategy, &BTreeMap::new()) + } else { + effective_envs + .iter() + .any(|env| auth_evidence_satisfied_for_env(strategy, env)) + } +} + +fn auth_evidence_satisfied_for_env( + strategy: AuthEvidenceStrategy, + effective_env: &BTreeMap, +) -> bool { + match strategy { + AuthEvidenceStrategy::None => false, + AuthEvidenceStrategy::StaticEnvKeys(keys) => keys + .iter() + .any(|key| env_value_is_set_from(effective_env, key, std::env::var_os(key))), + AuthEvidenceStrategy::CodexProviderEnvKey => codex_env_key_auth_satisfied(effective_env), + } +} + +fn env_value_is_set_from( + effective_env: &BTreeMap, + key: &str, + process_value: Option, +) -> bool { + match effective_env.get(key) { + Some(value) => !value.trim().is_empty(), + None => process_value.is_some_and(|value| !value.to_string_lossy().trim().is_empty()), + } +} + +pub(super) fn needs_probe(entry: &AcpRuntimeCatalogEntry) -> bool { + needs_probe_status(&entry.availability, &entry.auth_status) +} + +fn needs_probe_status(availability: &AcpAvailabilityStatus, auth_status: &AuthStatus) -> bool { + *availability == AcpAvailabilityStatus::Available && *auth_status == AuthStatus::Unknown +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unavailable_runtime_never_needs_auth_probe() { + assert!(!needs_probe_status( + &AcpAvailabilityStatus::NotInstalled, + &AuthStatus::Unknown + )); + } + + #[test] + fn preauthenticated_runtime_does_not_need_auth_probe() { + assert!(!needs_probe_status( + &AcpAvailabilityStatus::Available, + &AuthStatus::LoggedIn + )); + } + + #[test] + fn static_env_key_uses_last_wins_semantics() { + let env = BTreeMap::from([("TOKEN".to_string(), String::new())]); + + assert!(!env_value_is_set_from( + &env, + "TOKEN", + Some("parent-secret".into()) + )); + } + + #[test] + fn static_env_key_rejects_whitespace_only_values() { + let env = BTreeMap::from([("TOKEN".to_string(), " \t".to_string())]); + + assert!(!env_value_is_set_from(&env, "TOKEN", None)); + } +} 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 34edecdcd9c..d8cdb019213 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,11 @@ +/// Authentication evidence that can settle a runtime before its CLI probe. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AuthEvidenceStrategy { + None, + StaticEnvKeys(&'static [&'static str]), + CodexProviderEnvKey, +} + /// Static capabilities and installation metadata for a known ACP runtime. pub(crate) struct KnownAcpRuntime { pub id: &'static str, @@ -64,6 +72,9 @@ pub(crate) struct KnownAcpRuntime { /// 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]>, + /// Evidence read from the environment of the process Buzz will spawn. + /// When satisfied, it takes precedence over the sibling CLI probe. + pub auth_evidence: AuthEvidenceStrategy, } 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 6fe6a77521b..fb32c7de3df 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -1686,7 +1686,7 @@ fn custom_catalog_entry_carries_definition_env_for_edit_roundtrip() { ) .unwrap(); - let entries = discover_acp_runtimes_from(Some(dir.path())); + let entries = discover_acp_runtimes_from(Some(dir.path()), &[]); let entry = entries .iter() .find(|e| e.id == "env-harness") @@ -1716,7 +1716,7 @@ fn builtin_catalog_entry_has_empty_definition_env() { // publishes to the global registry. let _path_guard = crate::managed_agents::lock_path_mutex(); let _lock = registry_test_lock(); - let entries = discover_acp_runtimes_from(None); + let entries = discover_acp_runtimes_from(None, &[]); // Find any builtin entry (e.g. "goose" or "claude"). let builtin = entries .iter() @@ -1797,7 +1797,7 @@ fn discovery_publish_path_survives_mid_flight_save() { assert!(lookup_loaded_harness_by_id("mid-flight-save").is_some()); })); - let _entries = discover_acp_runtimes_from(Some(dir.path())); + let _entries = discover_acp_runtimes_from(Some(dir.path()), &[]); assert!( lookup_loaded_harness_by_id("mid-flight-save").is_some(), @@ -1830,7 +1830,7 @@ fn discovery_publish_path_drops_mid_flight_delete() { assert!(lookup_loaded_harness_by_id("mid-flight-delete").is_none()); })); - let _entries = discover_acp_runtimes_from(Some(dir.path())); + let _entries = discover_acp_runtimes_from(Some(dir.path()), &[]); assert!( lookup_loaded_harness_by_id("mid-flight-delete").is_none(), diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c072448ff13..59416c144e0 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -391,8 +391,8 @@ impl AgentReadiness { /// - `databricks` / `databricks_v2` → `DATABRICKS_HOST` (token optional — /// OAuth PKCE is the fallback) /// * **claude**: a successful `claude auth status` probe. -/// * **codex**: a successful `codex login status` probe (checks the codex -/// credential store — NOT `OPENAI_API_KEY`). +/// * **codex**: either a successful `codex login status` probe or a non-empty +/// credential named by the active custom provider's `env_key`. /// * **unknown / custom command**: always `Ready` (no requirements known). /// /// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` in @@ -435,12 +435,7 @@ 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), + "claude" | "codex" => cli_login::requirements_for_runtime(rt, &effective.env), _ => vec![], } } @@ -1055,11 +1050,11 @@ mod tests { required_normalized_fields: &[], login_hint: None, auth_probe_args: None, + auth_evidence: crate::managed_agents::AuthEvidenceStrategy::None, } } - /// Returns the absolute path of the currently-running test binary as a `&'static str`. - /// Host-portable stand-in for a "present" binary: absolute path so `find_command` resolves + /// Host-portable absolute path that `find_command` resolves as present. /// it via `path.exists()`. Leaked allocation is intentional — process exits after tests. fn present_binary_str() -> &'static str { let path = std::env::current_exe().expect("current_exe must be available in tests"); @@ -1247,6 +1242,7 @@ mod tests { required_normalized_fields: &[], login_hint: None, auth_probe_args: None, + auth_evidence: crate::managed_agents::AuthEvidenceStrategy::None, } } 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 4036d9f2393..e86677fd70a 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::path::Path; use crate::managed_agents::{ @@ -11,10 +12,57 @@ use crate::managed_agents::{ use super::{cli_probe, Requirement}; /// Requirements for CLI-login runtimes (claude, codex). +#[cfg(test)] pub(super) fn requirements( probe_args: &[&str], setup_copy: &str, runtime: &KnownAcpRuntime, +) -> Vec { + requirements_with_env_auth(probe_args, setup_copy, runtime, false) +} + +pub(super) fn requirements_with_effective_env( + probe_args: &[&str], + setup_copy: &str, + runtime: &KnownAcpRuntime, + effective_env: &BTreeMap, +) -> Vec { + let env_auth_satisfied = + crate::managed_agents::discovery::runtime_auth::auth_evidence_satisfied( + runtime.auth_evidence, + std::slice::from_ref(effective_env), + ); + requirements_with_env_auth(probe_args, setup_copy, runtime, env_auth_satisfied) +} + +pub(super) fn requirements_for_runtime( + runtime: &KnownAcpRuntime, + effective_env: &BTreeMap, +) -> Vec { + match runtime.id { + "claude" => requirements_with_effective_env( + &["claude", "auth", "status"], + "complete Claude Code authentication by running the Claude CLI", + runtime, + effective_env, + ), + "codex" => requirements_with_effective_env( + &["codex", "login", "status"], + "run `codex login`", + runtime, + effective_env, + ), + _ => vec![], + } +} + +/// Requirements for a CLI-login runtime that may also be authenticated by an +/// environment credential outside the CLI's persisted login store. +pub(super) fn requirements_with_env_auth( + probe_args: &[&str], + setup_copy: &str, + runtime: &KnownAcpRuntime, + env_auth_satisfied: bool, ) -> Vec { let adapter_result = runtime .commands @@ -39,6 +87,9 @@ pub(super) fn requirements( match availability { AcpAvailabilityStatus::Available => { + if env_auth_satisfied { + return vec![]; + } let Some(binary_path) = resolve_command(probe_args[0]) else { return vec![missing_requirement( probe_args, @@ -78,3 +129,116 @@ fn missing_requirement( availability, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn present_binary_str() -> &'static str { + Box::leak( + std::env::current_exe() + .expect("test executable path") + .to_string_lossy() + .into_owned() + .into_boxed_str(), + ) + } + + fn static_commands(commands: Vec<&'static str>) -> &'static [&'static str] { + Box::leak(commands.into_boxed_slice()) + } + + fn make_runtime( + commands: &'static [&'static str], + underlying_cli: Option<&'static str>, + ) -> KnownAcpRuntime { + KnownAcpRuntime { + id: "test-cli-runtime", + label: "Test CLI", + commands, + aliases: &[], + avatar_url: "", + mcp_command: None, + mcp_hooks: false, + underlying_cli, + cli_install_commands: &[], + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "", + adapter_install_instructions_url: "", + cli_install_hint: "", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + default_env: &[], + config_file_path: None, + config_file_format: None, + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + max_rounds_env_var: None, + required_normalized_fields: &[], + login_hint: None, + auth_probe_args: None, + auth_evidence: crate::managed_agents::AuthEvidenceStrategy::None, + } + } + + #[test] + fn satisfied_env_auth_bypasses_login_probe() { + let exe = present_binary_str(); + let runtime = make_runtime(static_commands(vec![exe]), Some(exe)); + let requirements = requirements_with_env_auth( + &[exe, "--buzz-probe-must-not-run"], + "this should not show", + &runtime, + true, + ); + + assert!(requirements.is_empty()); + } + + #[test] + fn static_runtime_env_auth_bypasses_login_probe() { + let exe = present_binary_str(); + let mut runtime = make_runtime(static_commands(vec![exe]), Some(exe)); + runtime.auth_evidence = + crate::managed_agents::AuthEvidenceStrategy::StaticEnvKeys(&["TOKEN"]); + let env = BTreeMap::from([("TOKEN".to_string(), "secret".to_string())]); + + let requirements = requirements_with_effective_env( + &[exe, "--buzz-probe-must-not-run"], + "this should not show", + &runtime, + &env, + ); + + assert!(requirements.is_empty()); + } + + #[test] + fn env_auth_does_not_hide_missing_tooling() { + let runtime = make_runtime( + &["__buzz_nonexistent_adapter_env_auth__"], + Some("__buzz_nonexistent_cli_env_auth__"), + ); + let requirements = requirements_with_env_auth( + &["__buzz_nonexistent_cli_env_auth__", "status"], + "install the tool", + &runtime, + true, + ); + + assert!(matches!( + requirements.as_slice(), + [Requirement::CliLogin { + availability: AcpAvailabilityStatus::NotInstalled, + .. + }] + )); + } +} diff --git a/desktop/src-tauri/src/managed_agents/storage.rs b/desktop/src-tauri/src/managed_agents/storage.rs index 652bb9b9ea8..34cf7da57ec 100644 --- a/desktop/src-tauri/src/managed_agents/storage.rs +++ b/desktop/src-tauri/src/managed_agents/storage.rs @@ -260,9 +260,17 @@ fn load_agent_store(app: &AppHandle) -> Result, String> /// folded into the same store) are filtered out so every pre-fold call site /// keeps seeing exactly the records it always did. pub fn load_managed_agents(app: &AppHandle) -> Result, String> { + let mut records = load_managed_agent_configs(app)?; + hydrate_keys(&mut records); + Ok(records) +} + +/// Load agent instance configuration without accessing private keys. +pub(crate) fn load_managed_agent_configs( + app: &AppHandle, +) -> Result, String> { let mut records = load_agent_store(app)?; records.retain(|record| !record.pubkey.is_empty()); - hydrate_keys(&mut records); Ok(records) }