From 1659c4a6291ae956f020c9c7354ff3cc3d050c48 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 13:03:11 -0700 Subject: [PATCH] PAC 2 - Add shared auth system proxy contract (#26707) ## Summary Stacked on #26706. Adds the shared auth/system-proxy contract that later platform resolver PRs plug into. This PR moves Codex-owned auth and startup HTTP clients through a common route-aware boundary, but does not yet add Windows or macOS system proxy resolution. The default path remains unchanged when `respect_system_proxy` is absent or disabled. ## Implementation - Adds `codex-client/src/outbound_proxy.rs` with the shared route-selection model: - `OutboundProxyConfig`; - `ClientRouteClass`; - `RouteFailureClass`; - `build_reqwest_client_for_route`. - Preserves the existing reqwest/default-client behavior when no route config is supplied. - Uses the fixed MVP routing policy when route config is supplied: platform system/PAC/WPAD discovery, then explicit env proxy variables, then direct connection. - Keeps platform-specific system discovery behind the shared client boundary. This PR provides the contract and fallback behavior; later resolver PRs plug in Windows and macOS discovery. - Adds `login::AuthRouteConfig` so auth call sites depend on a small policy type instead of platform resolver details. - Maps the resolved `Config.respect_system_proxy` boolean into `AuthRouteConfig` for auth-owned clients. - Wires the route config through browser login, device-code login, access-token login, login status, logout/revoke, token refresh, API-key exchange, app-server account login, TUI/app startup, cloud-config bootstrap, cloud tasks, plugin auth, and exec startup config loading. ## End-user behavior - No behavior changes by default. - When `respect_system_proxy = true`, auth-owned clients opt into the shared route-aware client path. - On platforms without a resolver implementation in this PR, system discovery is unavailable and the route-aware path falls back to explicit env proxy handling, then direct connection. - Custom CA handling remains separate from proxy route selection and still runs through the shared client builder. - No proxy URLs, PAC contents, or resolved platform details are exposed through the public config surface introduced here. ## Tests Adds or updates coverage for: - preserving default auth-client fallback behavior when no route config is provided; - injected environment-proxy fallback without mutating process environment; - existing login-server E2E flows using explicit `auth_route_config: None` to guard unchanged default behavior; - updated auth manager, login, logout, cloud-config, startup, and plugin-auth call sites passing route config explicitly. --- .../src/transport/remote_control/tests.rs | 3 + .../remote_control/tests/clients_tests.rs | 2 + .../remote_control/tests/pairing_tests.rs | 2 + .../src/transport/remote_control/websocket.rs | 3 + .../request_processors/account_processor.rs | 1 + codex-rs/cli/src/login.rs | 21 +- codex-rs/cli/src/main.rs | 10 +- codex-rs/cli/src/plugin_cmd.rs | 2 + codex-rs/cloud-config/src/bundle_loader.rs | 3 + codex-rs/cloud-config/src/service_tests.rs | 7 + codex-rs/cloud-tasks/src/util.rs | 1 + codex-rs/codex-client/src/lib.rs | 6 + codex-rs/codex-client/src/outbound_proxy.rs | 322 ++++++++++++++++++ .../codex-client/src/outbound_proxy_tests.rs | 133 ++++++++ codex-rs/core/src/config/mod.rs | 19 ++ codex-rs/core/tests/suite/client.rs | 1 + codex-rs/exec/src/lib.rs | 12 + codex-rs/login/src/auth/agent_identity.rs | 43 ++- codex-rs/login/src/auth/auth_tests.rs | 45 +++ .../login/src/auth/bedrock_api_key_tests.rs | 3 + codex-rs/login/src/auth/default_client.rs | 61 +++- codex-rs/login/src/auth/manager.rs | 94 ++++- .../login/src/auth/personal_access_token.rs | 21 +- .../src/auth/personal_access_token_tests.rs | 7 +- codex-rs/login/src/auth/revoke.rs | 6 +- codex-rs/login/src/device_code_auth.rs | 9 +- codex-rs/login/src/lib.rs | 2 + codex-rs/login/src/outbound_proxy.rs | 22 ++ codex-rs/login/src/server.rs | 36 +- codex-rs/login/tests/suite/auth_refresh.rs | 1 + .../login/tests/suite/device_code_login.rs | 3 + .../login/tests/suite/login_server_e2e.rs | 9 + codex-rs/login/tests/suite/logout.rs | 4 + codex-rs/models-manager/src/manager_tests.rs | 1 + codex-rs/tui/src/lib.rs | 13 + codex-rs/tui/src/onboarding/auth.rs | 1 + codex-rs/tui/src/session_archive_commands.rs | 10 + 37 files changed, 885 insertions(+), 54 deletions(-) create mode 100644 codex-rs/codex-client/src/outbound_proxy.rs create mode 100644 codex-rs/codex-client/src/outbound_proxy_tests.rs create mode 100644 codex-rs/login/src/outbound_proxy.rs diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 6c453e6f9..44299c4b5 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1041,6 +1041,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let (transport_event_tx, _transport_event_rx) = @@ -1870,6 +1871,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let expected_server_name = gethostname().to_string_lossy().trim().to_string(); @@ -1966,6 +1968,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let remote_control_target = diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 6354e435c..21ec02ab0 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -185,6 +185,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -270,6 +271,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 190526d72..380f13984 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -539,6 +539,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -808,6 +809,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let remote_handle = diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 239d5d9ea..b9f1c7175 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -2214,6 +2214,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2312,6 +2313,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2435,6 +2437,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); 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 4c8538e2b..6aa624153 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -359,6 +359,7 @@ impl AccountRequestProcessor { config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) }; #[cfg(debug_assertions)] diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index dc3d0af87..5691c3cd1 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; use codex_login::AuthKeyringBackendKind; +use codex_login::AuthRouteConfig; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; @@ -119,11 +120,13 @@ async fn clear_existing_auth_before_login( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) { if let Err(err) = logout_with_revoke( codex_home, auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, ) .await { @@ -136,11 +139,13 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> std::io::Result<()> { clear_existing_auth_before_login( &codex_home, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config.as_ref(), ) .await; @@ -150,6 +155,7 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, ); let server = run_login_server(opts)?; @@ -169,12 +175,12 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> } let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - match login_with_chatgpt( config.codex_home.to_path_buf(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await { @@ -232,6 +238,7 @@ pub async fn run_login_with_access_token( std::process::exit(1); } + let auth_route_config = config.auth_route_config(); match login_with_access_token( &config.codex_home, &access_token, @@ -239,6 +246,7 @@ pub async fn run_login_with_access_token( config.forced_chatgpt_workspace_id.as_deref(), Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -307,10 +315,12 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + let auth_route_config = config.auth_route_config(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); @@ -320,6 +330,7 @@ pub async fn run_login_with_device_code( forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -352,10 +363,12 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + let auth_route_config = config.auth_route_config(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await; @@ -366,6 +379,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -409,12 +423,14 @@ 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 auth_route_config = config.auth_route_config(); match CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -459,11 +475,13 @@ 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_route_config = config.auth_route_config(); match logout_with_revoke( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -536,6 +554,7 @@ mod tests { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9d278c212..8fd1358ef 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1738,9 +1738,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 auth_route_config = config.auth_route_config(); + let auth = CodexAuth::from_agent_identity_jwt( + &agent_identity_jwt, + Some(&config.chatgpt_base_url), + auth_route_config.as_ref(), + ) + .await?; return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 1fc670c87..433714a00 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -566,11 +566,13 @@ pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option { return Some(CodexAuth::from_api_key(&api_key).api_auth_mode()); } + let auth_route_config = config.auth_route_config(); CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await .ok() diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 5ec1ab260..e4266c619 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -7,6 +7,7 @@ use codex_config::CloudConfigBundleLoader; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -58,6 +59,7 @@ pub async fn cloud_config_bundle_loader_for_storage( credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, chatgpt_base_url: String, + auth_route_config: Option, ) -> CloudConfigBundleLoader { let auth_manager = AuthManager::shared( codex_home.clone(), @@ -66,6 +68,7 @@ pub async fn cloud_config_bundle_loader_for_storage( /*forced_chatgpt_workspace_id*/ None, Some(chatgpt_base_url.clone()), keyring_backend_kind, + auth_route_config, ) .await; cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 96fed2f58..47b49d43b 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -53,6 +53,7 @@ async fn auth_manager_with_api_key() -> Arc { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -83,6 +84,7 @@ async fn auth_manager_with_plan_and_identity( /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -108,6 +110,7 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc { task_id: Some("task-123".to_string()), }, "https://auth.openai.com/api/accounts", + /*auth_route_config*/ None, ) .await .expect("agent identity record should be complete"), @@ -686,6 +689,7 @@ async fn get_bundle_recovers_after_unauthorized_reload() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -742,6 +746,7 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -806,6 +811,7 @@ async fn get_bundle_surfaces_auth_recovery_message() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -872,6 +878,7 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 65db955f1..2a836f857 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -52,6 +52,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option) -> 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", + }) + } +} + +/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing. +/// Resolved endpoints and platform details remain internal to the client builder. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutboundProxyConfig; + +impl OutboundProxyConfig { + pub const fn respect_system_proxy() -> Self { + Self + } +} + +/// 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 }, +} + +impl From for io::Error { + fn from(error: BuildRouteAwareHttpClientError) -> Self { + match error { + BuildRouteAwareHttpClientError::CustomCa(error) => error.into(), + BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } => io::Error::other(error), + } + } +} + +/// Builds a reqwest client with conservative route selection and shared CA handling. +/// +/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after +/// a route is selected are returned without trying another route. +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(&ProcessEnv, builder, request_url, route_class, config)?; + build_reqwest_client_with_custom_ca(builder).map_err(Into::into) +} + +fn configure_proxy_for_route( + env: &dyn EnvSource, + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, +) -> Result { + if config.is_none() { + return Ok(builder); + } + let origin = RequestOrigin::parse(request_url); + + let Some(origin) = origin.as_ref() else { + return configure_env_proxy_handling(env, builder, /*origin*/ None, route_class); + }; + + match resolve_system_proxy(request_url, origin) { + SystemProxyDecision::Direct => Ok(builder.no_proxy()), + SystemProxyDecision::Proxy { url } => { + configure_concrete_proxy(builder, route_class, &url, /*no_proxy*/ None) + } + SystemProxyDecision::Unavailable { .. } => { + configure_env_proxy_handling(env, builder, Some(origin), route_class) + } + } +} + +fn configure_concrete_proxy( + builder: reqwest::ClientBuilder, + route_class: ClientRouteClass, + proxy_url: &str, + no_proxy: Option, +) -> Result { + let proxy = match reqwest::Proxy::all(proxy_url) { + Ok(proxy) => proxy, + Err(_source) => { + return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class }); + } + }; + Ok(builder.proxy(proxy.no_proxy(no_proxy))) +} + +fn configure_env_proxy_handling( + env: &dyn EnvSource, + builder: reqwest::ClientBuilder, + origin: Option<&RequestOrigin>, + route_class: ClientRouteClass, +) -> Result { + if let Some(origin) = origin { + let proxy_url = match origin.scheme.as_str() { + "https" => { + proxy_env_value(env, "HTTPS_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) + } + "http" => { + proxy_env_value(env, "HTTP_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) + } + _ => proxy_env_value(env, "ALL_PROXY"), + }; + if let Some(proxy_url) = proxy_url { + let no_proxy = proxy_env_value(env, "NO_PROXY") + .and_then(|value| reqwest::NoProxy::from_string(&value)); + return configure_concrete_proxy(builder, route_class, &proxy_url, no_proxy); + } + } + Ok(builder.no_proxy()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +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(match scheme.as_str() { + "http" => Some(80), + "https" => Some(443), + _ => None, + })?; + Some(Self { scheme, host, port }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow( + dead_code, + reason = "Direct and Proxy are constructed by platform resolvers added in later PRs" +)] +enum SystemProxyDecision { + Direct, + Proxy { url: String }, + Unavailable { failure: RouteFailureClass }, +} + +fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + if let Some(decision) = cached_system_proxy_decision(request_url) { + return decision; + } + + let decision = resolve_platform_system_proxy(request_url, origin); + cache_system_proxy_decision(request_url, decision.clone()); + decision +} + +fn resolve_platform_system_proxy( + _request_url: &str, + _origin: &RequestOrigin, +) -> 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) -> Option { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().ok()?; + let cached = cache.get(request_url)?; + if cached.expires_at > Instant::now() { + return Some(cached.decision.clone()); + } + cache.remove(request_url); + None +} + +fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut cache) = cache.lock() { + insert_system_proxy_cache_entry(&mut cache, request_url, decision, Instant::now()); + } +} + +fn insert_system_proxy_cache_entry( + cache: &mut HashMap, + request_url: &str, + decision: SystemProxyDecision, + now: Instant, +) { + let ttl = match &decision { + SystemProxyDecision::Direct | SystemProxyDecision::Proxy { .. } => { + SYSTEM_PROXY_SUCCESS_CACHE_TTL + } + SystemProxyDecision::Unavailable { .. } => SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL, + }; + + cache.retain(|_, cached| cached.expires_at > now); + if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES + && !cache.contains_key(request_url) + && let Some(request_url_to_evict) = cache + .iter() + .min_by_key(|(_, cached)| cached.expires_at) + .map(|(request_url, _)| request_url.clone()) + { + cache.remove(&request_url_to_evict); + } + cache.insert( + request_url.to_string(), + CachedSystemProxyDecision { + decision, + expires_at: now + ttl, + }, + ); +} + +trait EnvSource { + fn var(&self, key: &str) -> Option; +} + +struct ProcessEnv; + +impl EnvSource for ProcessEnv { + fn var(&self, key: &str) -> Option { + std::env::var(key).ok() + } +} + +fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option { + let lower = upper.to_ascii_lowercase(); + env.var(upper) + .or_else(|| env.var(&lower)) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +#[path = "outbound_proxy_tests.rs"] +mod tests; diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/codex-client/src/outbound_proxy_tests.rs new file mode 100644 index 000000000..9c713d28f --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy_tests.rs @@ -0,0 +1,133 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io::Read; +use std::io::Write; + +struct MapEnv { + values: HashMap, +} + +impl EnvSource for MapEnv { + fn var(&self, key: &str) -> Option { + self.values.get(key).cloned() + } +} + +#[test] +fn proxy_env_value_matches_reqwest_casing_precedence() { + let env = MapEnv { + values: HashMap::from([ + ("HTTPS_PROXY".to_string(), "upper".to_string()), + ("https_proxy".to_string(), "lower".to_string()), + ("http_proxy".to_string(), "lower-only".to_string()), + ("ALL_PROXY".to_string(), String::new()), + ("all_proxy".to_string(), "masked".to_string()), + ]), + }; + + assert_eq!( + proxy_env_value(&env, "HTTPS_PROXY"), + Some("upper".to_string()) + ); + assert_eq!( + proxy_env_value(&env, "HTTP_PROXY"), + Some("lower-only".to_string()) + ); + assert_eq!(proxy_env_value(&env, "ALL_PROXY"), None); +} + +#[test] +fn environment_fallback_reads_injected_proxy_environment() { + let env = MapEnv { + values: HashMap::from([("HTTPS_PROXY".to_string(), "://invalid".to_string())]), + }; + let origin = RequestOrigin::parse("https://auth.openai.com/oauth/token").expect("valid URL"); + let result = configure_env_proxy_handling( + &env, + reqwest::Client::builder(), + Some(&origin), + ClientRouteClass::Auth, + ); + + assert!(matches!( + result, + Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { + route_class: ClientRouteClass::Auth, + }) + )); +} + +#[tokio::test] +async fn enabled_environment_proxy_routes_request_through_proxy() { + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).expect("local proxy listener should bind"); + let proxy_addr = listener + .local_addr() + .expect("local proxy listener should have an address"); + let proxy_thread = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("proxy should accept a request"); + let mut buffer = [0_u8; 4096]; + let size = stream.read(&mut buffer).expect("proxy should read request"); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .expect("proxy should write response"); + String::from_utf8_lossy(&buffer[..size]).into_owned() + }); + let env = MapEnv { + values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]), + }; + let request_url = "http://enabled-proxy.test/proxy-check"; + let config = OutboundProxyConfig::respect_system_proxy(); + let builder = configure_proxy_for_route( + &env, + reqwest::Client::builder().timeout(Duration::from_secs(2)), + request_url, + ClientRouteClass::Auth, + Some(&config), + ) + .expect("enabled proxy route should configure"); + + let response = builder + .build() + .expect("proxy client should build") + .get(request_url) + .send() + .await + .expect("request should use local proxy"); + let proxy_request = proxy_thread.join().expect("proxy thread should finish"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + proxy_request.lines().next(), + Some("GET http://enabled-proxy.test/proxy-check HTTP/1.1") + ); +} + +#[test] +fn unavailable_system_proxy_decision_is_cached() { + let request_url = "https://unavailable-cache.test/oauth/token"; + let decision = SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + + cache_system_proxy_decision(request_url, decision.clone()); + + assert_eq!(cached_system_proxy_decision(request_url), Some(decision)); +} + +#[test] +fn system_proxy_cache_is_bounded() { + let mut cache = HashMap::new(); + let now = Instant::now(); + + for index in 0..=SYSTEM_PROXY_CACHE_MAX_ENTRIES { + insert_system_proxy_cache_entry( + &mut cache, + &format!("https://bounded-cache.test/{index}"), + SystemProxyDecision::Direct, + now, + ); + } + + assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES); +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 17161e1a3..19d4bb44c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -72,6 +72,7 @@ use codex_features::TokenBudgetConfigToml; 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_mcp::McpConfig; use codex_mcp::McpPluginAttribution; use codex_mcp::McpServerRegistration; @@ -1201,6 +1202,10 @@ impl AuthManagerConfig for Config { fn chatgpt_base_url(&self) -> String { self.chatgpt_base_url.clone() } + + fn auth_route_config(&self) -> Option { + Config::auth_route_config(self) + } } #[derive(Clone, Default)] @@ -1454,6 +1459,11 @@ impl Config { } } + pub fn auth_route_config(&self) -> Option { + self.respect_system_proxy + .then(AuthRouteConfig::respect_system_proxy) + } + /// Build the plugin-manager input from the effective config. pub fn plugins_config_input(&self) -> PluginsConfigInput { PluginsConfigInput::new( @@ -2737,6 +2747,15 @@ pub fn resolve_bootstrap_respect_system_proxy( Ok(features.get().enabled(Feature::RespectSystemProxy)) } +/// Resolves auth route settings for the initial cloud-config bootstrap. +pub fn resolve_bootstrap_auth_route_config( + cfg: &ConfigToml, + feature_requirements: Option<&Sourced>, +) -> std::io::Result> { + resolve_bootstrap_respect_system_proxy(cfg, feature_requirements) + .map(|enabled| enabled.then(AuthRouteConfig::respect_system_proxy)) +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cbd014c3f..d0e68c8a9 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1385,6 +1385,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("Failed to load CodexAuth") diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a04e25855..08c905c00 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -66,6 +66,7 @@ use codex_core::config::ConfigTomlLoadResult; use codex_core::config::find_codex_home; use codex_core::config::load_config_toml_with_layer_stack; use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use codex_core::config::resolve_bootstrap_auth_route_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; @@ -349,6 +350,14 @@ 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 auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -357,6 +366,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); @@ -468,6 +478,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result set_default_client_residency_requirement(config.enforce_residency.value()); + let auth_route_config = config.auth_route_config(); 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, @@ -475,6 +486,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result 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, }) .await { diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 370af883c..a164c7e3a 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -3,6 +3,9 @@ use std::sync::Arc; use codex_agent_identity::AgentIdentityKey; use codex_agent_identity::ChatGptEnvironment; +use codex_agent_identity::agent_identity_jwks_url; +use codex_agent_identity::agent_registration_url; +use codex_agent_identity::agent_task_registration_url; use codex_agent_identity::build_abom; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; @@ -15,7 +18,8 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::SessionSource; use thiserror::Error; -use crate::default_client::build_reqwest_client; +use crate::default_client::build_default_auth_reqwest_client; +use crate::outbound_proxy::AuthRouteConfig; use super::storage::AgentIdentityAuthRecord; @@ -96,13 +100,18 @@ impl AgentIdentityAuth { pub async fn from_record( mut record: AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key) .map_err(std::io::Error::other)?; if record_needs_task_registration(&record) { record.task_id = Some( - register_task_for_record_with_retries(&record, agent_identity_authapi_base_url) - .await?, + register_task_for_record_with_retries( + &record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?, ); } Ok(Self { @@ -114,9 +123,10 @@ impl AgentIdentityAuth { jwt: &str, chatgpt_base_url: &str, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { - let record = verified_record_from_jwt(jwt, chatgpt_base_url).await?; - Self::from_record(record, agent_identity_authapi_base_url).await + let record = verified_record_from_jwt(jwt, chatgpt_base_url, auth_route_config).await?; + Self::from_record(record, agent_identity_authapi_base_url, auth_route_config).await } #[cfg(test)] @@ -163,9 +173,11 @@ pub(super) async fn register_managed_chatgpt_agent_identity( binding: ManagedChatGptAgentIdentityBinding, agent_identity_authapi_base_url: &str, session_source: SessionSource, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let key_material = generate_agent_key_material().map_err(std::io::Error::other)?; - let client = build_reqwest_client(); + let registration_url = agent_registration_url(agent_identity_authapi_base_url); + let client = build_default_auth_reqwest_client(®istration_url, auth_route_config)?; let runtime_id = retry_registration(|| async { register_agent_identity( &client, @@ -200,7 +212,7 @@ pub(super) async fn register_managed_chatgpt_agent_identity( chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp, task_id: None, }; - AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) + AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url, auth_route_config) .await .map_err(|err| classify_bootstrap_error("agent task registration", err)) } @@ -208,9 +220,12 @@ pub(super) async fn register_managed_chatgpt_agent_identity( pub(super) async fn verified_record_from_jwt( 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_auth_reqwest_client(&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)?; @@ -285,19 +300,24 @@ where async fn register_task_for_record_with_retries( record: &AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { + let task_registration_url = + agent_task_registration_url(agent_identity_authapi_base_url, &record.agent_runtime_id); + let client = build_default_auth_reqwest_client(&task_registration_url, auth_route_config)?; retry_registration(|| async { - register_task_for_record(record, agent_identity_authapi_base_url).await + register_task_for_record(&client, record, agent_identity_authapi_base_url).await }) .await } async fn register_task_for_record( + client: &reqwest::Client, record: &AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, ) -> std::io::Result { register_agent_task( - &build_reqwest_client(), + client, agent_identity_authapi_base_url, key_for_record(record), ) @@ -370,6 +390,7 @@ mod tests { let auth = AgentIdentityAuth::from_record( agent_identity_record_with_generated_key(), &server.uri(), + /*auth_route_config*/ None, ) .await?; @@ -414,6 +435,7 @@ mod tests { &jwt, &format!("{}/backend-api", server.uri()), &server.uri(), + /*auth_route_config*/ None, ) .await?; @@ -456,6 +478,7 @@ mod tests { let auth = AgentIdentityAuth::from_record( agent_identity_record_with_generated_key(), &server.uri(), + /*auth_route_config*/ None, ) .await?; diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index b52f03b70..fbb6aac8e 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -122,6 +122,7 @@ async fn login_with_access_token_writes_agent_identity_jwt() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("login_with_access_token should succeed"); @@ -181,6 +182,7 @@ async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -226,6 +228,7 @@ async fn login_with_access_token_writes_only_personal_access_token() { Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -278,6 +281,7 @@ async fn login_with_access_token_rejects_personal_access_token_workspace_mismatc Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("personal access token workspace mismatch should fail"); @@ -310,6 +314,7 @@ async fn login_with_access_token_rejects_invalid_personal_access_token() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("invalid personal access token should fail"); @@ -333,6 +338,7 @@ async fn login_with_access_token_rejects_invalid_jwt() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("invalid access token should fail"); @@ -364,6 +370,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -373,6 +380,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::JwtOnly, /*agent_identity_authapi_base_url*/ None, /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -403,6 +411,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -412,6 +421,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -442,6 +452,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should reload"); @@ -450,6 +461,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -482,6 +494,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -510,6 +523,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -546,6 +560,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -563,6 +578,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -605,6 +621,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -625,6 +642,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -659,6 +677,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -676,6 +695,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -717,6 +737,7 @@ async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> an &agent_identity, Some(&chatgpt_base_url), &authapi_base_url, + /*auth_route_config*/ None, ) .await .expect_err("agent identity jwt task retry exhaustion should fail"); @@ -747,6 +768,7 @@ async fn login_with_access_token_rejects_unsigned_jwt() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("unsigned access token should fail"); @@ -768,6 +790,7 @@ async fn missing_auth_json_returns_none() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("call should succeed"); @@ -797,6 +820,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -858,6 +882,7 @@ async fn loads_api_key_from_auth_json() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -908,6 +933,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let managed = UnauthorizedRecovery { @@ -952,6 +978,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -972,6 +999,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("updated auth should parse"); @@ -1277,6 +1305,7 @@ async fn build_config( forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, + auth_route_config: None, } } @@ -1359,6 +1388,7 @@ async fn load_auth_reads_access_token_from_env() { Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1406,6 +1436,7 @@ async fn load_auth_reads_personal_access_token_from_env() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1459,6 +1490,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1498,6 +1530,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -1509,6 +1542,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1542,6 +1576,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -1574,6 +1609,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1670,6 +1706,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -1681,6 +1718,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: None, + auth_route_config: None, }; let err = super::enforce_login_restrictions(&config) @@ -1804,6 +1842,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), + auth_route_config: None, }; let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url( @@ -2056,6 +2095,7 @@ async fn assert_agent_identity_plan_alias( &jwt, Some(&chatgpt_base_url), &authapi_base_url, + /*auth_route_config*/ None, ) .await .expect("agent identity auth"); @@ -2087,6 +2127,7 @@ async fn plan_type_maps_known_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2118,6 +2159,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2152,6 +2194,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2186,6 +2229,7 @@ async fn plan_type_maps_unknown_to_unknown() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2217,6 +2261,7 @@ async fn missing_plan_type_maps_to_unknown() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index 691d8bbc5..7db2672ce 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -63,6 +63,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -111,6 +112,7 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -135,6 +137,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index ee51edf9b..f72727bfc 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. @@ -189,6 +194,9 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String { } /// Create an HTTP client with default `originator` and `User-Agent` headers set. +/// +/// This supported default path preserves reqwest's existing proxy behavior and does not opt into +/// Codex's route-aware system/PAC resolution. pub fn create_client() -> CodexHttpClient { let inner = build_reqwest_client(); CodexHttpClient::new(inner) @@ -200,6 +208,10 @@ pub fn create_client() -> CodexHttpClient { /// policy, then layers in shared custom CA handling from `CODEX_CA_CERTIFICATE` / /// `SSL_CERT_FILE`. The function remains infallible for compatibility with existing call sites, so /// a custom-CA or builder failure is logged and falls back to `reqwest::Client::new()`. +/// +/// This supported default path preserves reqwest's existing proxy behavior and does not opt into +/// Codex's route-aware system/PAC resolution. Auth callers with route settings must use +/// `build_default_auth_reqwest_client` or `create_default_auth_client`. pub fn build_reqwest_client() -> reqwest::Client { try_build_reqwest_client().unwrap_or_else(|error| { tracing::warn!(error = %error, "failed to build default reqwest client"); @@ -220,13 +232,58 @@ 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) +/// Builds a raw reqwest client for an auth endpoint without Codex default headers. +pub(crate) fn build_raw_auth_reqwest_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_reqwest_client_for_route( + reqwest::Client::builder(), + endpoint, + ClientRouteClass::Auth, + auth_route_config.map(AuthRouteConfig::route_config), + ) +} + +/// Builds the default Codex reqwest client for an auth endpoint. +pub(crate) fn build_default_auth_reqwest_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else { + return Ok(build_reqwest_client()); + }; + + if is_sandboxed() { + // Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed + // separately through network-proxy. + return Ok(build_reqwest_client()); + } + build_reqwest_client_for_route( + default_reqwest_client_builder(), + endpoint, + ClientRouteClass::Auth, + Some(route_config), + ) +} + +/// Builds the default Codex HTTP client wrapper for an auth endpoint. +pub(crate) fn create_default_auth_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_default_auth_reqwest_client(endpoint, auth_route_config).map(CodexHttpClient::new) } pub fn default_headers() -> HeaderMap { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 138bdad5b..a762bd546 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -49,6 +49,8 @@ use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +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; @@ -237,6 +239,7 @@ impl CodexAuth { chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, agent_identity_authapi_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); if auth_mode == ApiAuthMode::ApiKey { @@ -263,14 +266,18 @@ impl CodexAuth { &jwt, &base_url, agent_identity_authapi_base_url, + auth_route_config, ) .await?; return Ok(Self::AgentIdentity(auth)); } AgentIdentityStorage::Record(record) => { - let auth = - AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) - .await?; + let auth = AgentIdentityAuth::from_record( + record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?; return Ok(Self::AgentIdentity(auth)); } } @@ -281,7 +288,8 @@ impl CodexAuth { "personal access token auth is missing a personal access token.", )); }; - return Self::from_personal_access_token(personal_access_token).await; + return Self::from_personal_access_token(personal_access_token, auth_route_config) + .await; } if auth_mode == ApiAuthMode::BedrockApiKey { let Some(auth) = auth_dot_json.bedrock_api_key else { @@ -293,9 +301,10 @@ impl CodexAuth { } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); + let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?; let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client: create_client(), + client, }; match auth_mode { @@ -324,6 +333,7 @@ impl CodexAuth { auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url).ok(); @@ -335,6 +345,7 @@ impl CodexAuth { chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url.as_deref(), + auth_route_config, ) .await } @@ -342,12 +353,14 @@ impl CodexAuth { pub async fn from_agent_identity_jwt( jwt: &str, chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?; Self::from_agent_identity_jwt_with_authapi_base_url( jwt, chatgpt_base_url, &agent_identity_authapi_base_url, + auth_route_config, ) .await } @@ -356,19 +369,29 @@ impl CodexAuth { jwt: &str, chatgpt_base_url: Option<&str>, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let base_url = chatgpt_base_url .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); Ok(Self::AgentIdentity( - AgentIdentityAuth::from_jwt(jwt, &base_url, agent_identity_authapi_base_url).await?, + AgentIdentityAuth::from_jwt( + jwt, + &base_url, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?, )) } - pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { + pub async fn from_personal_access_token( + access_token: &str, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { Ok(Self::PersonalAccessToken( - PersonalAccessTokenAuth::load(access_token).await?, + PersonalAccessTokenAuth::load(access_token, auth_route_config).await?, )) } @@ -568,6 +591,7 @@ impl CodexAuth { policy: AgentIdentityAuthPolicy, agent_identity_authapi_base_url: Option<&str>, forced_chatgpt_workspace_id: Option>, + auth_route_config: Option<&AuthRouteConfig>, session_source: SessionSource, ) -> std::io::Result> { match self { @@ -583,6 +607,7 @@ impl CodexAuth { self.ensure_managed_chatgpt_agent_identity( require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, forced_chatgpt_workspace_id, + auth_route_config, session_source, ) .await @@ -595,6 +620,7 @@ impl CodexAuth { &self, agent_identity_authapi_base_url: &str, forced_chatgpt_workspace_id: Option>, + auth_route_config: Option<&AuthRouteConfig>, session_source: SessionSource, ) -> std::io::Result { let binding = @@ -607,9 +633,13 @@ impl CodexAuth { && record_matches_managed_chatgpt_binding(&record, &binding) { let should_persist = record_needs_task_registration(&record); - let auth = AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) - .await - .map_err(|err| classify_bootstrap_error("agent task registration", err))?; + let auth = AgentIdentityAuth::from_record( + record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await + .map_err(|err| classify_bootstrap_error("agent task registration", err))?; if should_persist { self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; } @@ -620,6 +650,7 @@ impl CodexAuth { binding, agent_identity_authapi_base_url, session_source, + auth_route_config, ) .await?; self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; @@ -789,6 +820,7 @@ pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_dot_json = match load_auth_dot_json( codex_home, @@ -801,7 +833,7 @@ pub async fn logout_with_revoke( None } }; - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), auth_route_config).await { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } logout_all_stores( @@ -843,10 +875,11 @@ pub async fn login_with_access_token( forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result<()> { let auth_dot_json = match classify_codex_access_token(access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { - let auth = PersonalAccessTokenAuth::load(access_token).await?; + let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; AuthDotJson { // Infer PAT auth from the credential field so older Codex builds can still @@ -865,7 +898,7 @@ pub async fn login_with_access_token( .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); - verified_record_from_jwt(jwt, &base_url).await?; + verified_record_from_jwt(jwt, &base_url, auth_route_config).await?; AuthDotJson { auth_mode: Some(ApiAuthMode::AgentIdentity), openai_api_key: None, @@ -954,8 +987,10 @@ pub struct AuthConfig { pub forced_login_method: Option, pub chatgpt_base_url: Option, pub forced_chatgpt_workspace_id: Option>, + pub auth_route_config: Option, } +/// Enforces configured login restrictions using auth-owned HTTP settings. pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok(); @@ -978,6 +1013,7 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, agent_identity_authapi_base_url, + config.auth_route_config.as_ref(), ) .await? else { @@ -1113,6 +1149,7 @@ fn logout_all_stores( Ok(removed_ephemeral || removed_managed) } +#[allow(clippy::too_many_arguments)] async fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, @@ -1121,6 +1158,7 @@ async fn load_auth( chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, agent_identity_authapi_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() { @@ -1142,6 +1180,7 @@ async fn load_auth( chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1153,7 +1192,7 @@ async fn load_auth( if let Some(access_token) = read_codex_access_token_from_env() { return match classify_codex_access_token(&access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { - let auth = PersonalAccessTokenAuth::load(access_token).await?; + let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; Ok(Some(CodexAuth::PersonalAccessToken(auth))) } @@ -1162,6 +1201,7 @@ async fn load_auth( jwt, chatgpt_base_url, require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, + auth_route_config, ) } .await @@ -1192,6 +1232,7 @@ async fn load_auth( chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1237,7 +1278,6 @@ async fn request_chatgpt_token_refresh( grant_type: "refresh_token", refresh_token, }; - let endpoint = refresh_token_endpoint(); // Use shared client factory to include standard headers @@ -1702,6 +1742,7 @@ pub struct AuthManager { refresh_lock: Semaphore, agent_identity_lock: Semaphore, external_auth: RwLock>>, + auth_route_config: Option, } /// Configuration view required to construct a shared [`AuthManager`]. @@ -1725,6 +1766,9 @@ 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; } impl Debug for AuthManager { @@ -1743,6 +1787,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() } @@ -1764,6 +1809,7 @@ impl AuthManager { forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Self { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok(); @@ -1775,6 +1821,7 @@ impl AuthManager { chatgpt_base_url.as_deref(), keyring_backend_kind, agent_identity_authapi_base_url.as_deref(), + auth_route_config.as_ref(), ) .await .ok() @@ -1796,6 +1843,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config, } } @@ -1820,6 +1868,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1843,6 +1892,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1874,6 +1924,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1897,6 +1948,7 @@ impl AuthManager { external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc )), + auth_route_config: None, }) } @@ -1958,6 +2010,7 @@ impl AuthManager { policy, self.agent_identity_authapi_base_url.as_deref(), self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), session_source, ) .await; @@ -1966,6 +2019,7 @@ impl AuthManager { policy, self.agent_identity_authapi_base_url.as_deref(), self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), session_source, ) .await @@ -2074,6 +2128,7 @@ impl AuthManager { self.chatgpt_base_url.as_deref(), self.keyring_backend_kind, self.agent_identity_authapi_base_url.as_deref(), + self.auth_route_config.as_ref(), ) .await .ok() @@ -2149,6 +2204,7 @@ impl AuthManager { forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Arc { Arc::new( Self::new( @@ -2158,6 +2214,7 @@ impl AuthManager { forced_chatgpt_workspace_id, chatgpt_base_url, keyring_backend_kind, + auth_route_config, ) .await, ) @@ -2175,6 +2232,7 @@ impl AuthManager { config.forced_chatgpt_workspace_id(), Some(config.chatgpt_base_url()), config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await } @@ -2328,7 +2386,9 @@ 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(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( diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index b99092f51..5c9fcc3bd 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -5,7 +5,8 @@ use serde::Deserialize; use std::env; use std::fmt; -use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL"; @@ -36,13 +37,18 @@ impl fmt::Debug for PersonalAccessTokenAuth { } impl PersonalAccessTokenAuth { - pub(super) async fn load(access_token: &str) -> std::io::Result { + pub(super) async fn load( + access_token: &str, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR) .ok() .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) .filter(|base_url| !base_url.is_empty()) .unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string()); - hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await + let endpoint = whoami_endpoint(&authapi_base_url); + let client = create_default_auth_client(&endpoint, auth_route_config)?; + hydrate_personal_access_token(&client, &endpoint, access_token).await } pub fn access_token(&self) -> &str { @@ -72,12 +78,11 @@ impl PersonalAccessTokenAuth { async fn hydrate_personal_access_token( client: &CodexHttpClient, - authapi_base_url: &str, + endpoint: &str, access_token: &str, ) -> std::io::Result { - let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')); let response = client - .get(&endpoint) + .get(endpoint) .bearer_auth(access_token) .send() .await @@ -107,6 +112,10 @@ async fn hydrate_personal_access_token( }) } +fn whoami_endpoint(authapi_base_url: &str) -> String { + format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')) +} + #[cfg(test)] #[path = "personal_access_token_tests.rs"] mod tests; diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs index ac6ee1226..4d1b5e1ef 100644 --- a/codex-rs/login/src/auth/personal_access_token_tests.rs +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::default_client::create_client; use pretty_assertions::assert_eq; use serde_json::json; use wiremock::Mock; @@ -29,7 +30,8 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { .mount(&server) .await; - let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + let endpoint = whoami_endpoint(&server.uri()); + let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await .expect("personal access token hydration should succeed"); @@ -59,7 +61,8 @@ async fn hydrate_rejects_missing_email() { .mount(&server) .await; - let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + let endpoint = whoami_endpoint(&server.uri()); + let err = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await .expect_err("personal access token hydration should reject missing email"); diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index 22de353d3..0f7e313c1 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -16,7 +16,8 @@ use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use super::manager::oauth_client_id; use super::storage::AuthDotJson; use super::util::try_parse_error_message; -use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10); @@ -53,13 +54,14 @@ struct RevokeTokenRequest<'a> { pub(super) async fn revoke_auth_tokens( 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_default_auth_client(&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 6f18c5984..70e6dff22 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_raw_auth_reqwest_client; 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,8 +157,10 @@ 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('/'); + // The route selected for the issuer is reused for all device-auth endpoint paths; the endpoint + // paths are not resolved separately. + let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; @@ -174,8 +176,8 @@ 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 client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); let code_resp = poll_for_token( @@ -199,6 +201,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}")))?; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 2b8e48b4a..44c2f1c4d 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; @@ -53,4 +54,5 @@ 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 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 000000000..bf7dfbeb4 --- /dev/null +++ b/codex-rs/login/src/outbound_proxy.rs @@ -0,0 +1,22 @@ +use codex_client::OutboundProxyConfig; + +/// Auth-layer adapter around client-owned proxy policy. +/// +/// `AuthConfig` carries this value while endpoint resolution and platform details remain in the +/// client layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthRouteConfig { + route_config: OutboundProxyConfig, +} + +impl AuthRouteConfig { + pub fn respect_system_proxy() -> Self { + Self { + route_config: OutboundProxyConfig::respect_system_proxy(), + } + } + + pub(crate) fn route_config(&self) -> &OutboundProxyConfig { + &self.route_config + } +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 67d5e8c66..c450da0f6 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -27,7 +27,9 @@ use std::time::Duration; use crate::auth::AuthDotJson; use crate::auth::AuthKeyringBackendKind; use crate::auth::save_auth; +use crate::default_client::build_raw_auth_reqwest_client; use crate::default_client::originator; +use crate::outbound_proxy::AuthRouteConfig; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use crate::token_data::TokenData; @@ -35,7 +37,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; @@ -71,6 +72,7 @@ pub struct ServerOptions { pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub auth_keyring_backend_kind: AuthKeyringBackendKind, + pub auth_route_config: Option, } impl ServerOptions { @@ -81,6 +83,7 @@ impl ServerOptions { forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Self { Self { codex_home, @@ -93,6 +96,7 @@ impl ServerOptions { codex_streamlined_login: false, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, } } } @@ -336,8 +340,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( @@ -353,9 +364,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(), @@ -719,6 +735,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 { @@ -727,7 +744,9 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; + // The route selected for the issuer is reused for token exchange; the token endpoint path is + // not resolved separately. + let client = build_raw_auth_reqwest_client(issuer.trim_end_matches('/'), auth_route_config)?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); info!( issuer = %sanitize_url_for_logging(issuer), @@ -1129,14 +1148,15 @@ 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_raw_auth_reqwest_client(&token_endpoint, auth_route_config)?; let resp = client .post(token_endpoint) .header("Content-Type", "application/x-www-form-urlencoded") diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index af892d76a..9c5eb936b 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -1220,6 +1220,7 @@ impl RefreshTokenTestContext { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 78adc84c2..83cae8af5 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -112,6 +112,7 @@ fn server_opts( /*forced_chatgpt_workspace_id*/ None, cli_auth_credentials_store_mode, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -277,6 +278,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -332,6 +334,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 3d7c5a180..6f7b4e4f8 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -122,6 +122,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, @@ -185,6 +186,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, @@ -226,6 +228,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, @@ -268,6 +271,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, @@ -329,6 +333,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, @@ -398,6 +403,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, @@ -507,6 +513,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -545,6 +552,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, @@ -567,6 +575,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/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 3c47bc88b..e8f9981a8 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -61,6 +61,7 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -119,6 +120,7 @@ async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> R codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -161,6 +163,7 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -204,6 +207,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; save_auth( diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index f7006ca90..eb9c5a893 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -232,6 +232,7 @@ c2ln", AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("auth should load") diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c5cf1e185..290430604 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -10,6 +10,7 @@ use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::ConfigTomlLoadResult; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use crate::legacy_core::config::resolve_bootstrap_auth_route_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; @@ -957,6 +958,14 @@ pub async fn run_main( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -965,6 +974,7 @@ pub async fn run_main( .unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; @@ -1154,6 +1164,7 @@ pub async fn run_main( } if !app_server_target.uses_remote_workspace() { + let auth_route_config = config.auth_route_config(); #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), @@ -1162,6 +1173,7 @@ pub async fn run_main( 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, }) .await { @@ -1429,6 +1441,7 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, initial_config.auth_keyring_backend_kind(), initial_config.chatgpt_base_url.clone(), + initial_config.auth_route_config(), ) .await; } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 0170ae49d..8ba409f7c 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1040,6 +1040,7 @@ mod tests { AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), "https://chatgpt.com/backend-api/".to_string(), + /*auth_route_config*/ None, ) .await, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 852aaad5c..56bcfae6e 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -13,6 +13,7 @@ use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use codex_app_server_protocol::Thread as AppServerThread; @@ -329,12 +330,21 @@ async fn start_app_server_for_archive_command( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await;