feat(auth): enable refresh auth cli (#11657)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Leon Bowie
2026-09-01 01:42:11 +00:00
committed by GitHub
parent 35897f6984
commit 0a9749b1cb
19 changed files with 985 additions and 21 deletions
+64 -6
View File
@@ -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<AuthConfig> = 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<String> = 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::<u64>()
.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() {
+2
View File
@@ -420,6 +420,8 @@ pub fn from_declarative_config(
));
}
config.validate_auth()?;
let api_key = if config.api_key_env.is_empty() {
None
} else {
+7
View File
@@ -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> {
self.transport_policy = TransportPolicy::HttpsOnly;
self.rebuild_client()?;
+45
View File
@@ -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<String>,
/// 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<u64>,
/// 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<String>,
}
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<String>,
#[serde(default)]
pub env_vars: Option<Vec<EnvVarConfig>>,
/// 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<AuthConfig>,
/// 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 {
+3
View File
@@ -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,
+3
View File
@@ -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,
+16 -2
View File
@@ -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")?;
@@ -145,6 +145,8 @@ pub struct CreateCustomProviderParams {
pub catalog_provider_id: Option<String>,
pub base_path: Option<String>,
pub preserves_thinking: Option<bool>,
/// Alternative to `api_key`; mutually exclusive with it.
pub auth: Option<AuthConfig>,
}
#[derive(Debug, Clone)]
@@ -161,6 +163,8 @@ pub struct UpdateCustomProviderParams {
pub catalog_provider_id: Option<String>,
pub base_path: Option<String>,
pub preserves_thinking: Option<bool>,
/// Alternative to `api_key`; mutually exclusive with it.
pub auth: Option<AuthConfig>,
}
pub fn create_custom_provider(
@@ -169,7 +173,18 @@ pub fn create_custom_provider(
let id = generate_id(&params.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(&params.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(&params.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();
+14 -3
View File
@@ -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<TlsConfig>,
) -> Result<AnthropicProvider> {
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,
+475
View File
@@ -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<String>,
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<RwLock<Option<CachedCredential>>>,
}
impl CommandAuthProvider {
pub fn new(
auth_config: &AuthConfig,
header_name: impl Into<String>,
header_value_prefix: impl Into<String>,
) -> 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<String> {
// 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<String>,
args: Vec<impl Into<String>>,
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<String>) {
("sleep", vec![seconds.to_string()])
}
#[cfg(windows)]
fn sleep_seconds_command(seconds: u64) -> (&'static str, Vec<String>) {
(
"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<String>) {
("sh", vec!["-c".to_string(), format!("echo {text}; exit 1")])
}
#[cfg(windows)]
fn fail_after_printing_command(text: &str) -> (&'static str, Vec<String>) {
(
"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()
);
}
}
+26 -1
View File
@@ -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,
@@ -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)
}
+1
View File
@@ -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;
@@ -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,
+13 -3
View File
@@ -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<crate::providers::api_client::TlsConfig>,
) -> Result<OllamaProvider> {
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()
+10 -2
View File
@@ -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<goose_providers::api_client::TlsConfig>,
) -> Result<OpenAiProvider> {
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()
})
@@ -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,
@@ -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<Mutex<Vec<String>>> = 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<Mutex<Vec<String>>> = 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"
);
}
@@ -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
</TabItem>
</Tabs>
### 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.
<Tabs groupId="interface">
<TabItem value="cli" label="goose CLI" default>
In `goose configure`, choose **Command (refreshable)** when prompted for how to obtain credentials for a custom provider (see [above](#configure-custom-provider)).
</TabItem>
<TabItem value="config" label="Config File">
Add the `auth` object shown above to the provider's JSON file instead of setting `api_key_env`.
</TabItem>
</Tabs>
**To update a custom provider:**
<Tabs groupId="interface">