Add shared auth system proxy contract

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