diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index 092332639..a5895fd2b 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -6,7 +6,7 @@ use goose::agents::extension_manager::get_parameter_names; use goose::agents::Agent; use goose::agents::{extension::Envs, ExtensionConfig}; use goose::config::declarative_providers::{ - create_custom_provider, remove_custom_provider, CreateCustomProviderParams, + create_custom_provider, remove_custom_provider, AuthConfig, CreateCustomProviderParams, }; use goose::config::extensions::{ get_all_extension_names, get_all_extensions, get_enabled_extensions, get_extension_by_name, @@ -2198,11 +2198,68 @@ fn add_provider() -> anyhow::Result<()> { .initial_value(true) .interact()?; - let api_key: String = if requires_auth { - cliclack::password("API key:").mask('▪').interact()? - } else { - String::new() - }; + let mut api_key = String::new(); + let mut auth: Option = None; + + if requires_auth { + let auth_mode = cliclack::select("How should goose obtain credentials for this provider?") + .item("static", "Static API key", "Enter a fixed API key now") + .item( + "command", + "Command (refreshable)", + "Run a command to fetch/refresh a short-lived credential", + ) + .interact()?; + + if auth_mode == "command" { + let command: String = cliclack::input("Command to run for the credential:") + .placeholder("/path/to/get-token.sh") + .validate(|input: &String| { + if input.trim().is_empty() { + Err("Please enter a command") + } else { + Ok(()) + } + }) + .interact()?; + + let args_input: String = + cliclack::input("Command arguments (comma-separated, optional):") + .placeholder("--flag, value") + .required(false) + .interact()?; + let args: Vec = args_input + .split(',') + .map(str::trim) + .filter(|arg| !arg.is_empty()) + .map(str::to_string) + .collect(); + + let refresh_interval_input: String = cliclack::input( + "Refresh interval in seconds (0 to only refresh after an auth failure):", + ) + .default_input("3600") + .validate(|input: &String| { + input + .trim() + .parse::() + .map(|_| ()) + .map_err(|_| "Please enter a whole number of seconds") + }) + .interact()?; + let refresh_interval: u64 = refresh_interval_input.trim().parse().unwrap_or(3600); + + auth = Some(AuthConfig { + command, + args, + refresh_interval, + timeout_seconds: None, + cwd: None, + }); + } else { + api_key = cliclack::password("API key:").mask('▪').interact()?; + } + } let models_input: String = cliclack::input("Available models (separate with commas):") .placeholder("model-a, model-b, model-c") @@ -2251,6 +2308,7 @@ fn add_provider() -> anyhow::Result<()> { catalog_provider_id: None, base_path, preserves_thinking: None, + auth, })?; if !provider_config.models.is_empty() { diff --git a/crates/goose-providers/src/anthropic.rs b/crates/goose-providers/src/anthropic.rs index 38dcdb05c..a9a54f8f2 100644 --- a/crates/goose-providers/src/anthropic.rs +++ b/crates/goose-providers/src/anthropic.rs @@ -420,6 +420,8 @@ pub fn from_declarative_config( )); } + config.validate_auth()?; + let api_key = if config.api_key_env.is_empty() { None } else { diff --git a/crates/goose-providers/src/api_client.rs b/crates/goose-providers/src/api_client.rs index 43ac83b1f..beb95d6bf 100644 --- a/crates/goose-providers/src/api_client.rs +++ b/crates/goose-providers/src/api_client.rs @@ -429,6 +429,13 @@ impl ApiClient { self } + // Auth is applied fresh on every request, so unlike `with_headers` this + // doesn't need to rebuild the underlying `reqwest::Client`. + pub fn with_auth(mut self, auth: AuthMethod) -> Self { + self.auth = auth; + self + } + pub fn with_https_only(mut self) -> Result { self.transport_policy = TransportPolicy::HttpsOnly; self.rebuild_client()?; diff --git a/crates/goose-providers/src/declarative.rs b/crates/goose-providers/src/declarative.rs index f42fce9ed..b158c2b10 100644 --- a/crates/goose-providers/src/declarative.rs +++ b/crates/goose-providers/src/declarative.rs @@ -114,6 +114,35 @@ impl FromStr for ProviderEngine { } } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthConfig { + /// Executed directly, never through a shell, so `args` is never + /// shell-interpolated. For shell features, invoke an interpreter + /// explicitly, e.g. `command: "/bin/bash"`, `args: ["-c", "..."]`. + /// Bare names (no path separator) are resolved via `PATH`; paths are + /// resolved against `cwd`. + pub command: String, + #[serde(default)] + pub args: Vec, + /// How long a fetched credential is cached before the command is re-run, + /// in seconds. `0` disables proactive refresh entirely — the command + /// only reruns reactively, after an auth failure (matches Codex's + /// `refresh_interval_ms: 0` convention). + #[serde(default = "default_refresh_interval")] + pub refresh_interval: u64, + /// Timeout for the command, in seconds. Defaults to 10s if unset. + #[serde(default)] + pub timeout_seconds: Option, + /// Working directory for the command, and the base a relative `command` + /// path is resolved against. Defaults to goose's current directory. + #[serde(default)] + pub cwd: Option, +} + +fn default_refresh_interval() -> u64 { + 3600 +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeclarativeProviderConfig { pub name: String, @@ -135,6 +164,10 @@ pub struct DeclarativeProviderConfig { pub base_path: Option, #[serde(default)] pub env_vars: Option>, + /// Alternative to `api_key_env`: run a command to fetch/refresh the credential + /// instead of reading a static secret. Mutually exclusive with `api_key_env`. + #[serde(default)] + pub auth: Option, /// Controls whether `fetch_supported_models` calls the provider's `/v1/models` /// endpoint or returns the static `models` list directly. /// @@ -189,6 +222,18 @@ impl DeclarativeProviderConfig { pub fn models(&self) -> &[ModelInfo] { &self.models } + + /// Errors if both `api_key_env` and `auth.command` are set; they're + /// alternative ways to authenticate and mutually exclusive. + pub fn validate_auth(&self) -> anyhow::Result<()> { + if self.auth.is_some() && !self.api_key_env.is_empty() { + anyhow::bail!( + "Provider '{}' sets both `api_key_env` and `auth.command`; these are mutually exclusive.", + self.name + ); + } + Ok(()) + } } pub trait KeyResolver { diff --git a/crates/goose-providers/src/ollama.rs b/crates/goose-providers/src/ollama.rs index 9020f9a1b..9bb6f18ce 100644 --- a/crates/goose-providers/src/ollama.rs +++ b/crates/goose-providers/src/ollama.rs @@ -307,6 +307,8 @@ pub fn from_declarative_config( .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } + config.validate_auth()?; + let api_key = if config.api_key_env.is_empty() { None } else { @@ -581,6 +583,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose-providers/src/openai.rs b/crates/goose-providers/src/openai.rs index c91d129c1..0951375c6 100644 --- a/crates/goose-providers/src/openai.rs +++ b/crates/goose-providers/src/openai.rs @@ -912,6 +912,8 @@ pub fn from_declarative_config( )); } + config.validate_auth()?; + let api_key = if config.api_key_env.is_empty() { None } else { @@ -1384,6 +1386,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models: Some(false), skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/src/acp/server/providers.rs b/crates/goose/src/acp/server/providers.rs index 6a7f13409..168206baf 100644 --- a/crates/goose/src/acp/server/providers.rs +++ b/crates/goose/src/acp/server/providers.rs @@ -649,6 +649,7 @@ impl GooseAcpAgent { catalog_provider_id: provider.catalog_provider_id, base_path: provider.base_path, preserves_thinking: provider.preserves_thinking, + auth: None, }, ) .internal_err_ctx("Failed to create custom provider")?; @@ -693,7 +694,7 @@ impl GooseAcpAgent { } let provider = normalize_custom_provider_upsert(req.provider, false)?; - if provider.requires_auth && provider.api_key.is_none() { + if provider.requires_auth && provider.api_key.is_none() && loaded.config.auth.is_none() { let api_key_env = if loaded.config.api_key_env.is_empty() { declarative_providers::generate_api_key_name(&req.provider_id) } else { @@ -710,7 +711,11 @@ impl GooseAcpAgent { engine: provider.engine, display_name: provider.display_name, api_url: provider.api_url, - api_key: provider.api_key, + api_key: if loaded.config.auth.is_some() { + None + } else { + provider.api_key + }, models: custom_provider_models( provider.models, &loaded.config.models, @@ -722,6 +727,15 @@ impl GooseAcpAgent { catalog_provider_id: provider.catalog_provider_id, base_path: provider.base_path, preserves_thinking: provider.preserves_thinking, + // The desktop/ACP form doesn't yet support editing command-based + // auth, so carry the existing setting forward unchanged rather + // than silently clearing it — but only while auth stays enabled; + // disabling auth must actually stop the credential command. + auth: if provider.requires_auth { + loaded.config.auth.clone() + } else { + None + }, }, ) .internal_err_ctx("Failed to update custom provider")?; diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 501c8e3a2..9c385116d 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -145,6 +145,8 @@ pub struct CreateCustomProviderParams { pub catalog_provider_id: Option, pub base_path: Option, pub preserves_thinking: Option, + /// Alternative to `api_key`; mutually exclusive with it. + pub auth: Option, } #[derive(Debug, Clone)] @@ -161,6 +163,8 @@ pub struct UpdateCustomProviderParams { pub catalog_provider_id: Option, pub base_path: Option, pub preserves_thinking: Option, + /// Alternative to `api_key`; mutually exclusive with it. + pub auth: Option, } pub fn create_custom_provider( @@ -169,7 +173,18 @@ pub fn create_custom_provider( let id = generate_id(¶ms.display_name); validate_provider_id(&id)?; - let api_key_env = if params.requires_auth { + if params.auth.is_some() + && params + .api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + { + anyhow::bail!("cannot set both apiKey and auth.command"); + } + + let api_key_env = if params.auth.is_some() { + String::new() + } else if params.requires_auth { let api_key = params .api_key .as_deref() @@ -205,6 +220,7 @@ pub fn create_custom_provider( catalog_provider_id: params.catalog_provider_id, base_path: params.base_path, env_vars: None, + auth: params.auth, dynamic_models: None, skip_canonical_filtering: false, model_doc_link: None, @@ -230,8 +246,22 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> let existing_config = loaded_provider.config; let editable = loaded_provider.is_editable; + if params.auth.is_some() + && params + .api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()) + { + anyhow::bail!("cannot set both apiKey and auth.command"); + } + let config = Config::global(); - let api_key_env = if params.requires_auth { + let api_key_env = if params.auth.is_some() { + if existing_config.api_key_env == generate_api_key_name(¶ms.id) { + config.delete_secret(&existing_config.api_key_env)?; + } + String::new() + } else if params.requires_auth { let api_key_name = if existing_config.api_key_env.is_empty() { generate_api_key_name(¶ms.id) } else { @@ -309,6 +339,7 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> catalog_provider_id: params.catalog_provider_id, base_path: params.base_path, env_vars: existing_config.env_vars, + auth: params.auth, dynamic_models: existing_config.dynamic_models, skip_canonical_filtering: existing_config.skip_canonical_filtering, model_doc_link: existing_config.model_doc_link, @@ -539,6 +570,10 @@ fn huggingface_declarative_inventory_configured_from_sources( provider_secret_configured: impl FnOnce(&str) -> bool, global_huggingface_configured: impl FnOnce() -> bool, ) -> bool { + if config.auth.is_some() { + return true; + } + if !config.requires_auth { return true; } @@ -581,6 +616,7 @@ mod tests { catalog_provider_id: Some("huggingface".to_string()), base_path: None, env_vars: None, + auth: None, dynamic_models: Some(false), skip_canonical_filtering: false, model_doc_link: None, @@ -616,6 +652,24 @@ mod tests { )); } + #[test] + fn huggingface_inventory_accepts_command_auth() { + let mut config = test_huggingface_config(); + config.auth = Some(AuthConfig { + command: "get-token".to_string(), + args: vec![], + refresh_interval: 3600, + timeout_seconds: None, + cwd: None, + }); + + assert!(huggingface_declarative_inventory_configured_from_sources( + &config, + |_| false, + || false, + )); + } + #[test] fn huggingface_inventory_does_not_fallback_when_explicit_key_is_missing() { let mut config = test_huggingface_config(); @@ -720,6 +774,7 @@ mod tests { catalog_provider_id: None, base_path: None, preserves_thinking: None, + auth: None, }) .unwrap(); @@ -736,6 +791,7 @@ mod tests { catalog_provider_id: None, base_path: None, preserves_thinking: None, + auth: None, }) .unwrap(); @@ -852,6 +908,7 @@ mod tests { catalog_provider_id: None, base_path: None, preserves_thinking: None, + auth: None, }) .unwrap(); diff --git a/crates/goose/src/providers/anthropic_def.rs b/crates/goose/src/providers/anthropic_def.rs index a2404a404..914aa6359 100644 --- a/crates/goose/src/providers/anthropic_def.rs +++ b/crates/goose/src/providers/anthropic_def.rs @@ -3,7 +3,10 @@ use futures::future::BoxFuture; use crate::{ config::{Config, DeclarativeProviderConfig}, - providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver}, + providers::{ + base::ProviderDef, command_auth::CommandAuthProvider, + custom_provider_config::ConfigKeyResolver, + }, }; use goose_providers::{ anthropic::{self, AnthropicProvider, AnthropicProviderBuilder, ANTHROPIC_API_VERSION}, @@ -69,12 +72,19 @@ pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> Result { + let auth_override = config.auth.clone(); anthropic::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global())) .map(|builder| { builder .map_api_client(|api_client| { - api_client - .with_request_builder(crate::session_context::session_id_request_builder()) + let api_client = api_client + .with_request_builder(crate::session_context::session_id_request_builder()); + match auth_override { + Some(auth_config) => api_client.with_auth(AuthMethod::Custom(Box::new( + CommandAuthProvider::new(&auth_config, "x-api-key", ""), + ))), + None => api_client, + } }) .build() }) @@ -129,6 +139,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/src/providers/command_auth.rs b/crates/goose/src/providers/command_auth.rs new file mode 100644 index 000000000..60d33eb27 --- /dev/null +++ b/crates/goose/src/providers/command_auth.rs @@ -0,0 +1,475 @@ +use anyhow::Result; +use async_trait::async_trait; +use goose_providers::declarative::AuthConfig; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::sync::RwLock; + +use super::api_client::AuthProvider; +use crate::subprocess::configure_subprocess; + +const DEFAULT_AUTH_COMMAND_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_STDERR_BYTES: usize = 500; + +struct CachedCredential { + token: String, + fetched_at: Instant, +} + +/// Fetches a credential by running a user-configured command and caches the +/// result for `refresh_interval` before re-running it. Runtime counterpart of +/// `DeclarativeProviderConfig::auth`, for custom providers whose credentials +/// are short-lived and issued by an external script rather than a static key. +pub struct CommandAuthProvider { + command: String, + args: Vec, + refresh_interval: Duration, + timeout: Duration, + /// Working directory for the command, and the base a relative `command` + /// resolves against. Captured once at construction (defaults to goose's + /// current directory at that point), not re-read per invocation. + cwd: PathBuf, + header_name: String, + header_value_prefix: String, + cached: Arc>>, +} + +impl CommandAuthProvider { + pub fn new( + auth_config: &AuthConfig, + header_name: impl Into, + header_value_prefix: impl Into, + ) -> Self { + // Made absolute up front: `cwd` doubles as both `current_dir` for the + // spawned process and the join base in `resolve_program`, and a + // relative value would otherwise be applied twice (once by us, once + // by the OS resolving a relative program path against the process's + // now-`current_dir`-ed working directory). + let cwd = auth_config + .cwd + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let cwd = std::env::current_dir() + .map(|base| { + if cwd.is_absolute() { + cwd.clone() + } else { + base.join(&cwd) + } + }) + .unwrap_or(cwd); + Self { + command: auth_config.command.clone(), + args: auth_config.args.clone(), + refresh_interval: Duration::from_secs(auth_config.refresh_interval), + timeout: auth_config + .timeout_seconds + .map(Duration::from_secs) + .unwrap_or(DEFAULT_AUTH_COMMAND_TIMEOUT), + cwd, + header_name: header_name.into(), + header_value_prefix: header_value_prefix.into(), + cached: Arc::new(RwLock::new(None)), + } + } + + async fn fetch_token(&self) -> Result { + // Spawned directly, never through a shell, so `args` is never + // shell-interpolated. Inherits goose's full environment, since the + // same user configures goose and writes the script. + let program = resolve_program(&self.command, &self.cwd); + let mut command = tokio::process::Command::new(&program); + command + .args(&self.args) + .current_dir(&self.cwd) + // No stdin, so a script that unexpectedly tries to prompt fails + // fast instead of hanging on the parent's stdin. `kill_on_drop` + // is a fallback for exit paths other than the timeout below, + // which kills the whole process group explicitly — the direct + // child alone wouldn't take a shell pipeline's descendants with + // it, since `configure_subprocess` puts the child in its own + // new process group. + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + configure_subprocess(&mut command); + + let child = command + .spawn() + .map_err(|e| anyhow::anyhow!("failed to run auth command '{}': {}", self.command, e))?; + #[cfg(unix)] + let pid = child.id(); + + let output = match tokio::time::timeout(self.timeout, child.wait_with_output()).await { + Ok(result) => result.map_err(|e| { + anyhow::anyhow!("failed to run auth command '{}': {}", self.command, e) + })?, + Err(_) => { + #[cfg(unix)] + if let Some(pid) = pid { + // SAFETY: signals only the process group `configure_subprocess` + // put this child in (negative pid), never an unrelated group. + unsafe { + libc::kill(-(pid as libc::pid_t), libc::SIGKILL); + } + } + anyhow::bail!( + "auth command '{}' timed out after {:?}", + self.command, + self.timeout + ); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!( + "auth command '{}' exited with {}: {}", + self.command, + output.status, + truncate(&stderr, MAX_STDERR_BYTES) + ); + } + + let token = String::from_utf8(output.stdout) + .map_err(|_| { + anyhow::anyhow!( + "auth command '{}' wrote non-UTF-8 data to stdout", + self.command + ) + })? + .trim() + .to_string(); + if token.is_empty() { + anyhow::bail!( + "auth command '{}' produced an empty credential", + self.command + ); + } + + Ok(token) + } + + /// `refresh_interval: 0` means "never expire proactively" — matching + /// Codex's `refresh_interval_ms: 0` convention — so the credential is + /// only refreshed reactively, via `refresh_credentials()` after an auth + /// failure, not on a TTL. + fn is_fresh(&self, cached: &CachedCredential) -> bool { + self.refresh_interval.is_zero() || cached.fetched_at.elapsed() < self.refresh_interval + } +} + +/// A bare command name (no path separator) is left alone for `PATH` lookup; +/// an absolute path is used as-is; a relative path is resolved against `cwd`. +fn resolve_program(command: &str, cwd: &Path) -> PathBuf { + let path = Path::new(command); + if path.is_absolute() { + return path.to_path_buf(); + } + if path.components().count() > 1 { + return cwd.join(path); + } + PathBuf::from(command) +} + +fn truncate(s: &str, max_bytes: usize) -> &str { + if s.len() <= max_bytes { + return s; + } + let mut end = max_bytes; + while !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or(s) +} + +#[async_trait] +impl AuthProvider for CommandAuthProvider { + async fn get_auth_header(&self) -> Result<(String, String)> { + // Try read lock first for better concurrency + if let Some(cached) = self.cached.read().await.as_ref() { + if self.is_fresh(cached) { + return Ok(( + self.header_name.clone(), + format!("{}{}", self.header_value_prefix, cached.token), + )); + } + } + + // Take write lock only if needed + let mut guard = self.cached.write().await; + + // Double-check freshness after acquiring write lock + if let Some(cached) = guard.as_ref() { + if self.is_fresh(cached) { + return Ok(( + self.header_name.clone(), + format!("{}{}", self.header_value_prefix, cached.token), + )); + } + } + + // Get a new token. No fallback to a stale cached token on failure: + // that would surface as a confusing downstream 401 instead of a + // clear "the refresh command failed" error. + let token = self.fetch_token().await?; + *guard = Some(CachedCredential { + token: token.clone(), + fetched_at: Instant::now(), + }); + + Ok(( + self.header_name.clone(), + format!("{}{}", self.header_value_prefix, token), + )) + } + + async fn refresh_credentials(&self) -> Result<()> { + *self.cached.write().await = None; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn auth_config( + command: impl Into, + args: Vec>, + refresh_interval: u64, + ) -> AuthConfig { + AuthConfig { + command: command.into(), + args: args.into_iter().map(Into::into).collect(), + refresh_interval, + timeout_seconds: None, + cwd: None, + } + } + + /// A command whose stdout differs on every invocation (this process's own + /// PID / a fresh pseudo-random draw), so two captured values back-to-back + /// prove whether the underlying command was actually re-run. + #[cfg(unix)] + fn distinct_value_command() -> (&'static str, Vec<&'static str>) { + ("sh", vec!["-c", "echo $$"]) + } + #[cfg(windows)] + fn distinct_value_command() -> (&'static str, Vec<&'static str>) { + ("cmd", vec!["/C", "echo %RANDOM%%RANDOM%%RANDOM%"]) + } + + #[cfg(unix)] + fn sleep_seconds_command(seconds: u64) -> (&'static str, Vec) { + ("sleep", vec![seconds.to_string()]) + } + #[cfg(windows)] + fn sleep_seconds_command(seconds: u64) -> (&'static str, Vec) { + ( + "powershell", + vec![ + "-NoProfile".to_string(), + "-Command".to_string(), + format!("Start-Sleep -Seconds {seconds}"), + ], + ) + } + + #[cfg(unix)] + fn fail_command() -> (&'static str, Vec<&'static str>) { + ("sh", vec!["-c", "exit 1"]) + } + #[cfg(windows)] + fn fail_command() -> (&'static str, Vec<&'static str>) { + ("cmd", vec!["/C", "exit 1"]) + } + + #[cfg(unix)] + fn fail_after_printing_command(text: &str) -> (&'static str, Vec) { + ("sh", vec!["-c".to_string(), format!("echo {text}; exit 1")]) + } + #[cfg(windows)] + fn fail_after_printing_command(text: &str) -> (&'static str, Vec) { + ( + "cmd", + vec!["/C".to_string(), format!("echo {text} & exit 1")], + ) + } + + #[tokio::test] + async fn caches_token_within_refresh_interval() { + // A fresh value on every invocation, so two equal results back-to-back + // prove the command was only run once. + let (command, args) = distinct_value_command(); + let provider = CommandAuthProvider::new( + &auth_config(command, args, 3600), + "Authorization", + "Bearer ", + ); + + let (header, first) = provider.get_auth_header().await.unwrap(); + assert_eq!(header, "Authorization"); + let (_, second) = provider.get_auth_header().await.unwrap(); + + assert_eq!(first, second); + } + + #[tokio::test] + async fn refetches_after_ttl_expires() { + let (command, args) = distinct_value_command(); + let provider = + CommandAuthProvider::new(&auth_config(command, args, 1), "Authorization", "Bearer "); + + let (_, first) = provider.get_auth_header().await.unwrap(); + tokio::time::sleep(Duration::from_millis(1100)).await; + let (_, second) = provider.get_auth_header().await.unwrap(); + + assert_ne!(first, second); + } + + #[tokio::test] + async fn zero_refresh_interval_disables_proactive_refresh() { + // Matches Codex's `refresh_interval_ms: 0` convention: the cache + // never ages out on its own, only `refresh_credentials()` (the + // reactive, on-401 path) invalidates it. + let (command, args) = distinct_value_command(); + let provider = + CommandAuthProvider::new(&auth_config(command, args, 0), "Authorization", "Bearer "); + + let (_, first) = provider.get_auth_header().await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + let (_, second) = provider.get_auth_header().await.unwrap(); + assert_eq!(first, second); + + provider.refresh_credentials().await.unwrap(); + let (_, third) = provider.get_auth_header().await.unwrap(); + assert_ne!(second, third); + } + + #[tokio::test] + async fn refresh_credentials_forces_refetch() { + let (command, args) = distinct_value_command(); + let provider = CommandAuthProvider::new( + &auth_config(command, args, 3600), + "Authorization", + "Bearer ", + ); + + let (_, first) = provider.get_auth_header().await.unwrap(); + provider.refresh_credentials().await.unwrap(); + let (_, second) = provider.get_auth_header().await.unwrap(); + + assert_ne!(first, second); + } + + #[tokio::test] + async fn enforces_timeout() { + let (command, args) = sleep_seconds_command(5); + let mut config = auth_config(command, args, 3600); + config.timeout_seconds = Some(1); + let provider = CommandAuthProvider::new(&config, "Authorization", "Bearer "); + + let start = Instant::now(); + let result = provider.get_auth_header().await; + assert!(result.is_err()); + assert!(start.elapsed() < Duration::from_secs(4)); + } + + #[tokio::test] + async fn errors_on_nonzero_exit_without_falling_back_to_stale_cache() { + let (command, args) = fail_command(); + let provider = CommandAuthProvider::new( + &auth_config(command, args, 3600), + "Authorization", + "Bearer ", + ); + // Seed a valid cached credential, then invalidate it the same way a + // 401 response does, so the next fetch attempt hits the failing + // command instead of returning the still-fresh cached value. + *provider.cached.write().await = Some(CachedCredential { + token: "stale-token".to_string(), + fetched_at: Instant::now(), + }); + provider.refresh_credentials().await.unwrap(); + + let result = provider.get_auth_header().await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn error_message_does_not_leak_stdout() { + let (command, args) = fail_after_printing_command("super-secret-token"); + let provider = CommandAuthProvider::new( + &auth_config(command, args, 3600), + "Authorization", + "Bearer ", + ); + + let err = provider.get_auth_header().await.unwrap_err(); + assert!(!err.to_string().contains("super-secret-token")); + } + + #[test] + fn resolve_program_leaves_bare_names_for_path_lookup() { + let resolved = resolve_program("aws", Path::new("/some/cwd")); + assert_eq!(resolved, PathBuf::from("aws")); + } + + #[test] + fn resolve_program_leaves_absolute_paths_unchanged() { + let resolved = resolve_program("/usr/local/bin/get-token.sh", Path::new("/some/cwd")); + assert_eq!(resolved, PathBuf::from("/usr/local/bin/get-token.sh")); + } + + #[test] + fn resolve_program_joins_relative_paths_against_cwd() { + let resolved = resolve_program("./scripts/get-token.sh", Path::new("/some/cwd")); + assert_eq!( + resolved, + Path::new("/some/cwd").join("./scripts/get-token.sh") + ); + } + + #[test] + fn relative_cwd_is_resolved_to_absolute_before_use() { + // A relative `cwd` must become absolute once, at construction, so it + // isn't applied twice: once by `resolve_program`'s join, once by the + // OS resolving a relative program path against the already + // `current_dir`-ed child process. + let mut config = auth_config("./get-token", Vec::<&str>::new(), 3600); + config.cwd = Some("some/relative/dir".to_string()); + let provider = CommandAuthProvider::new(&config, "Authorization", "Bearer "); + + assert!(provider.cwd.is_absolute()); + assert_eq!( + provider.cwd, + std::env::current_dir().unwrap().join("some/relative/dir") + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn command_runs_with_configured_cwd_and_resolves_relative_path() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let script_path = dir.path().join("get-token.sh"); + std::fs::write(&script_path, "#!/bin/sh\npwd\n").unwrap(); + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let mut config = auth_config("./get-token.sh", Vec::<&str>::new(), 3600); + config.cwd = Some(dir.path().to_string_lossy().to_string()); + let provider = CommandAuthProvider::new(&config, "Authorization", "Bearer "); + + let (_, value) = provider.get_auth_header().await.unwrap(); + let token = value.strip_prefix("Bearer ").unwrap(); + assert_eq!( + std::fs::canonicalize(token).unwrap(), + std::fs::canonicalize(dir.path()).unwrap() + ); + } +} diff --git a/crates/goose/src/providers/huggingface.rs b/crates/goose/src/providers/huggingface.rs index 6fa603de6..efe420a82 100644 --- a/crates/goose/src/providers/huggingface.rs +++ b/crates/goose/src/providers/huggingface.rs @@ -3,6 +3,7 @@ use super::base::{ ConfigKey, MessageStream, ModelInfo, Provider, ProviderDef, ProviderMetadata, DEFAULT_PROVIDER_TIMEOUT_SECS, }; +use super::command_auth::CommandAuthProvider; use super::huggingface_auth; use super::openai_compatible::OpenAiCompatibleProvider; use crate::config::declarative_providers::DeclarativeProviderConfig; @@ -84,7 +85,15 @@ impl HuggingFaceProvider { )); } - let auth_method = custom_auth_method(&config)?; + config.validate_auth()?; + let auth_method = match config.auth.as_ref() { + Some(auth_config) => AuthMethod::Custom(Box::new(CommandAuthProvider::new( + auth_config, + "Authorization", + "Bearer ", + ))), + None => custom_auth_method(&config)?, + }; let (host, completions_prefix, query_params) = openai_compatible_endpoint_parts(&config.base_url, config.base_path.as_deref())?; @@ -464,6 +473,21 @@ mod tests { assert_eq!(provider.get_context_limit("static-a", None).await, 128_000); } + #[test] + fn custom_provider_accepts_command_auth_without_huggingface_token() { + let mut config = test_config(); + config.api_key_env.clear(); + config.auth = Some(goose_providers::declarative::AuthConfig { + command: "echo".to_string(), + args: vec!["token".to_string()], + refresh_interval: 3600, + timeout_seconds: None, + cwd: None, + }); + + HuggingFaceProvider::from_custom_config(config, None).unwrap(); + } + #[test] fn custom_provider_requires_static_models_when_dynamic_models_disabled() { let mut config = test_config(); @@ -552,6 +576,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models: None, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/src/providers/inventory/mod.rs b/crates/goose/src/providers/inventory/mod.rs index 6c7997962..1f6612bc5 100644 --- a/crates/goose/src/providers/inventory/mod.rs +++ b/crates/goose/src/providers/inventory/mod.rs @@ -979,6 +979,19 @@ pub fn declarative_inventory_identity( .insert(config.api_key_env.clone(), value); } } + if let Some(auth) = &config.auth { + identity + .secret_inputs + .insert("auth_command".to_string(), auth.command.clone()); + identity + .secret_inputs + .insert("auth_args".to_string(), serde_json::to_string(&auth.args)?); + if let Some(cwd) = &auth.cwd { + identity + .secret_inputs + .insert("auth_cwd".to_string(), cwd.clone()); + } + } Ok(identity) } diff --git a/crates/goose/src/providers/mod.rs b/crates/goose/src/providers/mod.rs index f2ccd62b3..9987b8da2 100644 --- a/crates/goose/src/providers/mod.rs +++ b/crates/goose/src/providers/mod.rs @@ -28,6 +28,7 @@ pub mod claude_code; pub(crate) mod cli_common; pub mod codex; pub mod codex_acp; +pub mod command_auth; pub mod copilot_acp; pub mod cursor_agent; pub mod custom_provider_config; diff --git a/crates/goose/src/providers/ollama_cloud.rs b/crates/goose/src/providers/ollama_cloud.rs index 7401c6970..529b6b197 100644 --- a/crates/goose/src/providers/ollama_cloud.rs +++ b/crates/goose/src/providers/ollama_cloud.rs @@ -481,6 +481,7 @@ mod tests { catalog_provider_id: None, base_path: None, env_vars: None, + auth: None, dynamic_models, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/src/providers/ollama_def.rs b/crates/goose/src/providers/ollama_def.rs index 161fd55c6..be469a230 100644 --- a/crates/goose/src/providers/ollama_def.rs +++ b/crates/goose/src/providers/ollama_def.rs @@ -6,7 +6,10 @@ use url::Url; use crate::{ config::{declarative_providers::DeclarativeProviderConfig, Config}, - providers::{base::ProviderDef, custom_provider_config::ConfigKeyResolver}, + providers::{ + base::ProviderDef, command_auth::CommandAuthProvider, + custom_provider_config::ConfigKeyResolver, + }, }; use goose_providers::{ api_client::{ApiClient, AuthMethod}, @@ -96,12 +99,19 @@ pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> Result { + let auth_override = config.auth.clone(); ollama::from_declarative_config(config, tls_config, ConfigKeyResolver::new(Config::global())) .map(|builder| { builder .map_api_client(|api_client| { - api_client - .with_request_builder(crate::session_context::session_id_request_builder()) + let api_client = api_client + .with_request_builder(crate::session_context::session_id_request_builder()); + match auth_override { + Some(auth_config) => api_client.with_auth(AuthMethod::Custom(Box::new( + CommandAuthProvider::new(&auth_config, "Authorization", "Bearer "), + ))), + None => api_client, + } }) .options(options_from_config()) .build() diff --git a/crates/goose/src/providers/openai_def.rs b/crates/goose/src/providers/openai_def.rs index 2d1374a26..970b5ef24 100644 --- a/crates/goose/src/providers/openai_def.rs +++ b/crates/goose/src/providers/openai_def.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use crate::config::declarative_providers::DeclarativeProviderConfig; use crate::config::Config; use crate::providers::base::{ProviderDef, DEFAULT_PROVIDER_TIMEOUT_SECS}; +use crate::providers::command_auth::CommandAuthProvider; use crate::providers::custom_provider_config::ConfigKeyResolver; use goose_providers::api_client::{ApiClient, AuthMethod}; use goose_providers::openai::{ @@ -217,6 +218,7 @@ pub fn from_custom_config( config: DeclarativeProviderConfig, tls_config: Option, ) -> Result { + let auth_override = config.auth.clone(); goose_providers::openai::from_declarative_config( config, tls_config, @@ -225,8 +227,14 @@ pub fn from_custom_config( .map(|builder| { builder .map_api_client(|api_client| { - api_client - .with_request_builder(crate::session_context::session_id_request_builder()) + let api_client = api_client + .with_request_builder(crate::session_context::session_id_request_builder()); + match auth_override { + Some(auth_config) => api_client.with_auth(AuthMethod::Custom(Box::new( + CommandAuthProvider::new(&auth_config, "Authorization", "Bearer "), + ))), + None => api_client, + } }) .build() }) diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index 6738c0753..332f8cad6 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -244,7 +244,7 @@ impl ProviderRegistry { let mut config_keys = base_metadata.config_keys.clone(); if let Some(api_key_index) = config_keys.iter().position(|key| key.secret) { - if !config.requires_auth { + if !config.requires_auth || config.auth.is_some() { config_keys.remove(api_key_index); } else if !config.api_key_env.is_empty() { config_keys[api_key_index] = @@ -375,6 +375,7 @@ mod tests { catalog_provider_id: Some("huggingface".to_string()), base_path: None, env_vars: None, + auth: None, dynamic_models: None, skip_canonical_filtering: false, model_doc_link: None, diff --git a/crates/goose/tests/custom_provider_command_auth_test.rs b/crates/goose/tests/custom_provider_command_auth_test.rs new file mode 100644 index 000000000..b2a1ce12f --- /dev/null +++ b/crates/goose/tests/custom_provider_command_auth_test.rs @@ -0,0 +1,184 @@ +//! End-to-end coverage for command-based auth on custom providers (issue #11329): +//! a `DeclarativeProviderConfig.auth` command is run to fetch the credential +//! instead of a static `api_key_env`, and the credential is refreshed +//! reactively when the upstream API returns a 401. + +use goose::conversation::message::Message; +use goose::providers::base::Provider; +use goose::providers::openai_def; +use goose_providers::declarative::{AuthConfig, DeclarativeProviderConfig, ProviderEngine}; +use goose_providers::model::ModelConfig; +use serde_json::json; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; + +fn custom_config_with_auth(base_url: &str, auth: AuthConfig) -> DeclarativeProviderConfig { + DeclarativeProviderConfig { + name: "custom_command_auth".to_string(), + engine: ProviderEngine::OpenAI, + display_name: "Custom Command Auth".to_string(), + description: None, + api_key_env: String::new(), + base_url: base_url.to_string(), + models: vec![goose_providers::base::ModelInfo::new("test-model")], + headers: None, + timeout_seconds: None, + supports_streaming: Some(true), + requires_auth: true, + catalog_provider_id: None, + base_path: None, + env_vars: None, + auth: Some(auth), + dynamic_models: Some(false), + skip_canonical_filtering: false, + model_doc_link: None, + setup_steps: vec![], + fast_model: None, + preserves_thinking: false, + emit_clear_thinking: false, + setup: None, + } +} + +/// A command whose stdout differs on every invocation (this process's own +/// PID / a fresh pseudo-random draw), so two captured values back-to-back +/// prove whether the underlying command was actually re-run. +#[cfg(unix)] +fn distinct_value_command() -> (&'static str, Vec<&'static str>) { + ("sh", vec!["-c", "echo $$"]) +} +#[cfg(windows)] +fn distinct_value_command() -> (&'static str, Vec<&'static str>) { + ("cmd", vec!["/C", "echo %RANDOM%%RANDOM%%RANDOM%"]) +} + +fn chat_completions_sse() -> String { + format!( + "data: {}\n\ndata: {}\n\ndata: [DONE]\n\n", + json!({ + "choices": [{ + "delta": {"content": "hi", "role": "assistant"}, + "index": 0 + }], + "created": 1755133833, + "id": "chatcmpl-test", + "model": "test-model" + }), + json!({ + "choices": [], + "usage": {"completion_tokens": 1, "prompt_tokens": 1, "total_tokens": 2} + }) + ) +} + +async fn complete(provider: &dyn Provider) -> anyhow::Result<()> { + let message = Message::user().with_text("hello"); + let model_config = ModelConfig::new("test-model"); + provider + .complete(&model_config, "system", &[message], &[]) + .await?; + Ok(()) +} + +#[tokio::test] +async fn command_auth_credential_is_sent_as_bearer_token() { + let server = MockServer::start().await; + let captured_auth: Arc>> = Arc::new(Mutex::new(Vec::new())); + let capture = captured_auth.clone(); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(move |req: &Request| { + let auth = req + .headers + .get("authorization") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + capture.lock().unwrap().push(auth); + ResponseTemplate::new(200) + .set_body_string(chat_completions_sse()) + .insert_header("content-type", "text/event-stream") + }) + .mount(&server) + .await; + + let config = custom_config_with_auth( + &server.uri(), + AuthConfig { + command: "echo".to_string(), + args: vec!["test-token-123".to_string()], + refresh_interval: 3600, + timeout_seconds: None, + cwd: None, + }, + ); + let provider = openai_def::from_custom_config(config, None).unwrap(); + + complete(&provider).await.unwrap(); + + assert_eq!( + captured_auth.lock().unwrap().as_slice(), + ["Bearer test-token-123".to_string()] + ); +} + +#[tokio::test] +async fn command_auth_refreshes_credential_on_401_and_retries() { + let server = MockServer::start().await; + let captured_auth: Arc>> = Arc::new(Mutex::new(Vec::new())); + let capture = captured_auth.clone(); + let call_count = Arc::new(AtomicUsize::new(0)); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(move |req: &Request| { + let auth = req + .headers + .get("authorization") + .map(|v| v.to_str().unwrap().to_string()) + .unwrap_or_default(); + capture.lock().unwrap().push(auth); + + if call_count.fetch_add(1, Ordering::SeqCst) == 0 { + ResponseTemplate::new(401) + .set_body_json(json!({"error": {"message": "token expired"}})) + } else { + ResponseTemplate::new(200) + .set_body_string(chat_completions_sse()) + .insert_header("content-type", "text/event-stream") + } + }) + .mount(&server) + .await; + + // A value that differs on every invocation (this process's own PID / a + // fresh pseudo-random draw), so two distinct captured Authorization + // headers prove the auth command actually ran twice (once up front, once + // after `refresh_credentials` invalidated the cache on the 401), rather + // than the same token being reused. + let (command, args) = distinct_value_command(); + let config = custom_config_with_auth( + &server.uri(), + AuthConfig { + command: command.to_string(), + args: args.into_iter().map(str::to_string).collect(), + refresh_interval: 3600, + timeout_seconds: None, + cwd: None, + }, + ); + let provider = openai_def::from_custom_config(config, None).unwrap(); + + complete(&provider) + .await + .expect("request should succeed after refreshing credentials and retrying"); + + let captured = captured_auth.lock().unwrap(); + assert_eq!(captured.len(), 2, "expected an initial request and a retry"); + assert_ne!( + captured[0], captured[1], + "the retried request should carry a freshly-fetched credential, not the stale one" + ); +} diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index c8da56e3f..47e8d4fe9 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -465,7 +465,9 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T - **Name**: A friendly name for the provider - **API URL**: The base URL of the API endpoint - **Authentication Required**: Answer "Yes" if your provider needs an API key, or "No" if authentication is not required - - If Yes: You'll be prompted to enter your **API Key** (stored securely in the keychain, or in `secrets.yaml` if the keyring is disabled or cannot be accessed) + - If Yes: Choose how goose should obtain the credential: + - **Static API key**: You'll be prompted to enter your **API Key** (stored securely in the keychain, or in `secrets.yaml` if the keyring is disabled or cannot be accessed) + - **Command (refreshable)**: You'll be prompted for a **command** (and optional arguments) that goose runs to fetch the credential, plus a **refresh interval** in seconds. Use this for short-lived credentials issued by an IdP or key vault, so goose can refresh them automatically instead of requiring a restart when they expire. See [Command-Based Authentication](#command-based-authentication) below. - If No: The API key prompt is skipped - **Available Models**: Comma-separated list of available model names - **Streaming Support**: Whether the API supports streaming responses @@ -523,6 +525,50 @@ Custom providers must use OpenAI, Anthropic, or Ollama compatible API formats. T +### Command-Based Authentication + +Instead of a static `api_key_env`, a custom provider can be configured to run a command to obtain its credential. This is useful for short-lived credentials issued by an IdP or key vault: goose re-runs the command to refresh the credential instead of requiring a restart when it expires. + +Add an `auth` object to the provider's JSON configuration in place of `api_key_env` (the two are mutually exclusive): + +```json +{ + "name": "custom_corp_api", + "engine": "openai", + "display_name": "Corporate API", + "base_url": "https://api.company.com/v1/chat/completions", + "models": [{ "name": "gpt-4o", "context_limit": 128000 }], + "requires_auth": true, + "auth": { + "command": "/path/to/get-token.sh", + "args": [], + "refresh_interval": 3600, + "timeout_seconds": 10 + } +} +``` + +- **`command`**: The executable to run. It is spawned directly, without a shell — none of `command`/`args` are shell-interpolated. If your script needs shell features (pipes, variable expansion), invoke an interpreter explicitly, e.g. `"command": "/bin/bash", "args": ["-c", "..."]`. A bare name (no path separator, e.g. `"get-token"`) is looked up on `PATH`; a relative path (e.g. `"./scripts/get-token.sh"`) is resolved against `cwd`. +- **`args`** (optional): Arguments passed to `command`. +- **`refresh_interval`** (optional, defaults to `3600`): How long, in seconds, a fetched credential is cached before the command is re-run. Set to `0` to disable proactive refresh entirely — the command then only reruns reactively, after the provider's API rejects a request with an auth error. +- **`timeout_seconds`** (optional, defaults to `10`): How long to wait for the command before treating it as failed. +- **`cwd`** (optional): Working directory for the command, and the base a relative `command` path is resolved against. Defaults to goose's current directory. + +The command's trimmed standard output is used as the credential. It must exit successfully and print a non-empty value; on failure, goose surfaces an error rather than silently reusing a stale credential. The command inherits goose's full environment, since the same user configures goose and writes the script. + + + + + In `goose configure`, choose **Command (refreshable)** when prompted for how to obtain credentials for a custom provider (see [above](#configure-custom-provider)). + + + + + Add the `auth` object shown above to the provider's JSON file instead of setting `api_key_env`. + + + + **To update a custom provider:**