Skip to content
Open
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
59 changes: 59 additions & 0 deletions desktop/src-tauri/src/managed_agents/env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,5 +330,64 @@ pub(crate) fn resolve_persona_env(
Ok(persona.env_vars)
}

/// Buzz's canonical env key for a user-typed OpenAI-family API key, named for
/// buzz-agent's contract and reused as the single UI secret field for every
/// runtime.
const CANONICAL_OPENAI_API_KEY: &str = "OPENAI_COMPAT_API_KEY";

/// The provider the goose child will select: the layered user env wins, then
/// Buzz's structured field, then goose's own `config.yaml`.
///
/// The structured field alone is not authoritative — Buzz derives
/// `GOOSE_PROVIDER` from it, but a later env layer can override that in either
/// direction, and with no layer set goose falls back to its config file.
pub(crate) fn goose_effective_provider<'a>(
env: &'a BTreeMap<String, String>,
structured_provider: Option<&'a str>,
file_provider: Option<&'a str>,
) -> Option<&'a str> {
[
env.get("GOOSE_PROVIDER").map(String::as_str),
structured_provider,
file_provider,
]
.into_iter()
.flatten()
.map(str::trim)
.find(|value| !value.is_empty())
}

/// Translate Buzz's canonical provider credential into the env key the runtime
/// actually reads: goose's openai provider reads `OPENAI_API_KEY`, so without
/// this a goose agent on `provider: openai` calls the API with no bearer token.
///
/// Returns nothing when the agent already carries the runtime-native key, so an
/// explicit value is never overwritten. `openai-compat` is not aliased: goose
/// reaches those endpoints through `openai` plus `OPENAI_HOST`/`OPENAI_BASE_PATH`,
/// which needs `OPENAI_COMPAT_BASE_URL` split into an origin and a path.
pub(crate) fn runtime_credential_alias_env(
runtime_id: Option<&str>,
provider: Option<&str>,
env: &BTreeMap<String, String>,
) -> Vec<(&'static str, String)> {
// buzz-agent defines the canonical names; claude and codex use CLI login.
if runtime_id != Some("goose") || provider.map(str::trim) != Some("openai") {
return Vec::new();
}

let non_blank = |key: &str| {
env.get(key)
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.map(str::to_owned)
};
if non_blank("OPENAI_API_KEY").is_some() {
return Vec::new();
}
non_blank(CANONICAL_OPENAI_API_KEY)
.map(|key| vec![("OPENAI_API_KEY", key)])
.unwrap_or_default()
}

#[cfg(test)]
mod tests;
89 changes: 86 additions & 3 deletions desktop/src-tauri/src/managed_agents/env_vars/tests.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
use std::collections::BTreeMap;

use super::{
display_invalid_key, is_derived_provider_model_key, is_reserved_env_key,
is_well_formed_env_key, merged_user_env, validate_user_env_keys,
DERIVED_PROVIDER_MODEL_ENV_KEYS, MAX_ENV_TOTAL_BYTES, MAX_ENV_VALUE_BYTES, RESERVED_ENV_KEYS,
display_invalid_key, goose_effective_provider, is_derived_provider_model_key,
is_reserved_env_key, is_well_formed_env_key, merged_user_env, runtime_credential_alias_env,
validate_user_env_keys, DERIVED_PROVIDER_MODEL_ENV_KEYS, MAX_ENV_TOTAL_BYTES,
MAX_ENV_VALUE_BYTES, RESERVED_ENV_KEYS,
};

fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
Expand Down Expand Up @@ -488,3 +489,85 @@ fn deploy_model_precedence_none_when_both_absent() {
let effective = persona_model.clone().or(record_model.clone());
assert_eq!(effective, None);
}

// ── goose credential alias ─────────────────────────────────────────

#[test]
fn credential_alias_maps_canonical_openai_key_for_goose() {
let env = map(&[("OPENAI_COMPAT_API_KEY", " sk-canonical ")]);
assert_eq!(
runtime_credential_alias_env(Some("goose"), Some("openai"), &env),
vec![("OPENAI_API_KEY", "sk-canonical".to_string())]
);
}

