PAC 4 - Add macOS system proxy resolver (#26709)

## Summary

Stacked on #26708.

Adds the macOS implementation of the shared system-proxy contract. This
allows Codex-owned auth clients to use the route macOS selects for each
auth URL through SystemConfiguration and CFNetwork, including PAC and
WPAD results.

The `respect_system_proxy` feature is disabled by default, so existing
client behavior remains unchanged unless explicitly enabled.

## Implementation

- Adds the macOS-only `system-configuration` dependency to
`codex-client`.
- Dispatches system-proxy resolution to `outbound_proxy/macos.rs` on
macOS.
- Reads system proxy settings from `SCDynamicStore` and resolves the
target URL with `CFNetworkCopyProxiesForURL`.
- Executes PAC URLs and inline PAC JavaScript through a bounded run loop
with a five-second timeout.
- Handles `DIRECT`, HTTP proxies, and CFNetwork HTTPS entries using HTTP
CONNECT; unsupported SOCKS entries map to `UnsupportedProxyScheme`.
- Builds concrete proxy URLs from host and port entries, including IPv6
host bracketing.
- Maps results into the shared `SystemProxyDecision::{Direct, Proxy,
Unavailable}` contract.
- Hashes URL-specific cache keys so PAC decisions remain distinct
without retaining raw request URLs or query strings.

## End-user behavior

- Disabled/default: existing client behavior is unchanged.
- Enabled with `[features.respect_system_proxy]`:
  - macOS auth clients honor system proxy configuration, PAC, and WPAD;
  - valid OS/PAC `DIRECT` decisions use a direct connection;
- unavailable system resolution falls back to explicit environment proxy
variables, then `DIRECT`, through the shared contract from #26707.
- Unsupported proxy schemes are not silently translated into another
route.
- Custom CA handling remains separate from proxy selection.
- Known limitation: only the first supported system/PAC candidate is
used. Subsequent proxy or `DIRECT` candidates are not attempted after a
connection failure. This matches the current Windows behavior and leaves
room for future ordered-fallback support.

## Tests

- `just test -p codex-client` — 34 tests passed.
- `just clippy -p codex-client`
- `just fmt`
- `just bazel-lock-check`
This commit is contained in:
canvrno-oai
2026-06-22 17:56:04 -07:00
committed by GitHub
Unverified
parent ff31ba8d0a
commit b16d2858f5
6 changed files with 419 additions and 11 deletions
+1
View File
@@ -2405,6 +2405,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.10.9",
"system-configuration",
"tempfile",
"thiserror 2.0.18",
"tokio",
+1
View File
@@ -409,6 +409,7 @@ strum_macros = "0.28.0"
supports-color = "3.0.2"
syntect = "5"
sys-locale = "0.3.2"
system-configuration = "0.7"
tar = { version = "=0.4.45", default-features = false }
tempfile = "3.23.0"
test-log = "0.2.19"
+6 -1
View File
@@ -24,8 +24,13 @@ tracing-opentelemetry = { workspace = true }
codex-utils-rustls-provider = { workspace = true }
zstd = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
sha2 = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
system-configuration = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_Networking_WinHttp",
+25 -9
View File
@@ -14,9 +14,9 @@ use std::time::Instant;
use crate::custom_ca::BuildCustomCaTransportError;
use crate::custom_ca::build_reqwest_client_with_custom_ca;
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
use sha2::Digest;
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
use sha2::Sha256;
use thiserror::Error;
@@ -24,6 +24,8 @@ 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;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
@@ -115,15 +117,23 @@ impl From<BuildRouteAwareHttpClientError> for io::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.
/// a route is selected are returned without trying another route. Ordered PAC candidates are
/// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates
/// are not retried after a connection failure.
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)?;
let builder = configure_proxy_for_route(
&ProcessEnv,
builder,
request_url,
route_class,
config,
resolve_system_proxy,
)?;
build_reqwest_client_with_custom_ca(builder).map_err(Into::into)
}
@@ -133,6 +143,7 @@ fn configure_proxy_for_route(
request_url: &str,
route_class: ClientRouteClass,
config: Option<&OutboundProxyConfig>,
resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision,
) -> Result<reqwest::ClientBuilder, BuildRouteAwareHttpClientError> {
if config.is_none() {
return Ok(builder);
@@ -217,7 +228,7 @@ impl RequestOrigin {
}
#[cfg_attr(
not(target_os = "windows"),
not(any(target_os = "windows", target_os = "macos")),
allow(
dead_code,
reason = "Direct and Proxy are constructed only by platform-specific resolvers"
@@ -240,12 +251,17 @@ fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProx
decision
}
#[cfg(target_os = "macos")]
fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
macos::resolve(request_url, origin)
}
#[cfg(target_os = "windows")]
fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
windows::resolve(request_url, origin)
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn resolve_platform_system_proxy(
_request_url: &str,
_origin: &RequestOrigin,
@@ -317,7 +333,7 @@ fn insert_system_proxy_cache_entry(
}
fn system_proxy_cache_key(request_url: &str) -> String {
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
// Keep URL-specific PAC decisions without retaining the raw routed URL.
let mut hasher = Sha256::new();
@@ -326,7 +342,7 @@ fn system_proxy_cache_key(request_url: &str) -> String {
format!("{:x}", hasher.finalize())
}
#[cfg(not(target_os = "windows"))]
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
request_url.to_string()
}
@@ -0,0 +1,382 @@
use std::ffi::c_void;
use std::ptr;
use std::time::Duration;
use std::time::Instant;
use super::RequestOrigin;
use super::RouteFailureClass;
use super::SystemProxyDecision;
use system_configuration::core_foundation::array::CFArray;
use system_configuration::core_foundation::array::CFArrayRef;
use system_configuration::core_foundation::base::CFEqual;
use system_configuration::core_foundation::base::CFGetTypeID;
use system_configuration::core_foundation::base::CFIndex;
use system_configuration::core_foundation::base::CFType;
use system_configuration::core_foundation::base::CFTypeRef;
use system_configuration::core_foundation::base::TCFType;
use system_configuration::core_foundation::base::kCFAllocatorDefault;
use system_configuration::core_foundation::dictionary::CFDictionary;
use system_configuration::core_foundation::dictionary::CFDictionaryRef;
use system_configuration::core_foundation::error::CFErrorRef;
use system_configuration::core_foundation::number::CFNumber;
use system_configuration::core_foundation::runloop::CFRunLoop;
use system_configuration::core_foundation::runloop::CFRunLoopSource;
use system_configuration::core_foundation::runloop::CFRunLoopSourceInvalidate;
use system_configuration::core_foundation::runloop::CFRunLoopSourceRef;
use system_configuration::core_foundation::runloop::kCFRunLoopDefaultMode;
use system_configuration::core_foundation::string::CFString;
use system_configuration::core_foundation::string::CFStringRef;
use system_configuration::core_foundation::url::CFURL;
use system_configuration::core_foundation::url::CFURLCreateWithString;
use system_configuration::core_foundation::url::CFURLGetTypeID;
use system_configuration::core_foundation::url::CFURLRef;
use system_configuration::dynamic_store::SCDynamicStoreBuilder;
const PAC_EXECUTION_TIMEOUT: Duration = Duration::from_secs(5);
type ProxyDictionary = CFDictionary<CFString, CFType>;
type ProxyArray = CFArray<ProxyDictionary>;
#[repr(C)]
struct CFStreamClientContext {
version: CFIndex,
info: *mut c_void,
retain: Option<unsafe extern "C" fn(*mut c_void) -> *mut c_void>,
release: Option<unsafe extern "C" fn(*mut c_void)>,
copy_description: Option<unsafe extern "C" fn(*mut c_void) -> CFStringRef>,
}
type CFProxyAutoConfigurationResultCallback =
unsafe extern "C" fn(*mut c_void, CFArrayRef, CFErrorRef);
#[link(name = "CFNetwork", kind = "framework")]
unsafe extern "C" {
static kCFProxyTypeKey: CFStringRef;
static kCFProxyHostNameKey: CFStringRef;
static kCFProxyPortNumberKey: CFStringRef;
static kCFProxyAutoConfigurationURLKey: CFStringRef;
static kCFProxyAutoConfigurationJavaScriptKey: CFStringRef;
static kCFProxyTypeNone: CFStringRef;
static kCFProxyTypeHTTP: CFStringRef;
static kCFProxyTypeHTTPS: CFStringRef;
static kCFProxyTypeSOCKS: CFStringRef;
static kCFProxyTypeAutoConfigurationURL: CFStringRef;
static kCFProxyTypeAutoConfigurationJavaScript: CFStringRef;
fn CFNetworkCopyProxiesForURL(url: CFURLRef, proxy_settings: CFDictionaryRef) -> CFArrayRef;
fn CFNetworkExecuteProxyAutoConfigurationURL(
proxy_auto_config_url: CFURLRef,
target_url: CFURLRef,
callback: CFProxyAutoConfigurationResultCallback,
client_context: *mut CFStreamClientContext,
) -> CFRunLoopSourceRef;
fn CFNetworkExecuteProxyAutoConfigurationScript(
proxy_auto_config_script: CFStringRef,
target_url: CFURLRef,
callback: CFProxyAutoConfigurationResultCallback,
client_context: *mut CFStreamClientContext,
) -> CFRunLoopSourceRef;
}
pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision {
let Some(target_url) = cf_url(request_url) else {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::InvalidProxyConfig,
};
};
let Some(settings) = system_proxy_settings() else {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
};
let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else {
return SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
};
};
proxy_array_decision(&proxies, &target_url, origin)
}
fn system_proxy_settings() -> Option<CFDictionary<CFString, CFType>> {
let store = SCDynamicStoreBuilder::new("Codex").build()?;
store.get_proxies()
}
fn copy_proxies_for_url(
target_url: &CFURL,
settings: &CFDictionary<CFString, CFType>,
) -> Option<ProxyArray> {
let proxies = unsafe {
CFNetworkCopyProxiesForURL(
target_url.as_concrete_TypeRef(),
settings.as_concrete_TypeRef(),
)
};
if proxies.is_null() {
None
} else {
Some(unsafe { ProxyArray::wrap_under_create_rule(proxies) })
}
}
fn proxy_array_decision(
proxies: &ProxyArray,
target_url: &CFURL,
origin: &RequestOrigin,
) -> SystemProxyDecision {
let mut saw_unsupported = false;
let mut saw_unavailable = false;
// CFNetwork returns candidates in failover order, but the shared resolver currently carries
// only one route. This matches the Windows limitation; cross-platform retry requires request
// replay semantics and is intentionally deferred.
for proxy in proxies {
match proxy_entry_decision(&proxy, target_url, origin) {
ProxyEntryDecision::Direct => return SystemProxyDecision::Direct,
ProxyEntryDecision::Proxy { url } => return SystemProxyDecision::Proxy { url },
ProxyEntryDecision::UnsupportedScheme => saw_unsupported = true,
ProxyEntryDecision::Unavailable => saw_unavailable = true,
}
}
if saw_unsupported {
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::UnsupportedProxyScheme,
}
} else if saw_unavailable {
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
}
} else {
SystemProxyDecision::Direct
}
}
fn proxy_entry_decision(
proxy: &ProxyDictionary,
target_url: &CFURL,
origin: &RequestOrigin,
) -> ProxyEntryDecision {
let Some(proxy_type) = cf_string_value(proxy, unsafe { kCFProxyTypeKey }) else {
return ProxyEntryDecision::Unavailable;
};
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeNone }) {
return ProxyEntryDecision::Direct;
}
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP }) {
return concrete_proxy_entry(proxy, "http");
}
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTPS }) {
// CFNetwork's HTTPS proxy type is a tunneling proxy for HTTPS destinations; it does not
// preserve an explicit TLS-to-proxy transport. See https://developer.apple.com/documentation/cfnetwork/kcfproxytypehttps.
return concrete_proxy_entry(proxy, "http");
}
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeSOCKS }) {
return ProxyEntryDecision::UnsupportedScheme;
}
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeAutoConfigurationURL }) {
let Some(pac_url) = cf_url_value(proxy, unsafe { kCFProxyAutoConfigurationURLKey }) else {
return ProxyEntryDecision::Unavailable;
};
return pac_decision(execute_pac_url(&pac_url, target_url), target_url, origin);
}
if cf_string_equals(&proxy_type, unsafe {
kCFProxyTypeAutoConfigurationJavaScript
}) {
let Some(script) =
cf_string_value(proxy, unsafe { kCFProxyAutoConfigurationJavaScriptKey })
else {
return ProxyEntryDecision::Unavailable;
};
return pac_decision(
execute_pac(|callback, context| unsafe {
CFNetworkExecuteProxyAutoConfigurationScript(
script.as_concrete_TypeRef(),
target_url.as_concrete_TypeRef(),
callback,
context,
)
}),
target_url,
origin,
);
}
ProxyEntryDecision::Unavailable
}
fn pac_decision(
result: Result<ProxyArray, RouteFailureClass>,
target_url: &CFURL,
origin: &RequestOrigin,
) -> ProxyEntryDecision {
let proxies = match result {
Ok(proxies) => proxies,
Err(RouteFailureClass::UnsupportedProxyScheme) => {
return ProxyEntryDecision::UnsupportedScheme;
}
Err(_) => return ProxyEntryDecision::Unavailable,
};
match proxy_array_decision(&proxies, target_url, origin) {
SystemProxyDecision::Direct => ProxyEntryDecision::Direct,
SystemProxyDecision::Proxy { url } => ProxyEntryDecision::Proxy { url },
SystemProxyDecision::Unavailable {
failure: RouteFailureClass::UnsupportedProxyScheme,
} => ProxyEntryDecision::UnsupportedScheme,
SystemProxyDecision::Unavailable { failure: _ } => ProxyEntryDecision::Unavailable,
}
}
fn execute_pac_url(pac_url: &CFURL, target_url: &CFURL) -> Result<ProxyArray, RouteFailureClass> {
execute_pac(|callback, context| unsafe {
CFNetworkExecuteProxyAutoConfigurationURL(
pac_url.as_concrete_TypeRef(),
target_url.as_concrete_TypeRef(),
callback,
context,
)
})
}
fn execute_pac(
create_source: impl FnOnce(
CFProxyAutoConfigurationResultCallback,
*mut CFStreamClientContext,
) -> CFRunLoopSourceRef,
) -> Result<ProxyArray, RouteFailureClass> {
let mut state = PacRunLoopState { result: None };
let mut context = CFStreamClientContext {
version: 0,
info: (&mut state as *mut PacRunLoopState).cast::<c_void>(),
retain: None,
release: None,
copy_description: None,
};
let source = create_source(pac_result_callback, &mut context);
if source.is_null() {
return Err(RouteFailureClass::ProxyResolutionUnavailable);
}
let source = unsafe { CFRunLoopSource::wrap_under_create_rule(source) };
let run_loop = CFRunLoop::get_current();
let mode = unsafe { kCFRunLoopDefaultMode };
run_loop.add_source(&source, mode);
let started_at = Instant::now();
while state.result.is_none() && started_at.elapsed() < PAC_EXECUTION_TIMEOUT {
CFRunLoop::run_in_mode(mode, Duration::from_millis(50), true);
}
if state.result.is_none() {
unsafe { CFRunLoopSourceInvalidate(source.as_concrete_TypeRef()) };
}
run_loop.remove_source(&source, mode);
state
.result
.unwrap_or(Err(RouteFailureClass::ConnectTimeout))
}
unsafe extern "C" fn pac_result_callback(
client: *mut c_void,
proxies: CFArrayRef,
error: CFErrorRef,
) {
let state = unsafe { &mut *client.cast::<PacRunLoopState>() };
state.result = if !error.is_null() || proxies.is_null() {
Some(Err(RouteFailureClass::ProxyResolutionUnavailable))
} else {
Some(Ok(unsafe { ProxyArray::wrap_under_get_rule(proxies) }))
};
CFRunLoop::get_current().stop();
}
struct PacRunLoopState {
result: Option<Result<ProxyArray, RouteFailureClass>>,
}
fn concrete_proxy_entry(proxy: &ProxyDictionary, proxy_scheme: &str) -> ProxyEntryDecision {
let Some(host) = cf_string_value(proxy, unsafe { kCFProxyHostNameKey })
.map(|host| host.to_string())
.filter(|host| !host.is_empty())
else {
return ProxyEntryDecision::Unavailable;
};
let host = bracket_ipv6_host(&host);
let url = match cf_i32_value(proxy, unsafe { kCFProxyPortNumberKey }) {
Some(port) if port > 0 => format!("{proxy_scheme}://{host}:{port}"),
_ => format!("{proxy_scheme}://{host}"),
};
ProxyEntryDecision::Proxy { url }
}
fn bracket_ipv6_host(host: &str) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]")
} else {
host.to_string()
}
}
fn cf_string_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<CFString> {
proxy
.find(key)
.and_then(|value| value.downcast::<CFString>())
}
fn cf_i32_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<i32> {
proxy
.find(key)
.and_then(|value| value.downcast::<CFNumber>())
.and_then(|value| value.to_i32())
}
fn cf_url_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option<CFURL> {
proxy.find(key).and_then(|value| {
if unsafe { CFGetTypeID(value.as_CFTypeRef()) == CFURLGetTypeID() } {
Some(unsafe { CFURL::wrap_under_get_rule(value.as_CFTypeRef() as CFURLRef) })
} else {
value
.downcast::<CFString>()
.and_then(|value| cf_url(value.to_string().as_str()))
}
})
}
fn cf_string_equals(value: &CFString, expected: CFStringRef) -> bool {
unsafe { CFEqual(value.as_CFTypeRef(), expected as CFTypeRef) != 0 }
}
fn cf_url(value: &str) -> Option<CFURL> {
let value = CFString::new(value);
let url = unsafe {
CFURLCreateWithString(
kCFAllocatorDefault,
value.as_concrete_TypeRef(),
ptr::null(),
)
};
if url.is_null() {
None
} else {
Some(unsafe { CFURL::wrap_under_create_rule(url) })
}
}
enum ProxyEntryDecision {
Direct,
Proxy { url: String },
UnsupportedScheme,
Unavailable,
}
@@ -84,6 +84,9 @@ async fn enabled_environment_proxy_routes_request_through_proxy() {
request_url,
ClientRouteClass::Auth,
Some(&config),
|_, _| SystemProxyDecision::Unavailable {
failure: RouteFailureClass::ProxyResolutionUnavailable,
},
)
.expect("enabled proxy route should configure");
@@ -204,6 +207,6 @@ fn system_proxy_cache_key_preserves_url_specific_pac_decisions() {
cache_key,
system_proxy_cache_key("https://auth.openai.com/oauth/revoke")
);
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", target_os = "macos"))]
assert!(!cache_key.contains(request_url));
}