feat(auth): enable refresh auth cli (#11657)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -420,6 +420,8 @@ pub fn from_declarative_config(
|
||||
));
|
||||
}
|
||||
|
||||
config.validate_auth()?;
|
||||
|
||||
let api_key = if config.api_key_env.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¶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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user