/// An explicit runtime-native key is the user's own override — never replace it.
#[test]
fn credential_alias_never_overwrites_explicit_runtime_native_key() {
let env = map(&[
("OPENAI_COMPAT_API_KEY", "sk-canonical"),
("OPENAI_API_KEY", "sk-explicit"),
]);
assert!(runtime_credential_alias_env(Some("goose"), Some("openai"), &env).is_empty());
}

#[test]
fn credential_alias_is_scoped_to_goose_and_openai() {
let env = map(&[("OPENAI_COMPAT_API_KEY", "sk-canonical")]);
for (runtime, provider) in [
(Some("buzz-agent"), Some("openai")),
(Some("codex"), Some("openai")),
// goose has no `openai-compat` provider — see the alias doc comment.
(Some("goose"), Some("openai-compat")),
(Some("goose"), Some("anthropic")),
(Some("goose"), None),
] {
assert!(
runtime_credential_alias_env(runtime, provider, &env).is_empty(),
"{runtime:?}/{provider:?} must not be aliased"
);
}
}

#[test]
fn credential_alias_needs_a_non_blank_canonical_key() {
for value in ["", " "] {
let env = map(&[("OPENAI_COMPAT_API_KEY", value)]);
assert!(runtime_credential_alias_env(Some("goose"), Some("openai"), &env).is_empty());
}
assert!(
runtime_credential_alias_env(Some("goose"), Some("openai"), &BTreeMap::new()).is_empty()
);
}

// ── goose_effective_provider ───────────────────────────────────────

/// The layered env is what the child sees, so it overrides the structured
/// field in both directions — including switching an agent *away* from openai,
/// where aliasing the key would leak it to another provider.
#[test]
fn goose_provider_prefers_layered_env_over_structured_field() {
let to_openai = map(&[("GOOSE_PROVIDER", "openai")]);
assert_eq!(
goose_effective_provider(&to_openai, Some("anthropic"), None),
Some("openai")
);

let away_from_openai = map(&[("GOOSE_PROVIDER", "anthropic")]);
assert_eq!(
goose_effective_provider(&away_from_openai, Some("openai"), None),
Some("anthropic")
);
}

/// Blank layers are not a provider, and a provider set only in goose's own
/// `config.yaml` still counts.
#[test]
fn goose_provider_falls_through_blank_layers_to_the_config_file() {
let env = map(&[("GOOSE_PROVIDER", " ")]);
assert_eq!(
goose_effective_provider(&env, Some(""), Some("openai")),
Some("openai")
);
assert_eq!(goose_effective_provider(&env, None, None), None);
}
31 changes: 31 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,37 @@ pub fn spawn_agent_child(
for (key, value) in &descriptor.env {
command.env(key, value);
}

// ── Provider credential aliases ───────────────────────────────────
//
// Buzz keeps one secret per provider under a canonical env key; runtimes
// that read a different key need the same value under their own name.
// Written after `descriptor.env` so the alias sees the fully layered env,
// and `runtime_credential_alias_env` yields nothing when the user set the
// native key themselves, so this cannot clobber an explicit value.
if runtime_meta.is_some_and(|meta| meta.id == "goose") {
// The config file is only worth reading when neither cheaper source
// resolves a provider.
let file_cfg = super::env_vars::goose_effective_provider(
&descriptor.env,
effective_provider.as_deref(),
None,
)
.is_none()
.then(super::config_bridge::read_goose_file_config)
.flatten();
let provider = super::env_vars::goose_effective_provider(
&descriptor.env,
effective_provider.as_deref(),
file_cfg.as_ref().and_then(|cfg| cfg.provider.as_deref()),
);
for (key, value) in
super::env_vars::runtime_credential_alias_env(Some("goose"), provider, &descriptor.env)
{
command.env(key, value);
}
}

configure_runtime_cli(&mut command, runtime_meta);

// Buzz shared compute is stored as a native provider; derive the OpenAI-compatible
Expand Down