diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2798dcb92..93fd8edfc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1015,7 +1015,7 @@ impl CodexMessageProcessor { &mut self, params: &LoginApiKeyParams, ) -> std::result::Result<(), JSONRPCErrorError> { - if self.auth_manager.is_external_auth_active() { + if self.auth_manager.is_external_chatgpt_auth_active() { return Err(self.external_auth_active_error()); } @@ -1094,7 +1094,7 @@ impl CodexMessageProcessor { ) -> std::result::Result { let config = self.config.as_ref(); - if self.auth_manager.is_external_auth_active() { + if self.auth_manager.is_external_chatgpt_auth_active() { return Err(self.external_auth_active_error()); } @@ -1531,7 +1531,7 @@ impl CodexMessageProcessor { } async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { - if self.auth_manager.is_external_auth_active() { + if self.auth_manager.is_external_chatgpt_auth_active() { return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; } if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 37ed7421c..64bce2086 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -206,10 +206,13 @@ impl MessageProcessor { session_source, enable_codex_api_key_env, } = args; - let auth_manager = AuthManager::shared( + let auth_manager = AuthManager::shared_with_external_chatgpt_auth_refresher( config.codex_home.clone(), enable_codex_api_key_env, config.cli_auth_credentials_store_mode, + Arc::new(ExternalAuthRefreshBridge { + outgoing: outgoing.clone(), + }), ); let thread_manager = Arc::new(ThreadManager::new( config.as_ref(), @@ -223,9 +226,6 @@ impl MessageProcessor { environment_manager, )); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id.clone()); - auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { - outgoing: outgoing.clone(), - })); let analytics_events_client = AnalyticsEventsClient::new( Arc::clone(&auth_manager), config.chatgpt_base_url.trim_end_matches('/').to_string(), @@ -282,7 +282,7 @@ impl MessageProcessor { } pub(crate) fn clear_runtime_references(&self) { - self.auth_manager.clear_external_auth_refresher(); + self.auth_manager.clear_external_chatgpt_auth_refresher(); } pub(crate) async fn process_request( diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 0d473d2fc..53580c72d 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -8,10 +8,12 @@ use codex_protocol::account::PlanType as AccountPlanType; use base64::Engine; use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ModelProviderAuthInfo; use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::json; use std::sync::Arc; +use tempfile::TempDir; use tempfile::tempdir; #[tokio::test] @@ -265,6 +267,180 @@ fn external_auth_tokens_without_chatgpt_metadata_cannot_seed_chatgpt_auth() { ); } +#[tokio::test] +async fn external_bearer_only_auth_manager_uses_cached_provider_token() { + let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap(); + let manager = AuthManager::external_bearer_only(script.auth_config()); + + let first = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + let second = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + + assert_eq!(first.as_deref(), Some("provider-token")); + assert_eq!(second.as_deref(), Some("provider-token")); +} + +#[tokio::test] +async fn external_bearer_only_auth_manager_returns_none_when_command_fails() { + let script = ProviderAuthScript::new_failing().unwrap(); + let manager = AuthManager::external_bearer_only(script.auth_config()); + + assert_eq!(manager.auth().await, None); +} + +#[tokio::test] +async fn unauthorized_recovery_uses_external_refresh_for_bearer_manager() { + let script = ProviderAuthScript::new(&["provider-token", "refreshed-provider-token"]).unwrap(); + let manager = AuthManager::external_bearer_only(script.auth_config()); + let initial_token = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + let mut recovery = manager.unauthorized_recovery(); + + assert!(recovery.has_next()); + assert_eq!(recovery.mode_name(), "external"); + assert_eq!(recovery.step_name(), "external_refresh"); + + let result = recovery + .next() + .await + .expect("external refresh should succeed"); + + assert_eq!(result.auth_state_changed(), Some(true)); + let refreshed_token = manager + .auth() + .await + .and_then(|auth| auth.api_key().map(str::to_string)); + assert_eq!(initial_token.as_deref(), Some("provider-token")); + assert_eq!(refreshed_token.as_deref(), Some("refreshed-provider-token")); +} + +struct ProviderAuthScript { + tempdir: TempDir, + command: String, + args: Vec, +} + +impl ProviderAuthScript { + fn new(tokens: &[&str]) -> std::io::Result { + let tempdir = tempfile::tempdir()?; + let token_file = tempdir.path().join("tokens.txt"); + let mut token_file_contents = String::new(); + for token in tokens { + token_file_contents.push_str(token); + token_file_contents.push('\n'); + } + std::fs::write(&token_file, token_file_contents)?; + + #[cfg(unix)] + let (command, args) = { + let script_path = tempdir.path().join("print-token.sh"); + std::fs::write( + &script_path, + r#"#!/bin/sh +first_line=$(sed -n '1p' tokens.txt) +printf '%s\n' "$first_line" +tail -n +2 tokens.txt > tokens.next +mv tokens.next tokens.txt +"#, + )?; + let mut permissions = std::fs::metadata(&script_path)?.permissions(); + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + } + std::fs::set_permissions(&script_path, permissions)?; + ("./print-token.sh".to_string(), Vec::new()) + }; + + #[cfg(windows)] + let (command, args) = { + let script_path = tempdir.path().join("print-token.ps1"); + std::fs::write( + &script_path, + r#"$lines = Get-Content -Path tokens.txt +if ($lines.Count -eq 0) { exit 1 } +Write-Output $lines[0] +$lines | Select-Object -Skip 1 | Set-Content -Path tokens.txt +"#, + )?; + ( + "powershell".to_string(), + vec![ + "-NoProfile".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-File".to_string(), + ".\\print-token.ps1".to_string(), + ], + ) + }; + + Ok(Self { + tempdir, + command, + args, + }) + } + + fn new_failing() -> std::io::Result { + let tempdir = tempfile::tempdir()?; + + #[cfg(unix)] + let (command, args) = { + let script_path = tempdir.path().join("fail.sh"); + std::fs::write( + &script_path, + r#"#!/bin/sh +exit 1 +"#, + )?; + let mut permissions = std::fs::metadata(&script_path)?.permissions(); + { + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + } + std::fs::set_permissions(&script_path, permissions)?; + ("./fail.sh".to_string(), Vec::new()) + }; + + #[cfg(windows)] + let (command, args) = ( + "powershell".to_string(), + vec![ + "-NoProfile".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-Command".to_string(), + "exit 1".to_string(), + ], + ); + + Ok(Self { + tempdir, + command, + args, + }) + } + + fn auth_config(&self) -> ModelProviderAuthInfo { + serde_json::from_value(json!({ + "command": self.command, + "args": self.args, + "timeout_ms": 1000, + "refresh_interval_ms": 60000, + "cwd": self.tempdir.path(), + })) + .expect("provider auth config should deserialize") + } +} + struct AuthFileParams { openai_api_key: Option, chatgpt_plan_type: Option, diff --git a/codex-rs/login/src/auth/external_bearer.rs b/codex-rs/login/src/auth/external_bearer.rs new file mode 100644 index 000000000..4cc94e6ec --- /dev/null +++ b/codex-rs/login/src/auth/external_bearer.rs @@ -0,0 +1,145 @@ +use codex_protocol::config_types::ModelProviderAuthInfo; +use std::fmt; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Instant; +use tokio::process::Command; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub(crate) struct ExternalBearerAuth { + state: Arc, +} + +impl ExternalBearerAuth { + pub(crate) fn new(config: ModelProviderAuthInfo) -> Self { + Self { + state: Arc::new(ExternalBearerAuthState::new(config)), + } + } + + pub(crate) async fn resolve_access_token(&self) -> io::Result { + let mut cached = self.state.cached_token.lock().await; + if let Some(cached_token) = cached.as_ref() + && cached_token.fetched_at.elapsed() < self.state.config.refresh_interval() + { + return Ok(cached_token.access_token.clone()); + } + + let access_token = run_provider_auth_command(&self.state.config).await?; + *cached = Some(CachedExternalBearerToken { + access_token: access_token.clone(), + fetched_at: Instant::now(), + }); + Ok(access_token) + } + + pub(crate) async fn refresh_after_unauthorized(&self) -> io::Result<()> { + let access_token = run_provider_auth_command(&self.state.config).await?; + let mut cached = self.state.cached_token.lock().await; + *cached = Some(CachedExternalBearerToken { + access_token, + fetched_at: Instant::now(), + }); + Ok(()) + } +} + +impl fmt::Debug for ExternalBearerAuth { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ExternalBearerAuth").finish_non_exhaustive() + } +} + +struct ExternalBearerAuthState { + config: ModelProviderAuthInfo, + cached_token: Mutex>, +} + +impl ExternalBearerAuthState { + fn new(config: ModelProviderAuthInfo) -> Self { + Self { + config, + cached_token: Mutex::new(None), + } + } +} + +struct CachedExternalBearerToken { + access_token: String, + fetched_at: Instant, +} + +async fn run_provider_auth_command(config: &ModelProviderAuthInfo) -> io::Result { + let program = resolve_provider_auth_program(&config.command, &config.cwd)?; + let mut command = Command::new(&program); + command + .args(&config.args) + .current_dir(config.cwd.as_path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + + let output = tokio::time::timeout(config.timeout(), command.output()) + .await + .map_err(|_| { + io::Error::other(format!( + "provider auth command `{}` timed out after {} ms", + config.command, + config.timeout_ms.get() + )) + })? + .map_err(|err| { + io::Error::other(format!( + "provider auth command `{}` failed to start: {err}", + config.command + )) + })?; + + if !output.status.success() { + let status = output.status; + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stderr_suffix = if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + }; + return Err(io::Error::other(format!( + "provider auth command `{}` exited with status {status}{stderr_suffix}", + config.command + ))); + } + + let stdout = String::from_utf8(output.stdout).map_err(|_| { + io::Error::other(format!( + "provider auth command `{}` wrote non-UTF-8 data to stdout", + config.command + )) + })?; + let access_token = stdout.trim().to_string(); + if access_token.is_empty() { + return Err(io::Error::other(format!( + "provider auth command `{}` produced an empty token", + config.command + ))); + } + + Ok(access_token) +} + +fn resolve_provider_auth_program(command: &str, cwd: &Path) -> io::Result { + let path = Path::new(command); + if path.is_absolute() { + return Ok(path.to_path_buf()); + } + + if path.components().count() > 1 { + return Ok(cwd.join(path)); + } + + Ok(PathBuf::from(command)) +} diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 9d1080c07..d13106cdd 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -16,7 +16,9 @@ use tokio::sync::Mutex as AsyncMutex; use codex_app_server_protocol::AuthMode as ApiAuthMode; use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ModelProviderAuthInfo; +use super::external_bearer::ExternalBearerAuth; use crate::auth::error::RefreshTokenFailedError; use crate::auth::error::RefreshTokenFailedReason; pub use crate::auth::storage::AuthCredentialsStoreMode; @@ -840,8 +842,6 @@ impl AuthDotJson { #[derive(Clone)] struct CachedAuth { auth: Option, - /// Callback used to refresh external auth by asking the parent app for new tokens. - external_refresher: Option>, /// Permanent refresh failure cached for the current auth snapshot so /// later refresh attempts for the same credentials fail fast without network. permanent_refresh_failure: Option, @@ -853,6 +853,27 @@ struct AuthScopedRefreshFailure { error: RefreshTokenFailedError, } +#[derive(Clone)] +enum ExternalAuth { + Bearer(ExternalBearerAuth), + ChatgptRefresher(Arc), +} + +impl Debug for ExternalAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Bearer(_) => f + .debug_tuple("ExternalAuth::Bearer") + .field(&"present") + .finish(), + Self::ChatgptRefresher(_) => f + .debug_tuple("ExternalAuth::ChatgptRefresher") + .field(&"present") + .finish(), + } + } +} + impl Debug for CachedAuth { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CachedAuth") @@ -860,10 +881,6 @@ impl Debug for CachedAuth { "auth_mode", &self.auth.as_ref().map(CodexAuth::api_auth_mode), ) - .field( - "external_refresher", - &self.external_refresher.as_ref().map(|_| "present"), - ) .field( "permanent_refresh_failure", &self @@ -907,9 +924,14 @@ enum UnauthorizedRecoveryMode { // 2. Attempt to refresh the token using OAuth token refresh flow. // If after both steps the server still responds with 401 we let the error bubble to the user. // -// For external ChatGPT auth tokens (chatgptAuthTokens), UnauthorizedRecovery does not touch disk or refresh -// tokens locally. Instead it calls the ExternalAuthRefresher (account/chatgptAuthTokens/refresh) to ask the -// parent app for new tokens, stores them in the ephemeral auth store, and retries once. +// For external auth sources, UnauthorizedRecovery retries once. +// +// - External ChatGPT auth tokens (`chatgptAuthTokens`) are refreshed by asking +// the parent app for new tokens through the configured +// `ExternalAuthRefresher`, persisting them in the ephemeral auth store, and +// reloading the cached auth snapshot. +// - External bearer auth sources for custom model providers rerun the provider +// auth command without touching disk. pub struct UnauthorizedRecovery { manager: Arc, step: UnauthorizedRecoveryStep, @@ -932,9 +954,10 @@ impl UnauthorizedRecovery { fn new(manager: Arc) -> Self { let cached_auth = manager.auth_cached(); let expected_account_id = cached_auth.as_ref().and_then(CodexAuth::get_account_id); - let mode = if cached_auth - .as_ref() - .is_some_and(CodexAuth::is_external_chatgpt_tokens) + let mode = if manager.has_external_bearer_auth() + || cached_auth + .as_ref() + .is_some_and(CodexAuth::is_external_chatgpt_tokens) { UnauthorizedRecoveryMode::External } else { @@ -953,6 +976,10 @@ impl UnauthorizedRecovery { } pub fn has_next(&self) -> bool { + if self.manager.has_external_bearer_auth() { + return !matches!(self.step, UnauthorizedRecoveryStep::Done); + } + if !self .manager .auth_cached() @@ -963,7 +990,7 @@ impl UnauthorizedRecovery { } if self.mode == UnauthorizedRecoveryMode::External - && !self.manager.has_external_auth_refresher() + && !self.manager.has_external_chatgpt_auth_refresher() { return false; } @@ -972,6 +999,14 @@ impl UnauthorizedRecovery { } pub fn unavailable_reason(&self) -> &'static str { + if self.manager.has_external_bearer_auth() { + return if matches!(self.step, UnauthorizedRecoveryStep::Done) { + "recovery_exhausted" + } else { + "ready" + }; + } + if !self .manager .auth_cached() @@ -982,7 +1017,7 @@ impl UnauthorizedRecovery { } if self.mode == UnauthorizedRecoveryMode::External - && !self.manager.has_external_auth_refresher() + && !self.manager.has_external_chatgpt_auth_refresher() { return "no_external_refresher"; } @@ -1085,6 +1120,7 @@ pub struct AuthManager { auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: RwLock>, refresh_lock: AsyncMutex<()>, + external_auth: RwLock>, } impl AuthManager { @@ -1108,13 +1144,13 @@ impl AuthManager { codex_home, inner: RwLock::new(CachedAuth { auth: managed_auth, - external_refresher: None, permanent_refresh_failure: None, }), enable_codex_api_key_env, auth_credentials_store_mode, forced_chatgpt_workspace_id: RwLock::new(None), refresh_lock: AsyncMutex::new(()), + external_auth: RwLock::new(None), } } @@ -1122,7 +1158,6 @@ impl AuthManager { pub fn from_auth_for_testing(auth: CodexAuth) -> Arc { let cached = CachedAuth { auth: Some(auth), - external_refresher: None, permanent_refresh_failure: None, }; @@ -1133,6 +1168,7 @@ impl AuthManager { auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), refresh_lock: AsyncMutex::new(()), + external_auth: RwLock::new(None), }) } @@ -1140,7 +1176,6 @@ impl AuthManager { pub fn from_auth_for_testing_with_home(auth: CodexAuth, codex_home: PathBuf) -> Arc { let cached = CachedAuth { auth: Some(auth), - external_refresher: None, permanent_refresh_failure: None, }; Arc::new(Self { @@ -1150,6 +1185,22 @@ impl AuthManager { auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), refresh_lock: AsyncMutex::new(()), + external_auth: RwLock::new(None), + }) + } + + pub fn external_bearer_only(config: ModelProviderAuthInfo) -> Arc { + Arc::new(Self { + codex_home: PathBuf::from("non-existent"), + inner: RwLock::new(CachedAuth { + auth: None, + permanent_refresh_failure: None, + }), + enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, + forced_chatgpt_workspace_id: RwLock::new(None), + refresh_lock: AsyncMutex::new(()), + external_auth: RwLock::new(Some(ExternalAuth::Bearer(ExternalBearerAuth::new(config)))), }) } @@ -1172,6 +1223,10 @@ impl AuthManager { /// For stale managed ChatGPT auth, first performs a guarded reload and then /// refreshes only if the on-disk auth is unchanged. pub async fn auth(&self) -> Option { + if let Some(auth) = self.resolve_external_bearer_auth().await { + return Some(auth); + } + let auth = self.auth_cached()?; if Self::is_stale_for_proactive_refresh(&auth) && let Err(err) = self.refresh_token().await @@ -1291,15 +1346,15 @@ impl AuthManager { } } - pub fn set_external_auth_refresher(&self, refresher: Arc) { - if let Ok(mut guard) = self.inner.write() { - guard.external_refresher = Some(refresher); + pub fn set_external_chatgpt_auth_refresher(&self, refresher: Arc) { + if let Ok(mut guard) = self.external_auth.write() { + *guard = Some(ExternalAuth::ChatgptRefresher(refresher)); } } - pub fn clear_external_auth_refresher(&self) { - if let Ok(mut guard) = self.inner.write() { - guard.external_refresher = None; + pub fn clear_external_chatgpt_auth_refresher(&self) { + if let Ok(mut guard) = self.external_auth.write() { + *guard = None; } } @@ -1316,15 +1371,15 @@ impl AuthManager { .and_then(|guard| guard.clone()) } - pub fn has_external_auth_refresher(&self) -> bool { - self.inner + pub fn has_external_chatgpt_auth_refresher(&self) -> bool { + self.external_auth .read() .ok() - .map(|guard| guard.external_refresher.is_some()) + .map(|guard| matches!(guard.as_ref(), Some(ExternalAuth::ChatgptRefresher(_)))) .unwrap_or(false) } - pub fn is_external_auth_active(&self) -> bool { + pub fn is_external_chatgpt_auth_active(&self) -> bool { self.auth_cached() .as_ref() .is_some_and(CodexAuth::is_external_chatgpt_tokens) @@ -1347,10 +1402,50 @@ impl AuthManager { )) } + pub fn shared_with_external_chatgpt_auth_refresher( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + refresher: Arc, + ) -> Arc { + let manager = Self::shared( + codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + ); + manager.set_external_chatgpt_auth_refresher(refresher); + manager + } + pub fn unauthorized_recovery(self: &Arc) -> UnauthorizedRecovery { UnauthorizedRecovery::new(Arc::clone(self)) } + fn external_auth(&self) -> Option { + self.external_auth + .read() + .ok() + .and_then(|guard| guard.clone()) + } + + fn has_external_bearer_auth(&self) -> bool { + matches!(self.external_auth(), Some(ExternalAuth::Bearer(_))) + } + + async fn resolve_external_bearer_auth(&self) -> Option { + let ExternalAuth::Bearer(bearer_auth) = self.external_auth()? else { + return None; + }; + + match bearer_auth.resolve_access_token().await { + Ok(access_token) => Some(CodexAuth::from_api_key(&access_token)), + Err(err) => { + tracing::error!("Failed to resolve external bearer auth: {err}"); + None + } + } + } + /// Attempt to refresh the token by first performing a guarded reload. Auth /// is reloaded from storage only when the account id matches the currently /// cached account id. If the persisted token differs from the cached token, we @@ -1439,10 +1534,16 @@ impl AuthManager { } pub fn get_api_auth_mode(&self) -> Option { + if self.has_external_bearer_auth() { + return Some(ApiAuthMode::ApiKey); + } self.auth_cached().as_ref().map(CodexAuth::api_auth_mode) } pub fn auth_mode(&self) -> Option { + if self.has_external_bearer_auth() { + return Some(crate::AuthMode::ApiKey); + } self.auth_cached().as_ref().map(CodexAuth::auth_mode) } @@ -1472,17 +1573,15 @@ impl AuthManager { &self, reason: ExternalAuthRefreshReason, ) -> Result<(), RefreshTokenError> { - let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); - let refresher = match self.inner.read() { - Ok(guard) => guard.external_refresher.clone(), - Err(_) => { - return Err(RefreshTokenError::Transient(std::io::Error::other( - "failed to read external auth state", - ))); - } - }; + if let Some(ExternalAuth::Bearer(bearer_auth)) = self.external_auth() { + return bearer_auth + .refresh_after_unauthorized() + .await + .map_err(RefreshTokenError::Transient); + } - let Some(refresher) = refresher else { + let forced_chatgpt_workspace_id = self.forced_chatgpt_workspace_id(); + let Some(ExternalAuth::ChatgptRefresher(refresher)) = self.external_auth() else { return Err(RefreshTokenError::Transient(std::io::Error::other( "external auth refresher is not configured", ))); diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 42c0fb24c..256cf16a8 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -3,6 +3,7 @@ pub mod error; mod storage; mod util; +mod external_bearer; mod manager; pub use error::RefreshTokenFailedError; diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 5586b9331..52178296b 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -1,6 +1,9 @@ +use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use std::num::NonZeroU64; +use std::time::Duration; use strum_macros::Display; use strum_macros::EnumIter; use ts_rs::TS; @@ -261,6 +264,81 @@ pub enum ForcedLoginMethod { Api, } +const DEFAULT_PROVIDER_AUTH_TIMEOUT_MS: u64 = 5_000; +const DEFAULT_PROVIDER_AUTH_REFRESH_INTERVAL_MS: u64 = 300_000; + +/// Configuration for obtaining a provider bearer token from a command. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ModelProviderAuthInfo { + /// Command to execute. Bare names are resolved via `PATH`; paths are resolved against `cwd`. + pub command: String, + + /// Command arguments. + #[serde(default)] + pub args: Vec, + + /// Maximum time to wait for the token command to exit successfully. + #[serde(default = "default_provider_auth_timeout_ms")] + pub timeout_ms: NonZeroU64, + + /// Maximum age for the cached token before rerunning the command. + #[serde(default = "default_provider_auth_refresh_interval_ms")] + pub refresh_interval_ms: NonZeroU64, + + /// Working directory used when running the token command. + #[serde(default = "default_provider_auth_cwd")] + #[schemars(skip_serializing_if = "is_default_provider_auth_cwd")] + pub cwd: AbsolutePathBuf, +} + +impl ModelProviderAuthInfo { + pub fn timeout(&self) -> Duration { + Duration::from_millis(self.timeout_ms.get()) + } + + pub fn refresh_interval(&self) -> Duration { + Duration::from_millis(self.refresh_interval_ms.get()) + } +} + +fn default_provider_auth_timeout_ms() -> NonZeroU64 { + non_zero_u64( + DEFAULT_PROVIDER_AUTH_TIMEOUT_MS, + "model_providers..auth.timeout_ms", + ) +} + +fn default_provider_auth_refresh_interval_ms() -> NonZeroU64 { + non_zero_u64( + DEFAULT_PROVIDER_AUTH_REFRESH_INTERVAL_MS, + "model_providers..auth.refresh_interval_ms", + ) +} + +fn non_zero_u64(value: u64, field_name: &str) -> NonZeroU64 { + match NonZeroU64::new(value) { + Some(value) => value, + None => panic!("{field_name} must be non-zero"), + } +} + +fn default_provider_auth_cwd() -> AbsolutePathBuf { + let deserializer = serde::de::value::StrDeserializer::::new("."); + if let Ok(cwd) = AbsolutePathBuf::deserialize(deserializer) { + return cwd; + } + + match AbsolutePathBuf::current_dir() { + Ok(cwd) => cwd, + Err(err) => panic!("provider auth cwd must resolve: {err}"), + } +} + +fn is_default_provider_auth_cwd(path: &AbsolutePathBuf) -> bool { + path == &default_provider_auth_cwd() +} + /// Represents the trust level for a project directory. /// This determines the approval policy and sandbox mode applied. #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]