diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index 05979e76cb..af3850503c 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -148,6 +148,48 @@ mod tests { ); } + /// The regression that routed an OpenAI agent's traffic to Anthropic. + /// + /// Desktop inherits the developer's login shell, so `GOOSE_PROVIDER` from + /// an installed goose reached the agent subprocess and beat the record's + /// configured provider. Spawn must clear the derived keys before writing + /// the ones it derives, so the record is the only source. + /// + /// Uses `env` as the child rather than reaching into `spawn_agent_child` + /// (which needs an `AppHandle`): the assertion is about what survives to + /// the child's environment, which is exactly what `env` prints. + #[test] + fn spawn_clears_inherited_provider_and_model_before_deriving_its_own() { + let mut cmd = std::process::Command::new("env"); + cmd.env_clear(); + // Stand in for the inherited login-shell environment. + for key in super::super::env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS { + cmd.env(key, "inherited-from-login-shell"); + } + + // The clearing step under test, mirrored from `spawn_agent_child`. + for key in super::super::env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS { + cmd.env_remove(key); + } + // Then the record's own values, as `runtime_metadata_env_vars` writes. + cmd.env("BUZZ_AGENT_PROVIDER", "openai"); + cmd.env("BUZZ_AGENT_MODEL", "gpt-5.6-sol"); + + let output = cmd.output().expect("env should run"); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + !stdout.contains("inherited-from-login-shell"), + "no inherited provider/model value may reach the agent: {stdout}" + ); + assert!(stdout.contains("BUZZ_AGENT_PROVIDER=openai")); + assert!(stdout.contains("BUZZ_AGENT_MODEL=gpt-5.6-sol")); + // An agent with no configured provider must get none at all, rather + // than silently inheriting the developer's. + assert!(!stdout.contains("GOOSE_PROVIDER=")); + assert!(!stdout.contains("GOOSE_MODEL=")); + } + #[test] fn parse_agent_env_lines_splits_on_first_equals() { // Value may itself contain `=` — only the first `=` is the separator. diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 51f03c1077..66180e01cb 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -6,10 +6,10 @@ use super::agent_env::build_buzz_agent_provider_defaults; use crate::{ managed_agents::{ - append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, - missing_command_message, normalize_agent_args, open_log_file, resolve_command, - spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, - ManagedAgentRuntimeKey, ManagedAgentSummary, + append_log_marker, env_vars::DERIVED_PROVIDER_MODEL_ENV_KEYS, known_acp_runtime, + login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, + open_log_file, resolve_command, spawn_key_refusal, KnownAcpRuntime, + ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, }, util::now_iso, }; @@ -740,6 +740,23 @@ pub fn spawn_agent_child( resolve_session_title(record.display_name.as_deref(), &record.name), ); build_buzz_agent_provider_defaults(&mut command); + // Clear inherited provider/model keys before deriving this agent's own. + // + // `DERIVED_PROVIDER_MODEL_ENV_KEYS` are refused at save time so a stale + // override cannot shadow the record's structured provider/model fields. + // That guard only covers env we *persist*: the child still inherits this + // process's environment, and Desktop inherits the user's login shell. + // A developer with goose installed exports `GOOSE_PROVIDER` there, and it + // reached the agent as if it were configuration — an agent set to OpenAI + // sent its OpenAI model to Anthropic and 404'd every turn while its + // settings still read "OpenAI" in the UI. + // + // Removing them here makes the record the only source: whatever + // `runtime_metadata_env_vars` derives below is what the child sees, and an + // agent with no configured provider gets none rather than the developer's. + for key in DERIVED_PROVIDER_MODEL_ENV_KEYS { + command.env_remove(key); + } if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var,