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.
This commit is contained in:
canvrno-oai
2026-06-22 13:03:11 -07:00
committed by GitHub
Unverified
parent e48ab86693
commit 1659c4a629
37 changed files with 885 additions and 54 deletions
@@ -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 =
@@ -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"));
@@ -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 =
@@ -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();
@@ -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)]
+20 -1
View File
@@ -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<Vec<String>>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
auth_keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> 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;
+7 -3
View File
@@ -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));
}
+2
View File
@@ -566,11 +566,13 @@ pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option<AuthMode> {
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()
@@ -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<AuthRouteConfig>,
) -> 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)
@@ -53,6 +53,7 @@ async fn auth_manager_with_api_key() -> Arc<AuthManager> {
/*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<AuthManager> {
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,
);
+1
View File
@@ -52,6 +52,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option<String>) -> Option<AuthM
config.forced_chatgpt_workspace_id.clone(),
chatgpt_base_url.or(Some(config.chatgpt_base_url.clone())),
config.auth_keyring_backend_kind(),
config.auth_route_config(),
)
.await,
)
+6
View File
@@ -3,6 +3,7 @@ mod chatgpt_hosts;
mod custom_ca;
mod default_client;
mod error;
mod outbound_proxy;
mod request;
mod retry;
mod sse;
@@ -25,6 +26,11 @@ pub use crate::default_client::CodexHttpClient;
pub use crate::default_client::CodexRequestBuilder;
pub use crate::error::StreamError;
pub use crate::error::TransportError;
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
pub use crate::outbound_proxy::ClientRouteClass;
pub use crate::outbound_proxy::OutboundProxyConfig;
pub use crate::outbound_proxy::RouteFailureClass;
pub use crate::outbound_proxy::build_reqwest_client_for_route;
pub use crate::request::EncodedJsonBody;
pub use crate::request::PreparedRequestBody;
pub use crate::request::Request;
+322
View File
@@ -0,0 +1,322 @@
//! Conservative outbound proxy selection for resolver-aware clients.
//!
//! When enabled, platform system discovery is tried first, explicit environment
//! proxies are the fallback, and the final fallback is a direct connection.
//! When disabled, callers retain the existing reqwest builder behavior.
use std::collections::HashMap;
use std::fmt;
use std::io;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;
use crate::custom_ca::BuildCustomCaTransportError;
use crate::custom_ca::build_reqwest_client_with_custom_ca;
use thiserror::Error;
const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60);
const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5);
const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256;
/// Coarse semantic bucket for the HTTP or WebSocket client being constructed.
///
/// This is not the selected proxy route or a concrete endpoint. It labels the
/// product path that owns the client so proxy-resolution diagnostics can
/// distinguish auth, API, WebSocket, and miscellaneous traffic without exposing
/// endpoint details.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientRouteClass {
/// Login, token refresh/revoke, PAT, and agent identity auth traffic.
Auth,
/// First-party API traffic that is not part of the auth flow.
Api,
/// WebSocket traffic.
WebSocket,
/// Call sites without a more specific route class.
Other,
}
impl fmt::Display for ClientRouteClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Auth => "auth",
Self::Api => "api",
Self::WebSocket => "wss",
Self::Other => "other",
})
}
}
/// Coarse failure class for route selection errors.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteFailureClass {
ProxyResolutionUnavailable,
ConnectTimeout,
ProxyAuthenticationRequired,
TlsError,
InvalidProxyConfig,
UnsupportedProxyScheme,
ResolverError,
}
impl fmt::Display for RouteFailureClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable",
Self::ConnectTimeout => "connect_timeout",
Self::ProxyAuthenticationRequired => "proxy_407",
Self::TlsError => "tls_error",
Self::InvalidProxyConfig => "invalid_proxy_config",
Self::UnsupportedProxyScheme => "unsupported_proxy_scheme",
Self::ResolverError => "resolver_error",
})
}
}
/// 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<BuildRouteAwareHttpClientError> 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<reqwest::Client, BuildRouteAwareHttpClientError> {
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<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
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<reqwest::NoProxy>,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
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<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
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<Self> {
let uri = request_url.parse::<http::Uri>().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<Mutex<HashMap<String, CachedSystemProxyDecision>>> =
OnceLock::new();
fn cached_system_proxy_decision(request_url: &str) -> Option<SystemProxyDecision> {
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<String, CachedSystemProxyDecision>,
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<String>;
}
struct ProcessEnv;
impl EnvSource for ProcessEnv {
fn var(&self, key: &str) -> Option<String> {
std::env::var(key).ok()
}
}
fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option<String> {
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;
@@ -0,0 +1,133 @@
use super::*;
use pretty_assertions::assert_eq;
use std::io::Read;
use std::io::Write;
struct MapEnv {
values: HashMap<String, String>,
}
impl EnvSource for MapEnv {
fn var(&self, key: &str) -> Option<String> {
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);
}
+19
View File
@@ -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<AuthRouteConfig> {
Config::auth_route_config(self)
}
}
#[derive(Clone, Default)]
@@ -1454,6 +1459,11 @@ impl Config {
}
}
pub fn auth_route_config(&self) -> Option<AuthRouteConfig> {
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<FeatureRequirementsToml>>,
) -> std::io::Result<Option<AuthRouteConfig>> {
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<WebSearchMode>,
permission_profile: &PermissionProfile,
+1
View File
@@ -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")
+12
View File
@@ -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
{
+33 -10
View File
@@ -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<Self> {
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<Self> {
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<AgentIdentityAuth> {
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(&registration_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> {
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<String> {
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<String> {
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?;
+45
View File
@@ -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")
@@ -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;
+59 -2
View File
@@ -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<reqwest::Client, BuildCustomCaTransportError> {
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<reqwest::Client, BuildRouteAwareHttpClientError> {
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<reqwest::Client, BuildRouteAwareHttpClientError> {
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<CodexHttpClient, BuildRouteAwareHttpClientError> {
build_default_auth_reqwest_client(endpoint, auth_route_config).map(CodexHttpClient::new)
}
pub fn default_headers() -> HeaderMap {
+77 -17
View File
@@ -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<Self> {
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<Option<Self>> {
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<Self> {
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<Self> {
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<Self> {
pub async fn from_personal_access_token(
access_token: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
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<Vec<String>>,
auth_route_config: Option<&AuthRouteConfig>,
session_source: SessionSource,
) -> std::io::Result<Option<AgentIdentityAuth>> {
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<Vec<String>>,
auth_route_config: Option<&AuthRouteConfig>,
session_source: SessionSource,
) -> std::io::Result<AgentIdentityAuth> {
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<bool> {
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<ForcedLoginMethod>,
pub chatgpt_base_url: Option<String>,
pub forced_chatgpt_workspace_id: Option<Vec<String>>,
pub auth_route_config: Option<AuthRouteConfig>,
}
/// 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<Option<CodexAuth>> {
// 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<Option<Arc<dyn ExternalAuth>>>,
auth_route_config: Option<AuthRouteConfig>,
}
/// 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<AuthRouteConfig>;
}
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<Vec<String>>,
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> 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<dyn ExternalAuth>
)),
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<Vec<String>>,
chatgpt_base_url: Option<String>,
keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> Arc<Self> {
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(
@@ -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<Self> {
pub(super) async fn load(
access_token: &str,
auth_route_config: Option<&AuthRouteConfig>,
) -> std::io::Result<Self> {
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<PersonalAccessTokenAuth> {
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;
@@ -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");
+4 -2
View File
@@ -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
}
+6 -3
View File
@@ -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<DeviceCode> {
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}")))?;
+2
View File
@@ -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;
+22
View File
@@ -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
}
}
+28 -8
View File
@@ -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<AuthRouteConfig>,
}
impl ServerOptions {
@@ -81,6 +83,7 @@ impl ServerOptions {
forced_chatgpt_workspace_id: Option<Vec<String>>,
cli_auth_credentials_store_mode: AuthCredentialsStoreMode,
auth_keyring_backend_kind: AuthKeyringBackendKind,
auth_route_config: Option<AuthRouteConfig>,
) -> 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<ExchangedTokens> {
#[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<String> {
// 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")
@@ -1220,6 +1220,7 @@ impl RefreshTokenTestContext {
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await;
@@ -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;
@@ -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,
+4
View File
@@ -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(
@@ -232,6 +232,7 @@ c2ln",
AuthCredentialsStoreMode::File,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
/*auth_route_config*/ None,
)
.await
.expect("auth should load")
+13
View File
@@ -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;
}
+1
View File
@@ -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(),
@@ -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;