diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index a4bbe42d61..a99ec93c2e 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -335,6 +335,12 @@ impl AccountRequestProcessor { config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, ) + .with_auth_route_config( + config + .system_proxy + .as_ref() + .map(codex_login::auth_route_config_from_system_proxy_config), + ) }; #[cfg(debug_assertions)] let opts = { diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 4e63df187e..88171aa567 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; pub use debug_sandbox::run_command_under_windows_sandbox; +pub use login::login_server_options_from_config; pub use login::read_access_token_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index b3fc1bd544..a114825fec 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -10,12 +10,13 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; +use codex_login::AuthManager; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; -use codex_login::login_with_access_token; +use codex_login::auth_route_config_from_system_proxy_config; +use codex_login::login_with_access_token_with_auth_route_config; use codex_login::login_with_api_key; -use codex_login::logout_with_revoke; use codex_login::run_device_code_login; use codex_login::run_login_server; use codex_protocol::config_types::ForcedLoginMethod; @@ -113,17 +114,34 @@ fn print_login_server_start(actual_port: u16, auth_url: &str) { ); } +pub fn login_server_options_from_config(config: &Config) -> ServerOptions { + ServerOptions::new( + config.codex_home.to_path_buf(), + CLIENT_ID.to_string(), + config.forced_chatgpt_workspace_id.clone(), + config.cli_auth_credentials_store_mode, + ) + .with_auth_route_config( + config + .system_proxy + .as_ref() + .map(auth_route_config_from_system_proxy_config), + ) +} + pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + system_proxy: Option<&codex_config::types::SystemProxyFeatureConfigToml>, ) -> std::io::Result<()> { let opts = ServerOptions::new( codex_home, CLIENT_ID.to_string(), forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, - ); + ) + .with_auth_route_config(system_proxy.map(auth_route_config_from_system_proxy_config)); let server = run_login_server(opts)?; print_login_server_start(server.actual_port, &server.auth_url); @@ -147,6 +165,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> config.codex_home.to_path_buf(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + config.system_proxy.as_ref(), ) .await { @@ -195,6 +214,7 @@ pub async fn run_login_with_access_token( access_token: String, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let login_server_opts = login_server_options_from_config(&config); let _login_log_guard = init_login_file_logging(&config); tracing::info!("starting access token login flow"); @@ -203,11 +223,12 @@ pub async fn run_login_with_access_token( std::process::exit(1); } - match login_with_access_token( + match login_with_access_token_with_auth_route_config( &config.codex_home, &access_token, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), + login_server_opts.auth_route_config.as_ref(), ) .await { @@ -282,6 +303,12 @@ pub async fn run_login_with_device_code( client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + ) + .with_auth_route_config( + config + .system_proxy + .as_ref() + .map(auth_route_config_from_system_proxy_config), ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -321,6 +348,12 @@ pub async fn run_login_with_device_code_fallback_to_browser( client_id.unwrap_or(CLIENT_ID.to_string()), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, + ) + .with_auth_route_config( + config + .system_proxy + .as_ref() + .map(auth_route_config_from_system_proxy_config), ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -364,11 +397,13 @@ pub async fn run_login_with_device_code_fallback_to_browser( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let login_server_opts = login_server_options_from_config(&config); - match CodexAuth::from_auth_storage( + match CodexAuth::from_auth_storage_with_auth_route_config( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), + login_server_opts.auth_route_config.as_ref(), ) .await { @@ -409,8 +444,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let auth_manager = + AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; - match logout_with_revoke(&config.codex_home, config.cli_auth_credentials_store_mode).await { + match auth_manager.logout_with_revoke().await { Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 0d1cae353d..b00f14d1b4 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,6 +10,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; +use codex_cli::login_server_options_from_config; use codex_cli::read_access_token_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; @@ -1678,9 +1679,13 @@ async fn load_exec_server_remote_auth_provider( let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; - let auth = - CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) - .await?; + let login_server_opts = login_server_options_from_config(config); + let auth = CodexAuth::from_agent_identity_jwt_with_auth_route_config( + &agent_identity_jwt, + Some(&config.chatgpt_base_url), + login_server_opts.auth_route_config.as_ref(), + ) + .await?; return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index c269460c1f..d509ede769 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -6,6 +6,7 @@ use codex_config::CloudConfigBundleLoadErrorCode; use codex_config::CloudConfigBundleLoader; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -57,12 +58,43 @@ pub async fn cloud_config_bundle_loader_for_storage( credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: String, ) -> CloudConfigBundleLoader { - let auth_manager = AuthManager::shared( - codex_home.clone(), + cloud_config_bundle_loader_for_storage_with_auth_route_config( + codex_home, enable_codex_api_key_env, credentials_store_mode, - Some(chatgpt_base_url.clone()), + chatgpt_base_url, + /*auth_route_config*/ None, ) - .await; + .await +} + +/// Loads cloud config with the explicit startup-only auth route policy. +pub async fn cloud_config_bundle_loader_for_storage_with_auth_route_config( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: String, + auth_route_config: Option, +) -> CloudConfigBundleLoader { + let auth_manager = if let Some(auth_route_config) = auth_route_config { + Arc::new( + AuthManager::new_with_auth_route_config( + codex_home.clone(), + enable_codex_api_key_env, + credentials_store_mode, + Some(chatgpt_base_url.clone()), + Some(auth_route_config), + ) + .await, + ) + } else { + AuthManager::shared( + codex_home.clone(), + enable_codex_api_key_env, + credentials_store_mode, + Some(chatgpt_base_url.clone()), + ) + .await + }; cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) } diff --git a/codex-rs/cloud-config/src/lib.rs b/codex-rs/cloud-config/src/lib.rs index 0e742f5522..9a3eeeeb71 100644 --- a/codex-rs/cloud-config/src/lib.rs +++ b/codex-rs/cloud-config/src/lib.rs @@ -12,3 +12,4 @@ mod validation; pub use bundle_loader::cloud_config_bundle_loader; pub use bundle_loader::cloud_config_bundle_loader_for_storage; +pub use bundle_loader::cloud_config_bundle_loader_for_storage_with_auth_route_config; diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 9a5056aa66..da4619f59e 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -44,15 +44,29 @@ pub fn normalize_base_url(input: &str) -> String { pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option { // TODO: pass in cli overrides once cloud tasks properly support them. let config = Config::load_with_cli_overrides(Vec::new()).await.ok()?; - Some( + let auth_route_config = codex_login::bootstrap_auth_route_config_from_system_proxy_config( + config.system_proxy.as_ref(), + ); + let chatgpt_base_url = chatgpt_base_url.or(Some(config.chatgpt_base_url)); + Some(if let Some(auth_route_config) = auth_route_config { + // Keep the existing cloud auth path unless platform system proxy discovery is selected. + AuthManager::new_with_auth_route_config( + config.codex_home.to_path_buf(), + /*enable_codex_api_key_env*/ false, + config.cli_auth_credentials_store_mode, + chatgpt_base_url, + Some(auth_route_config), + ) + .await + } else { AuthManager::new( config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config.cli_auth_credentials_store_mode, - chatgpt_base_url.or(Some(config.chatgpt_base_url)), + chatgpt_base_url, ) - .await, - ) + .await + }) } /// Build headers for ChatGPT-backed requests: `User-Agent`, optional `Authorization`, diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 0f503fb3e2..6bb0aac38b 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -3,6 +3,7 @@ mod chatgpt_hosts; mod custom_ca; mod default_client; mod error; +mod outbound_proxy; mod request; mod retry; mod sse; @@ -25,6 +26,12 @@ pub use crate::default_client::CodexHttpClient; pub use crate::default_client::CodexRequestBuilder; pub use crate::error::StreamError; pub use crate::error::TransportError; +pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; +pub use crate::outbound_proxy::ClientRouteClass; +pub use crate::outbound_proxy::OutboundProxyConfig; +pub use crate::outbound_proxy::OutboundProxyMode; +pub use crate::outbound_proxy::RouteFailureClass; +pub use crate::outbound_proxy::build_reqwest_client_for_route; pub use crate::request::PreparedRequestBody; pub use crate::request::Request; pub use crate::request::RequestBody; diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/codex-client/src/outbound_proxy.rs new file mode 100644 index 0000000000..4864dcd65a --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -0,0 +1,570 @@ +//! Conservative outbound proxy selection for resolver-aware clients. +//! +//! This module keeps the default path as close as possible to the existing +//! reqwest builder behavior. For `Auto`, platform system discovery is tried +//! first, explicit environment proxies are delegated back to reqwest as the +//! fallback, and the final fallback is the legacy builder behavior. + +use std::collections::HashMap; +use std::fmt; +use std::io; +use std::sync::Mutex; +use std::sync::OnceLock; +use std::time::Duration; +use std::time::Instant; + +use crate::custom_ca::BuildCustomCaTransportError; +use crate::custom_ca::build_reqwest_client_with_custom_ca; +use thiserror::Error; + +const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60); + +/// Environment kill switch reserved for system proxy discovery. +/// +/// Values such as `off`, `false`, `0`, `no`, or `disabled` disable system/PAC +/// discovery while still allowing explicit environment proxies to be honored by +/// resolver-aware clients. +const CODEX_SYSTEM_PROXY_ENV: &str = "CODEX_SYSTEM_PROXY"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SystemProxyEnvOverride { + Default, + Disabled, +} + +impl SystemProxyEnvOverride { + fn from_value(value: Option<&str>) -> Self { + let Some(value) = value else { + return Self::Default; + }; + match value.trim().to_ascii_lowercase().as_str() { + "off" | "false" | "0" | "no" | "disabled" => Self::Disabled, + _ => Self::Default, + } + } + + fn from_env() -> Self { + Self::from_value( + /*value*/ std::env::var(CODEX_SYSTEM_PROXY_ENV).ok().as_deref(), + ) + } + + const fn system_discovery_enabled(self) -> bool { + matches!(self, Self::Default) + } +} + +/// High-level client path being routed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientRouteClass { + Auth, + Api, + WebSocket, + Other, +} + +impl fmt::Display for ClientRouteClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Auth => "auth", + Self::Api => "api", + Self::WebSocket => "wss", + Self::Other => "other", + }) + } +} + +/// Coarse failure class for route selection errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteFailureClass { + ProxyResolutionUnavailable, + ConnectTimeout, + ProxyAuthenticationRequired, + TlsError, + InvalidProxyConfig, + UnsupportedProxyScheme, + ResolverError, +} + +impl fmt::Display for RouteFailureClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable", + Self::ConnectTimeout => "connect_timeout", + Self::ProxyAuthenticationRequired => "proxy_407", + Self::TlsError => "tls_error", + Self::InvalidProxyConfig => "invalid_proxy_config", + Self::UnsupportedProxyScheme => "unsupported_proxy_scheme", + Self::ResolverError => "resolver_error", + }) + } +} + +/// How a resolver-aware client should choose an outbound proxy. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum OutboundProxyMode { + /// Try system discovery where supported, then explicit env proxy settings, then legacy behavior. + #[default] + Auto, + /// Preserve only the existing reqwest/env proxy path. + Env, + /// Require supported system discovery, including WPAD auto-detect on supported platforms. + System, + /// Disable proxy use for this client. + Direct, +} + +/// Optional route-selection inputs for resolver-aware clients. +/// +/// This is intentionally policy-only. Resolved proxy endpoints, PAC URLs, and +/// platform resolver details stay internal to the client builder. +#[derive(Default, Clone, PartialEq, Eq)] +pub struct OutboundProxyConfig { + mode: OutboundProxyMode, +} + +impl OutboundProxyConfig { + pub const fn new(mode: OutboundProxyMode) -> Self { + Self { mode } + } + + pub const fn mode(&self) -> OutboundProxyMode { + self.mode + } +} + +impl fmt::Debug for OutboundProxyConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OutboundProxyConfig") + .field("mode", &self.mode) + .finish() + } +} + +/// Error while building a resolver-aware reqwest client. +#[derive(Debug, Error)] +pub enum BuildRouteAwareHttpClientError { + #[error(transparent)] + CustomCa(#[from] BuildCustomCaTransportError), + + #[error("Failed to configure outbound proxy selected for {route_class}")] + InvalidProxyConfig { route_class: ClientRouteClass }, + + #[error("System proxy resolution for {route_class} failed: {failure}")] + SystemProxyUnavailable { + route_class: ClientRouteClass, + failure: RouteFailureClass, + }, +} + +impl From for io::Error { + fn from(error: BuildRouteAwareHttpClientError) -> Self { + match error { + BuildRouteAwareHttpClientError::CustomCa(error) => error.into(), + BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } + | BuildRouteAwareHttpClientError::SystemProxyUnavailable { .. } => { + io::Error::other(error) + } + } + } +} + +/// Builds a reqwest client with conservative route selection and shared CA handling. +pub fn build_reqwest_client_for_route( + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, +) -> Result { + let builder = configure_proxy_for_route(builder, request_url, route_class, config)?; + build_reqwest_client_with_custom_ca(builder).map_err(Into::into) +} + +fn configure_proxy_for_route( + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, +) -> Result { + let Some(config) = config else { + return Ok(builder); + }; + let origin = RequestOrigin::parse(request_url); + + if config.mode == OutboundProxyMode::Direct { + return Ok(builder.no_proxy()); + } + + // Route contract: OS/PAC bypass decisions are returned by platform + // resolution as `Direct` and disable proxy use for this client. Env + // `NO_PROXY` is an env-derived direct decision; reqwest remains the + // authority for applying the env proxy contract to its own env proxies. + if origin.as_ref().is_some_and(no_proxy_env_matches_origin) { + if config.mode == OutboundProxyMode::Env { + return Ok(builder.no_proxy()); + } + return Ok(builder); + } + + if config.mode == OutboundProxyMode::Env { + return configure_env_proxy_handling( + builder, + origin.as_ref(), + route_class, + EnvProxyHandling::ResolveConcreteOrNoProxy, + ); + } + + if !SystemProxyEnvOverride::from_env().system_discovery_enabled() { + if config.mode == OutboundProxyMode::System { + return Err(BuildRouteAwareHttpClientError::SystemProxyUnavailable { + route_class, + failure: RouteFailureClass::ProxyResolutionUnavailable, + }); + } + return configure_env_proxy_handling( + builder, + origin.as_ref(), + route_class, + EnvProxyHandling::DelegateToReqwest, + ); + } + + if !system_proxy_supported() { + if config.mode == OutboundProxyMode::System { + return Err(BuildRouteAwareHttpClientError::SystemProxyUnavailable { + route_class, + failure: RouteFailureClass::ProxyResolutionUnavailable, + }); + } + return configure_env_proxy_handling( + builder, + origin.as_ref(), + route_class, + EnvProxyHandling::DelegateToReqwest, + ); + } + + let Some(origin) = origin.as_ref() else { + return if config.mode == OutboundProxyMode::System { + Err(BuildRouteAwareHttpClientError::SystemProxyUnavailable { + route_class, + failure: RouteFailureClass::InvalidProxyConfig, + }) + } else { + configure_env_proxy_handling( + builder, + None, + route_class, + EnvProxyHandling::DelegateToReqwest, + ) + }; + }; + + let include_auto_detect = matches!( + config.mode, + OutboundProxyMode::Auto | OutboundProxyMode::System + ); + match resolve_system_proxy(request_url, origin, include_auto_detect) { + SystemProxyDecision::Direct => Ok(builder.no_proxy()), + SystemProxyDecision::Proxy { url } => configure_concrete_proxy(builder, route_class, &url), + SystemProxyDecision::Unavailable { failure } => { + if config.mode == OutboundProxyMode::System { + Err(BuildRouteAwareHttpClientError::SystemProxyUnavailable { + route_class, + failure, + }) + } else { + configure_env_proxy_handling( + builder, + Some(origin), + route_class, + EnvProxyHandling::DelegateToReqwest, + ) + } + } + } +} + +const fn system_proxy_supported() -> bool { + false +} + +fn configure_concrete_proxy( + builder: reqwest::ClientBuilder, + route_class: ClientRouteClass, + proxy_url: &str, +) -> Result { + let proxy = match reqwest::Proxy::all(proxy_url) { + Ok(proxy) => proxy, + Err(_source) => { + return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class }); + } + }; + let proxy = proxy.no_proxy(reqwest::NoProxy::from_env()); + Ok(builder.proxy(proxy)) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EnvProxyHandling { + ResolveConcreteOrNoProxy, + DelegateToReqwest, +} + +fn configure_env_proxy_handling( + builder: reqwest::ClientBuilder, + origin: Option<&RequestOrigin>, + route_class: ClientRouteClass, + handling: EnvProxyHandling, +) -> Result { + if let Some(origin) = origin + && let Some(proxy_url) = env_proxy_for_origin(origin) + { + if handling == EnvProxyHandling::ResolveConcreteOrNoProxy { + return configure_concrete_proxy(builder, route_class, &proxy_url); + } + return Ok(builder); + } + + if conventional_proxy_env_present() { + return match handling { + EnvProxyHandling::ResolveConcreteOrNoProxy => Ok(builder.no_proxy()), + EnvProxyHandling::DelegateToReqwest => Ok(builder), + }; + } + + match handling { + EnvProxyHandling::ResolveConcreteOrNoProxy => Ok(builder.no_proxy()), + EnvProxyHandling::DelegateToReqwest => Ok(builder), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RequestOrigin { + scheme: String, + host: String, + port: u16, +} + +impl RequestOrigin { + fn parse(request_url: &str) -> Option { + let uri = request_url.parse::().ok()?; + let scheme = uri.scheme_str()?.to_ascii_lowercase(); + let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase(); + let port = uri + .port_u16() + .or_else(|| default_port_for_scheme(&scheme))?; + Some(Self { scheme, host, port }) + } +} + +fn default_port_for_scheme(scheme: &str) -> Option { + match scheme { + "http" => Some(80), + "https" => Some(443), + _ => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum SystemProxyDecision { + Direct, + Proxy { url: String }, + Unavailable { failure: RouteFailureClass }, +} + +fn resolve_system_proxy( + request_url: &str, + origin: &RequestOrigin, + include_auto_detect: bool, +) -> SystemProxyDecision { + if let Some(decision) = cached_system_proxy_decision(request_url, include_auto_detect) { + return decision; + } + + let decision = resolve_platform_system_proxy(request_url, origin, include_auto_detect); + cache_system_proxy_decision(request_url, include_auto_detect, decision.clone()); + decision +} + +fn resolve_platform_system_proxy( + _request_url: &str, + _origin: &RequestOrigin, + _include_auto_detect: bool, +) -> SystemProxyDecision { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } +} + +#[derive(Debug, Clone)] +struct CachedSystemProxyDecision { + decision: SystemProxyDecision, + expires_at: Instant, +} + +static SYSTEM_PROXY_CACHE: OnceLock>> = + OnceLock::new(); + +fn cached_system_proxy_decision( + request_url: &str, + include_auto_detect: bool, +) -> Option { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().ok()?; + let key = system_proxy_cache_key(request_url, include_auto_detect); + let cached = cache.get(&key)?; + if cached.expires_at > Instant::now() { + return Some(cached.decision.clone()); + } + cache.remove(&key); + None +} + +fn cache_system_proxy_decision( + request_url: &str, + include_auto_detect: bool, + decision: SystemProxyDecision, +) { + if matches!(decision, SystemProxyDecision::Unavailable { .. }) { + return; + } + + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut cache) = cache.lock() { + let now = Instant::now(); + cache.retain(|_, cached| cached.expires_at > now); + cache.insert( + system_proxy_cache_key(request_url, include_auto_detect), + CachedSystemProxyDecision { + decision, + expires_at: now + SYSTEM_PROXY_SUCCESS_CACHE_TTL, + }, + ); + } +} + +fn system_proxy_cache_key(request_url: &str, include_auto_detect: bool) -> String { + format!("{request_url}:auto_detect={include_auto_detect}") +} + +fn conventional_proxy_env_present() -> bool { + proxy_env_value("HTTPS_PROXY").is_some() + || proxy_env_value("HTTP_PROXY").is_some() + || proxy_env_value("ALL_PROXY").is_some() +} + +fn env_proxy_for_origin(origin: &RequestOrigin) -> Option { + if origin.scheme == "https" { + proxy_env_value("HTTPS_PROXY").or_else(|| proxy_env_value("ALL_PROXY")) + } else if origin.scheme == "http" { + proxy_env_value("HTTP_PROXY").or_else(|| proxy_env_value("ALL_PROXY")) + } else { + proxy_env_value("ALL_PROXY") + } +} + +fn proxy_env_value(upper: &str) -> Option { + let lower = upper.to_ascii_lowercase(); + std::env::var(upper) + .ok() + .filter(|value| !value.is_empty()) + .or_else(|| std::env::var(lower).ok().filter(|value| !value.is_empty())) +} + +fn no_proxy_env_matches_origin(origin: &RequestOrigin) -> bool { + let Some(no_proxy) = proxy_env_value("NO_PROXY") else { + return false; + }; + no_proxy_matches_origin(&no_proxy, origin) +} + +fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool { + no_proxy + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .any(|entry| no_proxy_entry_matches_origin(entry, origin)) +} + +fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool { + if entry == "*" { + return true; + } + + let mut entry = entry + .strip_prefix("http://") + .or_else(|| entry.strip_prefix("https://")) + .unwrap_or(entry) + .trim_matches(['[', ']']) + .to_ascii_lowercase(); + let mut port = None; + let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| { + if host.contains(':') { + return None; + } + candidate_port + .parse::() + .ok() + .map(|parsed_port| (host.to_string(), parsed_port)) + }); + if let Some((host, parsed_port)) = parsed_host_port { + entry = host; + port = Some(parsed_port); + } + if port.is_some_and(|port| port != origin.port) { + return false; + } + + if let Some(suffix) = entry.strip_prefix('.') { + return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}")); + } + + if entry.contains('*') { + return wildcard_host_match(&entry, &origin.host); + } + + origin.host == entry || origin.host.ends_with(&format!(".{entry}")) +} + +fn wildcard_host_match(pattern: &str, host: &str) -> bool { + let mut remaining = host; + let mut first = true; + for part in pattern.split('*') { + if part.is_empty() { + continue; + } + if first && !pattern.starts_with('*') { + let Some(stripped) = remaining.strip_prefix(part) else { + return false; + }; + remaining = stripped; + } else { + let Some(index) = remaining.find(part) else { + return false; + }; + remaining = &remaining[index + part.len()..]; + } + first = false; + } + pattern.ends_with('*') || remaining.is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_proxy_matches_exact_suffix_wildcard_and_port() { + let origin = RequestOrigin { + scheme: "https".to_string(), + host: "auth.openai.com".to_string(), + port: 443, + }; + assert!(no_proxy_matches_origin("auth.openai.com", &origin)); + assert!(no_proxy_matches_origin(".openai.com", &origin)); + assert!(no_proxy_matches_origin("*.openai.com", &origin)); + assert!(no_proxy_matches_origin("auth.openai.com:443", &origin)); + assert!(!no_proxy_matches_origin("auth.openai.com:8443", &origin)); + } +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9f992a9b17..bd187120d2 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -53,6 +53,7 @@ use codex_config::types::OtelExporterKind; use codex_config::types::SandboxWorkspaceWrite; use codex_config::types::SessionPickerViewMode; use codex_config::types::SkillsConfig; +use codex_config::types::SystemProxyFeatureConfigToml; use codex_config::types::SystemProxyFeatureModeToml; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; @@ -9668,6 +9669,107 @@ shell_tool = false Ok(()) } +#[tokio::test] +async fn system_proxy_mode_requirement_enables_and_sets_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +system_proxy_mode_system = true +"#, + ), + ) + .build() + .await?; + + assert!(config.features.enabled(Feature::SystemProxy)); + assert_eq!( + config.system_proxy, + Some(SystemProxyFeatureConfigToml { + enabled: Some(true), + mode: Some(SystemProxyFeatureModeToml::System), + }) + ); + assert!( + !config + .startup_warnings + .iter() + .any(|warning| warning.contains("system_proxy_mode_system")), + "{:?}", + config.startup_warnings + ); + + Ok(()) +} + +#[tokio::test] +async fn system_proxy_mode_requirement_overrides_configured_mode() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features.system_proxy] +enabled = true +mode = "env" +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +system_proxy_mode_direct = true +"#, + ), + ) + .build() + .await?; + + assert_eq!( + config.system_proxy, + Some(SystemProxyFeatureConfigToml { + enabled: Some(true), + mode: Some(SystemProxyFeatureModeToml::Direct), + }) + ); + + Ok(()) +} + +#[tokio::test] +async fn system_proxy_mode_requirements_reject_multiple_modes() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let result = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[features] +system_proxy_mode_auto = true +system_proxy_mode_system = true +"#, + ), + ) + .build() + .await; + + let message = result + .err() + .map(|err| err.to_string()) + .expect("conflicting system proxy mode requirements should fail"); + assert!(message.contains("at most one"), "{message}"); + + Ok(()) +} + #[tokio::test] async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() -> std::io::Result<()> { diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 234a2835c6..fd7a9b7ea4 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -13,9 +13,15 @@ use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; use codex_features::Features; +use codex_features::SystemProxyFeatureModeToml; use codex_features::canonical_feature_for_key; use codex_features::feature_for_key; +const SYSTEM_PROXY_MODE_AUTO_REQUIREMENT: &str = "system_proxy_mode_auto"; +const SYSTEM_PROXY_MODE_ENV_REQUIREMENT: &str = "system_proxy_mode_env"; +const SYSTEM_PROXY_MODE_SYSTEM_REQUIREMENT: &str = "system_proxy_mode_system"; +const SYSTEM_PROXY_MODE_DIRECT_REQUIREMENT: &str = "system_proxy_mode_direct"; + /// Wrapper around [`Features`] which enforces constraints defined in /// `FeatureRequirementsToml` and provides normalization to ensure constraints /// are satisfied. Constraints are enforced on construction and mutation of @@ -72,7 +78,11 @@ impl ManagedFeatures { value: feature_requirements, source, }) => ( - parse_feature_requirements(feature_requirements, &source, startup_warnings), + parse_feature_requirements_with_mode_gate( + &feature_requirements, + &source, + startup_warnings, + )?, Some(source), ), None => (BTreeMap::new(), None), @@ -203,24 +213,119 @@ fn feature_requirements_display(feature_requirements: &BTreeMap) format!("[{}]", values.join(", ")) } +struct SystemProxyModeRequirement { + key: String, + mode: SystemProxyFeatureModeToml, +} + +pub(crate) fn system_proxy_mode_requirement( + feature_requirements: Option<&Sourced>, +) -> std::io::Result> { + let Some(Sourced { + value: feature_requirements, + source, + }) = feature_requirements + else { + return Ok(None); + }; + parse_system_proxy_mode_requirement(feature_requirements, source) + .map(|requirement| requirement.map(|requirement| requirement.mode)) +} + +fn parse_feature_requirements_with_mode_gate( + feature_requirements: &FeatureRequirementsToml, + source: &RequirementSource, + startup_warnings: Option<&mut Vec>, +) -> std::io::Result> { + let system_proxy_mode = parse_system_proxy_mode_requirement(feature_requirements, source)?; + let mut pinned_features = + parse_feature_requirements(feature_requirements, source, startup_warnings); + if let Some(requirement) = system_proxy_mode { + if pinned_features.get(&Feature::SystemProxy) == Some(&false) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + ConstraintError::InvalidValue { + field_name: "features", + candidate: format!("system_proxy=false, {}=true", requirement.key), + allowed: + "system_proxy=true or omit system_proxy when selecting a system proxy mode" + .to_string(), + requirement_source: source.clone(), + }, + )); + } + pinned_features.insert(Feature::SystemProxy, true); + } + Ok(pinned_features) +} + +fn parse_system_proxy_mode_requirement( + feature_requirements: &FeatureRequirementsToml, + source: &RequirementSource, +) -> std::io::Result> { + let mut selected = None; + for (key, enabled) in &feature_requirements.entries { + let Some(mode) = system_proxy_mode_requirement_for_key(key) else { + continue; + }; + if !*enabled { + continue; + } + if let Some((existing_key, _)) = selected { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + ConstraintError::InvalidValue { + field_name: "features", + candidate: format!("{existing_key}=true, {key}=true"), + allowed: format!( + "at most one of {SYSTEM_PROXY_MODE_AUTO_REQUIREMENT}, {SYSTEM_PROXY_MODE_ENV_REQUIREMENT}, {SYSTEM_PROXY_MODE_SYSTEM_REQUIREMENT}, {SYSTEM_PROXY_MODE_DIRECT_REQUIREMENT}" + ), + requirement_source: source.clone(), + }, + )); + } + selected = Some((key.as_str(), mode)); + } + + Ok(selected.map(|(key, mode)| SystemProxyModeRequirement { + key: key.to_string(), + mode, + })) +} + +fn system_proxy_mode_requirement_for_key(key: &str) -> Option { + match key { + SYSTEM_PROXY_MODE_AUTO_REQUIREMENT => Some(SystemProxyFeatureModeToml::Auto), + SYSTEM_PROXY_MODE_ENV_REQUIREMENT => Some(SystemProxyFeatureModeToml::Env), + SYSTEM_PROXY_MODE_SYSTEM_REQUIREMENT => Some(SystemProxyFeatureModeToml::System), + SYSTEM_PROXY_MODE_DIRECT_REQUIREMENT => Some(SystemProxyFeatureModeToml::Direct), + _ => None, + } +} + fn parse_feature_requirements( - feature_requirements: FeatureRequirementsToml, + feature_requirements: &FeatureRequirementsToml, source: &RequirementSource, mut startup_warnings: Option<&mut Vec>, ) -> BTreeMap { let mut pinned_features = BTreeMap::new(); - for (key, enabled) in feature_requirements.entries { + for (key, enabled) in &feature_requirements.entries { + let enabled = *enabled; if key == "auto_review" { pinned_features.insert(Feature::GuardianApproval, enabled); continue; } - if let Some(feature) = canonical_feature_for_key(&key) { + if system_proxy_mode_requirement_for_key(key).is_some() { + continue; + } + + if let Some(feature) = canonical_feature_for_key(key) { pinned_features.insert(feature, enabled); continue; } - if let Some(feature) = feature_for_key(&key) { + if let Some(feature) = feature_for_key(key) { push_feature_requirement_warning( &mut startup_warnings, format!( @@ -283,11 +388,11 @@ pub(crate) fn validate_explicit_feature_settings_in_config_toml( return Ok(()); }; - let pinned_features = parse_feature_requirements( - feature_requirements.clone(), + let pinned_features = parse_feature_requirements_with_mode_gate( + feature_requirements, source, /*startup_warnings*/ None, - ); + )?; if pinned_features.is_empty() { return Ok(()); } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d68b1c3c25..5945595e48 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -48,6 +48,7 @@ use codex_config::types::Notice; use codex_config::types::OAuthCredentialsStoreMode; use codex_config::types::SessionPickerViewMode; use codex_config::types::SystemProxyFeatureConfigToml; +use codex_config::types::SystemProxyFeatureModeToml; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; @@ -71,6 +72,8 @@ use codex_features::NetworkProxyConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; +use codex_login::AuthRouteConfig; +use codex_login::auth_route_config_from_system_proxy_config; use codex_mcp::McpConfig; use codex_memories_read::memory_root; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; @@ -1114,6 +1117,12 @@ impl AuthManagerConfig for Config { fn chatgpt_base_url(&self) -> String { self.chatgpt_base_url.clone() } + + fn auth_route_config(&self) -> Option { + self.system_proxy + .as_ref() + .map(auth_route_config_from_system_proxy_config) + } } #[derive(Clone, Default)] @@ -2430,6 +2439,7 @@ fn system_proxy_toml_config( fn resolved_system_proxy_config_from_features( cfg: &ConfigToml, features: &Features, + mode_requirement: Option, ) -> Option { if !features.enabled(Feature::SystemProxy) { return None; @@ -2437,6 +2447,9 @@ fn resolved_system_proxy_config_from_features( let mut config = system_proxy_toml_config(cfg.features.as_ref()) .cloned() .unwrap_or_default(); + if let Some(mode) = mode_requirement { + config.mode = Some(mode); + } config.enabled = Some(true); Some(config) } @@ -2444,8 +2457,9 @@ fn resolved_system_proxy_config_from_features( fn resolved_system_proxy_config( cfg: &ConfigToml, features: &ManagedFeatures, + mode_requirement: Option, ) -> Option { - resolved_system_proxy_config_from_features(cfg, features.get()) + resolved_system_proxy_config_from_features(cfg, features.get(), mode_requirement) } /// Resolves `[features.system_proxy]` for the initial cloud-config bootstrap. @@ -2468,7 +2482,11 @@ pub fn resolve_bootstrap_system_proxy_config( ); let features = ManagedFeatures::from_configured(configured_features, feature_requirements.cloned())?; - Ok(resolved_system_proxy_config(cfg, &features)) + Ok(resolved_system_proxy_config( + cfg, + &features, + /*mode_requirement*/ None, + )) } pub(crate) fn resolve_web_search_mode_for_turn( @@ -2734,12 +2752,15 @@ impl Config { }, feature_overrides, ); + let system_proxy_mode_requirement = + managed_features::system_proxy_mode_requirement(feature_requirements.as_ref())?; let features = ManagedFeatures::from_configured_with_warnings( configured_features, feature_requirements, &mut startup_warnings, )?; - let system_proxy = resolved_system_proxy_config(&cfg, &features); + let system_proxy = + resolved_system_proxy_config(&cfg, &features, system_proxy_mode_requirement); let enable_network_proxy = features.enabled(Feature::NetworkProxy); let configured_windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); // Keep the configured mode separate so a requirement-constrained mode diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index fbecfe5620..8c53251eac 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -51,7 +51,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; use codex_arg0::Arg0DispatchPaths; -use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage_with_auth_route_config; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::ConfigLoadOptions; @@ -64,6 +64,7 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_and_load_options; +use codex_core::config::resolve_bootstrap_system_proxy_config; use codex_core::config::resolve_oss_provider; use codex_core::config::resolve_profile_v2_config_path; use codex_core::find_thread_meta_by_name_str; @@ -72,9 +73,11 @@ use codex_core::path_utils; use codex_feedback::CodexFeedback; use codex_git_utils::get_git_repo_root; use codex_login::AuthConfig; +use codex_login::auth_route_config_from_system_proxy_config; +use codex_login::bootstrap_auth_route_config_from_system_proxy_config; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::default_client::set_default_originator; -use codex_login::enforce_login_restrictions; +use codex_login::enforce_login_restrictions_with_auth_route_config; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; @@ -345,13 +348,17 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_config_bundle = cloud_config_bundle_loader_for_storage( + let system_proxy_config = resolve_bootstrap_system_proxy_config(&bootstrap_config_toml); + let auth_route_config = + bootstrap_auth_route_config_from_system_proxy_config(system_proxy_config.as_ref()); + let cloud_config_bundle = cloud_config_bundle_loader_for_storage_with_auth_route_config( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, bootstrap_config_toml .cli_auth_credentials_store .unwrap_or_default(), chatgpt_base_url, + auth_route_config, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); @@ -463,13 +470,20 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result set_default_client_residency_requirement(config.enforce_residency.value()); - if let Err(err) = enforce_login_restrictions(&AuthConfig { - codex_home: config.codex_home.to_path_buf(), - auth_credentials_store_mode: config.cli_auth_credentials_store_mode, - forced_login_method: config.forced_login_method, - forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), - chatgpt_base_url: Some(config.chatgpt_base_url.clone()), - }) + let auth_route_config = config + .system_proxy + .as_ref() + .map(auth_route_config_from_system_proxy_config); + if let Err(err) = enforce_login_restrictions_with_auth_route_config( + &AuthConfig { + codex_home: config.codex_home.to_path_buf(), + auth_credentials_store_mode: config.cli_auth_credentials_store_mode, + forced_login_method: config.forced_login_method, + forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), + chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + }, + auth_route_config.as_ref(), + ) .await { eprintln!("{err}"); diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 3644713328..225f79e6f2 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -1,9 +1,11 @@ use codex_agent_identity::AgentIdentityKey; +use codex_agent_identity::agent_task_registration_url; use codex_agent_identity::register_agent_task; use codex_protocol::account::PlanType as AccountPlanType; use std::env; -use crate::default_client::build_reqwest_client; +use crate::default_client::build_default_reqwest_client_for_auth_route; +use crate::outbound_proxy::AuthRouteConfig; use super::storage::AgentIdentityAuthRecord; @@ -18,14 +20,22 @@ pub struct AgentIdentityAuth { impl AgentIdentityAuth { pub async fn load(record: AgentIdentityAuthRecord) -> std::io::Result { + Self::load_with_auth_route_config(record, /*auth_route_config*/ None).await + } + + pub(crate) async fn load_with_auth_route_config( + record: AgentIdentityAuthRecord, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(); - let process_task_id = register_agent_task( - &build_reqwest_client(), - &agent_identity_authapi_base_url, - key(&record), - ) - .await - .map_err(std::io::Error::other)?; + let task_registration_url = + agent_task_registration_url(&agent_identity_authapi_base_url, &record.agent_runtime_id); + let client = + build_default_reqwest_client_for_auth_route(&task_registration_url, auth_route_config)?; + let process_task_id = + register_agent_task(&client, &agent_identity_authapi_base_url, key(&record)) + .await + .map_err(std::io::Error::other)?; Ok(Self { record, process_task_id, diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index e7b5502ebc..5f32c9b577 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::AuthRouteConfig; use crate::auth::storage::FileAuthStorage; use crate::auth::storage::get_auth_file; use crate::token_data::IdTokenInfo; @@ -290,6 +291,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -347,6 +349,7 @@ async fn loads_api_key_from_auth_json() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -425,6 +428,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -443,6 +447,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { updated_auth_dot_json, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("updated auth should parse"); @@ -826,6 +831,7 @@ async fn load_auth_reads_access_token_from_env() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, Some(&chatgpt_base_url), + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -949,6 +955,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*enable_codex_api_key_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1316,9 +1323,31 @@ async fn agent_identity_plan_type_maps_raw_education_alias() { assert_agent_identity_plan_alias(json!("education"), AccountPlanType::Edu).await; } +#[tokio::test] +#[serial(codex_auth_env)] +async fn agent_identity_auth_with_auth_route_config_loads() { + let auth_route_config = AuthRouteConfig::direct(); + + assert_agent_identity_auth_loads(json!("pro"), AccountPlanType::Pro, Some(&auth_route_config)) + .await +} + async fn assert_agent_identity_plan_alias( plan_type: serde_json::Value, expected_plan_type: AccountPlanType, +) { + assert_agent_identity_auth_loads( + plan_type, + expected_plan_type, + /*auth_route_config*/ None, + ) + .await; +} + +async fn assert_agent_identity_auth_loads( + plan_type: serde_json::Value, + expected_plan_type: AccountPlanType, + auth_route_config: Option<&AuthRouteConfig>, ) { let record = agent_identity_record("account-id"); let jwt = signed_agent_identity_jwt(&record, plan_type).expect("agent identity jwt"); @@ -1340,9 +1369,13 @@ async fn assert_agent_identity_plan_alias( let chatgpt_base_url = format!("{}/backend-api", server.uri()); let _authapi_guard = EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); - let auth = CodexAuth::from_agent_identity_jwt(&jwt, Some(&chatgpt_base_url)) - .await - .expect("agent identity auth"); + let auth = CodexAuth::from_agent_identity_jwt_with_auth_route_config( + &jwt, + Some(&chatgpt_base_url), + auth_route_config, + ) + .await + .expect("agent identity auth"); pretty_assertions::assert_eq!(auth.account_plan_type(), Some(expected_plan_type)); server.verify().await; @@ -1368,6 +1401,7 @@ async fn plan_type_maps_known_plan() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -1396,6 +1430,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -1427,6 +1462,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -1458,6 +1494,7 @@ async fn plan_type_maps_unknown_to_unknown() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -1486,6 +1523,7 @@ async fn missing_plan_type_maps_to_unknown() { /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index ee51edf9b5..365c722e28 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -5,8 +5,11 @@ //! workspace. use codex_client::BuildCustomCaTransportError; +use codex_client::BuildRouteAwareHttpClientError; +use codex_client::ClientRouteClass; use codex_client::CodexHttpClient; pub use codex_client::CodexRequestBuilder; +use codex_client::build_reqwest_client_for_route; use codex_client::build_reqwest_client_with_custom_ca; use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; @@ -17,6 +20,8 @@ use std::sync::LazyLock; use std::sync::Mutex; use std::sync::RwLock; +use crate::outbound_proxy::AuthRouteConfig; + /// Set this to add a suffix to the User-Agent string. /// /// It is not ideal that we're using a global singleton for this. @@ -220,13 +225,71 @@ pub fn build_reqwest_client() -> reqwest::Client { /// Callers that need a structured CA-loading failure instead of the legacy logged fallback can use /// this method directly. pub fn try_build_reqwest_client() -> Result { + build_reqwest_client_with_custom_ca(default_reqwest_client_builder()) +} + +fn default_reqwest_client_builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder().default_headers(default_headers()); if is_sandboxed() { builder = builder.no_proxy(); } - builder = with_chatgpt_cloudflare_cookie_store(builder); + with_chatgpt_cloudflare_cookie_store(builder) +} - build_reqwest_client_with_custom_ca(builder) +pub(crate) fn build_auth_reqwest_client_with_auth_route_config( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_auth_reqwest_client_with_builder(reqwest::Client::builder(), endpoint, auth_route_config) +} + +/// Builds the standard Codex reqwest client for a known auth destination. +pub(crate) fn build_default_reqwest_client_for_auth_route( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + let Some(route_config) = auth_route_config.and_then(AuthRouteConfig::route_config) else { + return Ok(build_reqwest_client()); + }; + + let builder = default_reqwest_client_builder(); + if is_sandboxed() { + return build_reqwest_client_with_custom_ca(builder).map_err(Into::into); + } + build_reqwest_client_for_route( + builder, + endpoint, + ClientRouteClass::Auth, + Some(route_config), + ) +} + +pub(crate) fn create_client_for_auth_route( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + if auth_route_config + .and_then(AuthRouteConfig::route_config) + .is_none() + { + return Ok(create_client()); + } + + build_default_reqwest_client_for_auth_route(endpoint, auth_route_config) + .map(CodexHttpClient::new) +} + +fn build_auth_reqwest_client_with_builder( + builder: reqwest::ClientBuilder, + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_reqwest_client_for_route( + builder, + endpoint, + ClientRouteClass::Auth, + auth_route_config.and_then(AuthRouteConfig::route_config), + ) } pub fn default_headers() -> HeaderMap { diff --git a/codex-rs/login/src/auth/default_client_tests.rs b/codex-rs/login/src/auth/default_client_tests.rs index be8e6bc392..bb65864c69 100644 --- a/codex-rs/login/src/auth/default_client_tests.rs +++ b/codex-rs/login/src/auth/default_client_tests.rs @@ -1,7 +1,10 @@ use super::sanitize_user_agent; use super::*; +use crate::AuthRouteConfig; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; +use serial_test::serial; +use tempfile::tempdir; #[test] fn test_get_codex_user_agent() { @@ -89,6 +92,38 @@ async fn test_create_client_sets_default_headers() { set_default_client_residency_requirement(/*enforce_residency*/ None); } +#[test] +#[serial(default_client_ca_env)] +fn auth_route_client_without_proxy_config_preserves_default_client_fallback() { + let temp_dir = tempdir().expect("tempdir"); + let missing_ca_path = temp_dir.path().join("missing-ca.pem"); + let _guard = EnvVarGuard::set( + "CODEX_CA_CERTIFICATE", + missing_ca_path + .to_str() + .expect("test CA path should be valid UTF-8"), + ); + + let no_proxy_client = create_client_for_auth_route( + "https://auth.openai.com/oauth/token", + /*auth_route_config*/ None, + ); + assert!(no_proxy_client.is_ok()); + + let raw_no_proxy_client = build_default_reqwest_client_for_auth_route( + "https://auth.openai.com/oauth/token", + /*auth_route_config*/ None, + ); + assert!(raw_no_proxy_client.is_ok()); + + let auth_route_config = AuthRouteConfig::direct(); + let explicit_proxy_client = create_client_for_auth_route( + "https://auth.openai.com/oauth/token", + Some(&auth_route_config), + ); + assert!(explicit_proxy_client.is_err()); +} + #[test] fn test_invalid_suffix_is_sanitized() { let prefix = "codex_cli_rs/0.0.0"; @@ -123,3 +158,34 @@ fn test_macos() { .unwrap(); assert!(re.is_match(&user_agent)); } + +/// Use sparingly. +/// TODO: replace this with an injectable env var provider. +#[cfg(test)] +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +#[cfg(test)] +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } +} + +#[cfg(test)] +impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } +} diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 9e255a99a3..f10150e75f 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -17,6 +17,7 @@ use std::sync::atomic::Ordering; use tokio::sync::Semaphore; use tokio::sync::watch; +use codex_agent_identity::agent_identity_jwks_url; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; use codex_app_server_protocol::AuthMode; @@ -27,7 +28,7 @@ use codex_protocol::config_types::ModelProviderAuthInfo; use super::access_token::CodexAccessToken; use super::access_token::classify_codex_access_token; use super::external_bearer::BearerTokenRefresher; -use super::revoke::revoke_auth_tokens; +use super::revoke::revoke_auth_tokens_with_auth_route_config; pub use crate::auth::agent_identity::AgentIdentityAuth; pub use crate::auth::personal_access_token::PersonalAccessTokenAuth; pub use crate::auth::storage::AgentIdentityAuthRecord; @@ -35,12 +36,12 @@ pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; -use crate::default_client::build_reqwest_client; -use crate::default_client::create_client; +use crate::default_client::build_default_reqwest_client_for_auth_route; +use crate::default_client::create_client_for_auth_route; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use crate::token_data::parse_jwt_expiration; -use codex_client::CodexHttpClient; use codex_config::types::AuthCredentialsStoreMode; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::PlanType as InternalPlanType; @@ -87,7 +88,6 @@ pub struct ChatgptAuthTokens { #[derive(Debug, Clone)] struct ChatgptAuthState { auth_dot_json: Arc>>, - client: CodexHttpClient, } const TOKEN_REFRESH_INTERVAL: i64 = 8; @@ -210,9 +210,9 @@ impl CodexAuth { auth_dot_json: AuthDotJson, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); - let client = create_client(); if auth_mode == ApiAuthMode::ApiKey { let Some(api_key) = auth_dot_json.openai_api_key.as_deref() else { return Err(std::io::Error::other("API key auth is missing a key.")); @@ -225,7 +225,12 @@ impl CodexAuth { "agent identity auth is missing an agent identity token.", )); }; - return Self::from_agent_identity_jwt(&agent_identity, chatgpt_base_url).await; + return Self::from_agent_identity_jwt_with_auth_route_config( + &agent_identity, + chatgpt_base_url, + auth_route_config, + ) + .await; } if auth_mode == ApiAuthMode::PersonalAccessToken { let Some(personal_access_token) = auth_dot_json.personal_access_token.as_deref() else { @@ -239,7 +244,6 @@ impl CodexAuth { let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client, }; match auth_mode { @@ -262,12 +266,28 @@ impl CodexAuth { codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + ) -> std::io::Result> { + Self::from_auth_storage_with_auth_route_config( + codex_home, + auth_credentials_store_mode, + chatgpt_base_url, + /*auth_route_config*/ None, + ) + .await + } + + pub async fn from_auth_storage_with_auth_route_config( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { load_auth( codex_home, /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, chatgpt_base_url, + auth_route_config, ) .await } @@ -275,13 +295,28 @@ impl CodexAuth { pub async fn from_agent_identity_jwt( jwt: &str, chatgpt_base_url: Option<&str>, + ) -> std::io::Result { + Self::from_agent_identity_jwt_with_auth_route_config( + jwt, + chatgpt_base_url, + /*auth_route_config*/ None, + ) + .await + } + + pub async fn from_agent_identity_jwt_with_auth_route_config( + jwt: &str, + chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let base_url = chatgpt_base_url .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) .trim_end_matches('/') .to_string(); - let record = verified_agent_identity_record(jwt, &base_url).await?; - Ok(Self::AgentIdentity(AgentIdentityAuth::load(record).await?)) + let record = verified_agent_identity_record(jwt, &base_url, auth_route_config).await?; + Ok(Self::AgentIdentity( + AgentIdentityAuth::load_with_auth_route_config(record, auth_route_config).await?, + )) } pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { @@ -474,10 +509,8 @@ impl CodexAuth { personal_access_token: None, }; - let client = create_client(); let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client, }; let dummy_auth_id = NEXT_DUMMY_AUTH_ID.fetch_add(1, Ordering::Relaxed); let storage = create_auth_storage( @@ -507,10 +540,6 @@ impl ChatgptAuth { fn storage(&self) -> &Arc { &self.storage } - - fn client(&self) -> &CodexHttpClient { - &self.state.client - } } pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; @@ -542,9 +571,12 @@ fn read_non_empty_env_var(key: &str) -> Option { async fn verified_agent_identity_record( jwt: &str, chatgpt_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; - let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url) + let jwks_url = agent_identity_jwks_url(chatgpt_base_url); + let client = build_default_reqwest_client_for_auth_route(&jwks_url, auth_route_config)?; + let jwks = fetch_agent_identity_jwks(&client, chatgpt_base_url) .await .map_err(std::io::Error::other)?; let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?; @@ -599,6 +631,23 @@ pub async fn login_with_access_token( access_token: &str, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, +) -> std::io::Result<()> { + login_with_access_token_with_auth_route_config( + codex_home, + access_token, + auth_credentials_store_mode, + chatgpt_base_url, + /*auth_route_config*/ None, + ) + .await +} + +pub async fn login_with_access_token_with_auth_route_config( + codex_home: &Path, + access_token: &str, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result<()> { let auth_dot_json = match classify_codex_access_token(access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { @@ -619,7 +668,7 @@ pub async fn login_with_access_token( .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL) .trim_end_matches('/') .to_string(); - verified_agent_identity_record(jwt, &base_url).await?; + verified_agent_identity_record(jwt, &base_url, auth_route_config).await?; AuthDotJson { auth_mode: Some(ApiAuthMode::AgentIdentity), openai_api_key: None, @@ -685,11 +734,20 @@ pub struct AuthConfig { } pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { + enforce_login_restrictions_with_auth_route_config(config, /*auth_route_config*/ None).await +} + +/// Enforces configured login restrictions using auth-owned HTTP settings. +pub async fn enforce_login_restrictions_with_auth_route_config( + config: &AuthConfig, + auth_route_config: Option<&AuthRouteConfig>, +) -> std::io::Result<()> { let Some(auth) = load_auth( &config.codex_home, /*enable_codex_api_key_env*/ true, config.auth_credentials_store_mode, config.chatgpt_base_url.as_deref(), + auth_route_config, ) .await? else { @@ -805,6 +863,7 @@ async fn load_auth( enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -823,6 +882,7 @@ async fn load_auth( auth_dot_json, AuthCredentialsStoreMode::Ephemeral, chatgpt_base_url, + auth_route_config, ) .await?; return Ok(Some(auth)); @@ -836,7 +896,11 @@ async fn load_auth( .map(Some) } CodexAccessToken::AgentIdentityJwt(jwt) => { - CodexAuth::from_agent_identity_jwt(jwt, chatgpt_base_url) + CodexAuth::from_agent_identity_jwt_with_auth_route_config( + jwt, + chatgpt_base_url, + auth_route_config, + ) .await .map(Some) } @@ -860,6 +924,7 @@ async fn load_auth( auth_dot_json, auth_credentials_store_mode, chatgpt_base_url, + auth_route_config, ) .await?; Ok(Some(auth)) @@ -895,7 +960,7 @@ fn persist_tokens( // The caller is responsible for persisting any returned tokens. async fn request_chatgpt_token_refresh( refresh_token: String, - client: &CodexHttpClient, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result { let refresh_request = RefreshRequest { client_id: CLIENT_ID, @@ -904,6 +969,8 @@ async fn request_chatgpt_token_refresh( }; let endpoint = refresh_token_endpoint(); + let client = create_client_for_auth_route(&endpoint, auth_route_config) + .map_err(|err| RefreshTokenError::Transient(err.into()))?; // Use shared client factory to include standard headers let response = client @@ -1353,6 +1420,7 @@ pub struct AuthManager { chatgpt_base_url: Option, refresh_lock: Semaphore, external_auth: RwLock>>, + auth_route_config: Option, } /// Configuration view required to construct a shared [`AuthManager`]. @@ -1373,6 +1441,11 @@ pub trait AuthManagerConfig { /// Returns the ChatGPT backend base URL used for first-party backend authorization. fn chatgpt_base_url(&self) -> String; + + /// Returns route-selection settings for auth-owned clients. + fn auth_route_config(&self) -> Option { + None + } } impl Debug for AuthManager { @@ -1390,6 +1463,7 @@ impl Debug for AuthManager { &self.forced_chatgpt_workspace_id, ) .field("chatgpt_base_url", &self.chatgpt_base_url) + .field("auth_route_config", &self.auth_route_config) .field("has_external_auth", &self.has_external_auth()) .finish_non_exhaustive() } @@ -1405,12 +1479,30 @@ impl AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option, + ) -> Self { + Self::new_with_auth_route_config( + codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + chatgpt_base_url, + /*auth_route_config*/ None, + ) + .await + } + + pub async fn new_with_auth_route_config( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option, + auth_route_config: Option, ) -> Self { let managed_auth = load_auth( &codex_home, enable_codex_api_key_env, auth_credentials_store_mode, chatgpt_base_url.as_deref(), + auth_route_config.as_ref(), ) .await .ok() @@ -1429,6 +1521,7 @@ impl AuthManager { chatgpt_base_url, refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config, } } @@ -1450,6 +1543,7 @@ impl AuthManager { chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1470,6 +1564,7 @@ impl AuthManager { chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1490,6 +1585,7 @@ impl AuthManager { external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc )), + auth_route_config: None, }) } @@ -1629,6 +1725,7 @@ impl AuthManager { self.enable_codex_api_key_env, self.auth_credentials_store_mode, self.chatgpt_base_url.as_deref(), + self.auth_route_config.as_ref(), ) .await .ok() @@ -1719,13 +1816,16 @@ impl AuthManager { config: &impl AuthManagerConfig, enable_codex_api_key_env: bool, ) -> Arc { - let auth_manager = Self::shared( - config.codex_home(), - enable_codex_api_key_env, - config.cli_auth_credentials_store_mode(), - Some(config.chatgpt_base_url()), - ) - .await; + let auth_manager = Arc::new( + Self::new_with_auth_route_config( + config.codex_home(), + enable_codex_api_key_env, + config.cli_auth_credentials_store_mode(), + Some(config.chatgpt_base_url()), + config.auth_route_config(), + ) + .await, + ); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id()); auth_manager } @@ -1874,7 +1974,12 @@ impl AuthManager { let auth_dot_json = self .auth_cached() .and_then(|auth| auth.get_current_auth_json()); - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + if let Err(err) = revoke_auth_tokens_with_auth_route_config( + auth_dot_json.as_ref(), + self.auth_route_config.as_ref(), + ) + .await + { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } let result = logout_all_stores(&self.codex_home, self.auth_credentials_store_mode)?; @@ -1993,7 +2098,8 @@ impl AuthManager { auth: &ChatgptAuth, refresh_token: String, ) -> Result<(), RefreshTokenError> { - let refresh_response = request_chatgpt_token_refresh(refresh_token, auth.client()).await?; + let refresh_response = + request_chatgpt_token_refresh(refresh_token, self.auth_route_config.as_ref()).await?; persist_tokens( auth.storage(), diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index 9bf6c350f7..949377baf7 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -13,5 +13,5 @@ mod revoke; pub use error::RefreshTokenFailedError; pub use error::RefreshTokenFailedReason; pub use manager::*; -pub(crate) use revoke::revoke_auth_tokens; +pub(crate) use revoke::revoke_auth_tokens_with_auth_route_config; pub(crate) use revoke::should_revoke_auth_tokens; diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index b6a9fef4bb..da308e042f 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -17,7 +17,8 @@ use super::manager::REVOKE_TOKEN_URL; use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use super::storage::AuthDotJson; use super::util::try_parse_error_message; -use crate::default_client::create_client; +use crate::default_client::create_client_for_auth_route; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10); @@ -52,15 +53,16 @@ struct RevokeTokenRequest<'a> { client_id: Option<&'static str>, } -pub(crate) async fn revoke_auth_tokens( +pub(crate) async fn revoke_auth_tokens_with_auth_route_config( auth_dot_json: Option<&AuthDotJson>, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result<(), std::io::Error> { let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else { return Ok(()); }; - let client = create_client(); let endpoint = revoke_token_endpoint(); + let client = create_client_for_auth_route(&endpoint, auth_route_config)?; revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await } diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 4b9cb7c321..fff4e463db 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -6,9 +6,9 @@ use serde::de::{self}; use std::time::Duration; use std::time::Instant; +use crate::default_client::build_auth_reqwest_client_with_auth_route_config; use crate::pkce::PkceCodes; use crate::server::ServerOptions; -use codex_client::build_reqwest_client_with_custom_ca; use std::io; const ANSI_BLUE: &str = "\x1b[94m"; @@ -157,9 +157,13 @@ fn print_device_code_prompt(verification_url: &str, code: &str) { } pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result { - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let base_url = opts.issuer.trim_end_matches('/'); let api_base_url = format!("{base_url}/api/accounts"); + let user_code_endpoint = format!("{api_base_url}/deviceauth/usercode"); + let client = build_auth_reqwest_client_with_auth_route_config( + &user_code_endpoint, + opts.auth_route_config.as_ref(), + )?; let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; Ok(DeviceCode { @@ -174,9 +178,13 @@ pub async fn complete_device_code_login( opts: ServerOptions, device_code: DeviceCode, ) -> std::io::Result<()> { - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let base_url = opts.issuer.trim_end_matches('/'); let api_base_url = format!("{base_url}/api/accounts"); + let token_endpoint = format!("{api_base_url}/deviceauth/token"); + let client = build_auth_reqwest_client_with_auth_route_config( + &token_endpoint, + opts.auth_route_config.as_ref(), + )?; let code_resp = poll_for_token( &client, @@ -199,6 +207,7 @@ pub async fn complete_device_code_login( &redirect_uri, &pkce, &code_resp.authorization_code, + opts.auth_route_config.as_ref(), ) .await .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; @@ -217,6 +226,7 @@ pub async fn complete_device_code_login( tokens.access_token, tokens.refresh_token, opts.cli_auth_credentials_store_mode, + opts.auth_route_config.as_ref(), ) .await } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 990cf8b80e..28c19ee211 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -3,6 +3,7 @@ pub mod auth_env_telemetry; pub mod token_data; mod device_code_auth; +mod outbound_proxy; mod pkce; mod server; @@ -37,8 +38,10 @@ pub use auth::RefreshTokenError; pub use auth::UnauthorizedRecovery; pub use auth::default_client; pub use auth::enforce_login_restrictions; +pub use auth::enforce_login_restrictions_with_auth_route_config; pub use auth::load_auth_dot_json; pub use auth::login_with_access_token; +pub use auth::login_with_access_token_with_auth_route_config; pub use auth::login_with_api_key; pub use auth::logout; pub use auth::logout_with_revoke; @@ -47,4 +50,7 @@ pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry; pub use auth_env_telemetry::collect_auth_env_telemetry; +pub use outbound_proxy::AuthRouteConfig; +pub use outbound_proxy::auth_route_config_from_system_proxy_config; +pub use outbound_proxy::bootstrap_auth_route_config_from_system_proxy_config; pub use token_data::TokenData; diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs new file mode 100644 index 0000000000..cd579a58d5 --- /dev/null +++ b/codex-rs/login/src/outbound_proxy.rs @@ -0,0 +1,124 @@ +use codex_client::OutboundProxyConfig; +use codex_client::OutboundProxyMode; +use codex_config::types::SystemProxyFeatureConfigToml; +use codex_config::types::SystemProxyFeatureModeToml; + +/// Stable route-selection policy for auth-owned clients. +/// +/// Auth call sites should accept this type instead of lower-level resolver +/// details. Config parsing decides whether to construct one, and the client +/// layer remains responsible for platform-specific proxy resolution. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AuthRouteConfig { + route_config: Option, +} + +impl AuthRouteConfig { + pub fn auto() -> Self { + Self::from_outbound_proxy_mode(OutboundProxyMode::Auto) + } + + pub fn env() -> Self { + Self::from_outbound_proxy_mode(OutboundProxyMode::Env) + } + + pub fn system() -> Self { + Self::from_outbound_proxy_mode(OutboundProxyMode::System) + } + + pub fn direct() -> Self { + Self::from_outbound_proxy_mode(OutboundProxyMode::Direct) + } + + pub(crate) fn route_config(&self) -> Option<&OutboundProxyConfig> { + self.route_config.as_ref() + } + + fn from_outbound_proxy_mode(mode: OutboundProxyMode) -> Self { + Self { + route_config: Some(OutboundProxyConfig::new(mode)), + } + } +} + +pub fn auth_route_config_from_system_proxy_config( + system_proxy: &SystemProxyFeatureConfigToml, +) -> AuthRouteConfig { + match system_proxy.mode.unwrap_or_default() { + SystemProxyFeatureModeToml::Auto => AuthRouteConfig::auto(), + SystemProxyFeatureModeToml::Env => AuthRouteConfig::env(), + SystemProxyFeatureModeToml::System => AuthRouteConfig::system(), + SystemProxyFeatureModeToml::Direct => AuthRouteConfig::direct(), + } +} + +/// Returns the auth route config for the explicit platform system-proxy startup path. +pub fn bootstrap_auth_route_config_from_system_proxy_config( + system_proxy: Option<&SystemProxyFeatureConfigToml>, +) -> Option { + let system_proxy = system_proxy?; + if system_proxy.mode != Some(SystemProxyFeatureModeToml::System) { + return None; + } + + // Legacy startup auth builders only need the explicit platform system-proxy selection here. + Some(auth_route_config_from_system_proxy_config(system_proxy)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn system_proxy_feature_config_maps_modes_and_startup_gate_together() { + use SystemProxyFeatureModeToml as SystemProxyFeatureMode; + + assert_eq!( + bootstrap_auth_route_config_from_system_proxy_config(None), + None + ); + + let system_config = AuthRouteConfig { + route_config: Some(OutboundProxyConfig::new(OutboundProxyMode::System)), + }; + let cases = [ + (None, AuthRouteConfig::auto(), None), + ( + Some(SystemProxyFeatureMode::Auto), + AuthRouteConfig::auto(), + None, + ), + ( + Some(SystemProxyFeatureMode::Env), + AuthRouteConfig::env(), + None, + ), + ( + Some(SystemProxyFeatureMode::Direct), + AuthRouteConfig::direct(), + None, + ), + ( + Some(SystemProxyFeatureMode::System), + system_config.clone(), + Some(system_config), + ), + ]; + + for (mode, expected_config, expected_startup_config) in cases { + let system_proxy = SystemProxyFeatureConfigToml { + enabled: Some(true), + mode, + }; + assert_eq!( + auth_route_config_from_system_proxy_config(&system_proxy), + expected_config + ); + assert_eq!( + bootstrap_auth_route_config_from_system_proxy_config(Some(&system_proxy)), + expected_startup_config + ); + } + } +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 5a1833b58c..be963395f7 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -26,10 +26,12 @@ use std::time::Duration; use crate::auth::AuthDotJson; use crate::auth::load_auth_dot_json; -use crate::auth::revoke_auth_tokens; +use crate::auth::revoke_auth_tokens_with_auth_route_config; use crate::auth::save_auth; use crate::auth::should_revoke_auth_tokens; +use crate::default_client::build_auth_reqwest_client_with_auth_route_config; use crate::default_client::originator; +use crate::outbound_proxy::AuthRouteConfig; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use crate::token_data::TokenData; @@ -37,7 +39,6 @@ use crate::token_data::parse_chatgpt_jwt_claims; use base64::Engine; use chrono::Utc; use codex_app_server_protocol::AuthMode; -use codex_client::build_reqwest_client_with_custom_ca; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_template::Template; use rand::RngCore; @@ -72,6 +73,7 @@ pub struct ServerOptions { pub forced_chatgpt_workspace_id: Option>, pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, + pub auth_route_config: Option, } impl ServerOptions { @@ -92,8 +94,14 @@ impl ServerOptions { forced_chatgpt_workspace_id, codex_streamlined_login: false, cli_auth_credentials_store_mode, + auth_route_config: None, } } + + pub fn with_auth_route_config(mut self, auth_route_config: Option) -> Self { + self.auth_route_config = auth_route_config; + self + } } /// Handle for a running login callback server. @@ -335,8 +343,15 @@ async fn process_request( } }; - match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, &code) - .await + match exchange_code_for_tokens( + &opts.issuer, + &opts.client_id, + redirect_uri, + pkce, + &code, + opts.auth_route_config.as_ref(), + ) + .await { Ok(tokens) => { if let Err(message) = ensure_workspace_allowed( @@ -352,9 +367,14 @@ async fn process_request( ); } // Obtain API key via token-exchange and persist - let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) - .await - .ok(); + let api_key = obtain_api_key( + &opts.issuer, + &opts.client_id, + &tokens.id_token, + opts.auth_route_config.as_ref(), + ) + .await + .ok(); if let Err(err) = persist_tokens_async( &opts.codex_home, api_key.clone(), @@ -362,6 +382,7 @@ async fn process_request( tokens.access_token.clone(), tokens.refresh_token.clone(), opts.cli_auth_credentials_store_mode, + opts.auth_route_config.as_ref(), ) .await { @@ -717,6 +738,7 @@ pub(crate) async fn exchange_code_for_tokens( redirect_uri: &str, pkce: &PkceCodes, code: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result { #[derive(serde::Deserialize)] struct TokenResponse { @@ -725,8 +747,9 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); + let client = + build_auth_reqwest_client_with_auth_route_config(&token_endpoint, auth_route_config)?; info!( issuer = %sanitize_url_for_logging(issuer), token_endpoint = %sanitize_url_for_logging(&token_endpoint), @@ -793,6 +816,7 @@ pub(crate) async fn persist_tokens_async( access_token: String, refresh_token: String, auth_credentials_store_mode: AuthCredentialsStoreMode, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result<()> { // Reuse existing synchronous logic but run it off the async runtime. let codex_home = codex_home.to_path_buf(); @@ -831,7 +855,9 @@ pub(crate) async fn persist_tokens_async( .map_err(|e| io::Error::other(format!("persist task failed: {e}")))??; if should_revoke_auth_tokens(previous_auth.as_ref(), &auth) - && let Err(err) = revoke_auth_tokens(previous_auth.as_ref()).await + && let Err(err) = + revoke_auth_tokens_with_auth_route_config(previous_auth.as_ref(), auth_route_config) + .await { warn!("failed to revoke superseded auth tokens after login: {err}"); } @@ -1137,14 +1163,16 @@ pub(crate) async fn obtain_api_key( issuer: &str, client_id: &str, id_token: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result { // Token exchange for an API key access token #[derive(serde::Deserialize)] struct ExchangeResp { access_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); + let client = + build_auth_reqwest_client_with_auth_route_config(&token_endpoint, auth_route_config)?; let resp = client .post(token_endpoint) .header("Content-Type", "application/x-www-form-urlencoded") @@ -1157,8 +1185,11 @@ pub(crate) async fn obtain_api_key( urlencoding::encode("urn:ietf:params:oauth:token-type:id_token") )) .send() - .await - .map_err(io::Error::other)?; + .await; + let resp = match resp { + Ok(resp) => resp, + Err(error) => return Err(io::Error::other(error)), + }; if !resp.status().is_success() { return Err(io::Error::other(format!( "api key exchange failed with status {}", @@ -1242,6 +1273,7 @@ mod tests { "new-access".to_string(), "new-refresh".to_string(), AuthCredentialsStoreMode::File, + /*auth_route_config*/ None, ) .await?; @@ -1302,6 +1334,7 @@ mod tests { "new-access".to_string(), "shared-refresh".to_string(), AuthCredentialsStoreMode::File, + /*auth_route_config*/ None, ) .await?; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index bc7bb401e8..aa2e1ad220 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -121,6 +121,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { let opts = ServerOptions { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -183,6 +184,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { let opts = ServerOptions { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -223,6 +225,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< let opts = ServerOptions { codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -264,6 +267,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -324,6 +328,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -392,6 +397,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -537,6 +543,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let first_opts = ServerOptions { codex_home: first_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), port: 0, @@ -558,6 +565,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let second_opts = ServerOptions { codex_home: second_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: login_port, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 281e4a52a0..e630147235 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,6 +8,7 @@ use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::load_config_as_toml_with_cli_and_load_options; +use crate::legacy_core::config::resolve_bootstrap_system_proxy_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use crate::legacy_core::format_exec_policy_error_with_source; @@ -38,7 +39,7 @@ use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadSortKey as AppServerThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; -use codex_cloud_config::cloud_config_bundle_loader_for_storage; +use codex_cloud_config::cloud_config_bundle_loader_for_storage_with_auth_route_config; use codex_config::CloudConfigBundleLoader; use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; @@ -46,9 +47,11 @@ use codex_config::format_config_error_with_source; use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_login::AuthConfig; +use codex_login::auth_route_config_from_system_proxy_config; +use codex_login::bootstrap_auth_route_config_from_system_proxy_config; use codex_login::default_client::originator; use codex_login::default_client::set_default_client_residency_requirement; -use codex_login::enforce_login_restrictions; +use codex_login::enforce_login_restrictions_with_auth_route_config; use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -1019,13 +1022,17 @@ pub async fn run_main( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); - let cloud_config_bundle = cloud_config_bundle_loader_for_storage( + let system_proxy_config = resolve_bootstrap_system_proxy_config(&bootstrap_config_toml); + let auth_route_config = + bootstrap_auth_route_config_from_system_proxy_config(system_proxy_config.as_ref()); + let cloud_config_bundle = cloud_config_bundle_loader_for_storage_with_auth_route_config( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, bootstrap_config_toml .cli_auth_credentials_store .unwrap_or_default(), chatgpt_base_url, + auth_route_config, ) .await; @@ -1220,14 +1227,21 @@ pub async fn run_main( } if !app_server_target.uses_remote_workspace() { + let auth_route_config = config + .system_proxy + .as_ref() + .map(auth_route_config_from_system_proxy_config); #[allow(clippy::print_stderr)] - if let Err(err) = enforce_login_restrictions(&AuthConfig { - codex_home: config.codex_home.to_path_buf(), - auth_credentials_store_mode: config.cli_auth_credentials_store_mode, - forced_login_method: config.forced_login_method, - forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), - chatgpt_base_url: Some(config.chatgpt_base_url.clone()), - }) + if let Err(err) = enforce_login_restrictions_with_auth_route_config( + &AuthConfig { + codex_home: config.codex_home.to_path_buf(), + auth_credentials_store_mode: config.cli_auth_credentials_store_mode, + forced_login_method: config.forced_login_method, + forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), + chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + }, + auth_route_config.as_ref(), + ) .await { eprintln!("{err}"); @@ -1488,11 +1502,15 @@ async fn run_ratatui_app( // and rebuild config. This avoids missing newly available cloud-managed policy due to login // status detection edge cases. if show_login_screen && !uses_remote_workspace { - cloud_config_bundle = cloud_config_bundle_loader_for_storage( + let auth_route_config = bootstrap_auth_route_config_from_system_proxy_config( + initial_config.system_proxy.as_ref(), + ); + cloud_config_bundle = cloud_config_bundle_loader_for_storage_with_auth_route_config( initial_config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, initial_config.cli_auth_credentials_store_mode, initial_config.chatgpt_base_url.clone(), + auth_route_config, ) .await; }