Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/src-tauri/src/commands/agent_config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/commands/agent_discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ use crate::{
relay::query_relay,
};

mod auth_env;
mod post_install_verification;

fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
Expand All @@ -22,7 +22,6 @@ fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<Stri

/// Returns the adapter install commands that `install_acp_runtime_blocking` would
/// run for `runtime_id` given a resolved adapter binary at `adapter_path` (or `None` if not found).
/// Returns `None` when no install is needed; `Some(cmds)` when adapter is missing or outdated.
///
/// For the codex **outdated** case, returns a two-step reinstall: uninstall `@zed-industries/codex-acp`
/// then install `@agentclientprotocol/codex-acp` (npm ≥7 refuses to overwrite a bin from another pkg).
Expand Down Expand Up @@ -69,7 +68,8 @@ pub async fn discover_acp_providers(
.app_data_dir()
.ok()
.map(|d| d.join("custom_harnesses"));
crate::managed_agents::discover_acp_runtimes_from(custom_dir.as_deref())
let auth_envs = auth_env::load(&app);
crate::managed_agents::discover_acp_runtimes_from(custom_dir.as_deref(), &auth_envs)
})
.await
.map_err(|e| format!("spawn_blocking failed: {e}"))
Expand Down
120 changes: 120 additions & 0 deletions desktop/src-tauri/src/commands/agent_discovery/auth_env.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
use std::collections::BTreeMap;

use tauri::AppHandle;

use crate::managed_agents::{
baked_build_env, known_acp_runtime, load_global_agent_config, load_managed_agent_configs,
load_personas, merged_user_env, record_agent_command, resolve_effective_agent_env,
AgentDefinition, GlobalAgentConfig, ManagedAgentRecord,
};

/// Effective child environments that can supply pre-probe auth evidence.
///
/// Runtime discovery is global rather than agent-specific, so a runtime is
/// authenticated when the desktop process or any configured child environment
/// can authenticate it. Readiness still evaluates one exact child environment.
pub(super) fn load(app: &AppHandle) -> Vec<BTreeMap<String, String>> {
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<BTreeMap<String, String>> {
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<String, String>) -> 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(""));
}
}
186 changes: 179 additions & 7 deletions desktop/src-tauri/src/managed_agents/config_bridge/codex.rs
Original file line number Diff line number Diff line change
@@ -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<RuntimeFileConfig> {
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.<id>] 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<String, String>) -> 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<String, String>, 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<String, String>,
env_key: &str,
process_value: Option<std::ffi::OsString>,
) -> bool {
match effective_env.get(env_key) {
Some(value) => !value.is_empty(),
None => process_value.is_some_and(|value| !value.is_empty()),
Comment on lines +33 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor case-insensitive Windows environment keys

When Desktop runs on Windows and a saved environment key uses different casing from Codex's env_key, this exact BTreeMap::get diverges from the case-insensitive environment seen by the child process. For example, env_key = "CUSTOM_API_KEY" with a saved custom_api_key=secret works in Codex but is reported as logged out; conversely, a differently cased empty override can fail to mask an inherited credential and incorrectly report readiness. The new CODEX_HOME and static Claude-key lookups have the same issue, so these effective-environment lookups need Windows-aware key matching while preserving override precedence.

Useful? React with 👍 / 👎.

}
}

fn read_active_provider_env_key(effective_env: &BTreeMap<String, String>) -> Option<String> {
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<String> {
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<RuntimeFileConfig> {
let table: toml::Table = toml_str.parse().ok()?;

Expand Down Expand Up @@ -122,12 +176,28 @@ fn toml_string(table: &toml::Table, key: &str) -> Option<String> {
.map(str::to_string)
}

pub(crate) fn codex_config_path() -> Option<std::path::PathBuf> {
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<String, String>,
) -> Option<std::path::PathBuf> {
codex_config_path_from(
effective_env,
std::env::var_os("CODEX_HOME"),
dirs::home_dir(),
)
}

fn codex_config_path_from(
effective_env: &BTreeMap<String, String>,
process_codex_home: Option<std::ffi::OsString>,
default_home: Option<std::path::PathBuf>,
) -> Option<std::path::PathBuf> {
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)]
Expand Down Expand Up @@ -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""#;
Expand Down
8 changes: 8 additions & 0 deletions desktop/src-tauri/src/managed_agents/config_bridge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,11 @@ pub(crate) use types::*;
pub(crate) fn read_goose_file_config() -> Option<RuntimeFileConfig> {
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<String, String>,
) -> bool {
codex::env_key_auth_satisfied(effective_env)
}
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,8 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option<String>
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,
}
}
Expand Down
Loading
Loading