From 53401a28086afa11cefcbe058022cc7a31c26153 Mon Sep 17 00:00:00 2001 From: Jonathan Kula <173840185+jdkula-openai@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:34:11 +0000 Subject: [PATCH] Use native DNS resolution for the network proxy on macOS (#45982) ## Why The default resolver misses macOS supplemental resolvers and VPN split DNS, preventing Codex from reaching hosts that depend on those DNS settings. ## What changed Use `tokio::net::lookup_host` for IPv4 and IPv6 lookups in the macOS TCP connector, including connections to upstream proxies. Preserve existing checks on resolved destination addresses and reject unsupported TXT lookups without falling back to a different resolver. ## Testing Add macOS tests for IPv4 and IPv6 localhost resolution and connections, local-network rejection, and resolving the upstream proxy instead of the destination. GitOrigin-RevId: 3c7fc4f8b309e26b02923d8fafd4e1f1e1f7755f --- codex-rs/Cargo.lock | 1 + codex-rs/network-proxy/Cargo.toml | 1 + codex-rs/network-proxy/src/connect_policy.rs | 10 +- codex-rs/network-proxy/src/lib.rs | 2 + codex-rs/network-proxy/src/system_dns.rs | 56 +++++++++++ .../network-proxy/src/system_dns_tests.rs | 95 +++++++++++++++++++ 6 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 codex-rs/network-proxy/src/system_dns.rs create mode 100644 codex-rs/network-proxy/src/system_dns_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1e51bf3834..439dea79c6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4126,6 +4126,7 @@ dependencies = [ "opentelemetry", "pretty_assertions", "rama-core", + "rama-dns", "rama-http", "rama-http-backend", "rama-net", diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index 9ab9d6af84..d5280e4eef 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -53,6 +53,7 @@ tempfile = { workspace = true } rama-unix = { version = "=0.3.0-alpha.4" } [target.'cfg(target_os = "macos")'.dependencies] +rama-dns = { version = "=0.3.0-alpha.4" } security-framework = "3" [target.'cfg(windows)'.dependencies] diff --git a/codex-rs/network-proxy/src/connect_policy.rs b/codex-rs/network-proxy/src/connect_policy.rs index 53b267595e..47430a1929 100644 --- a/codex-rs/network-proxy/src/connect_policy.rs +++ b/codex-rs/network-proxy/src/connect_policy.rs @@ -1,6 +1,8 @@ use crate::policy::is_non_public_ip; use crate::runtime::HostBlockDecision; use crate::state::NetworkProxyState; +#[cfg(target_os = "macos")] +use crate::system_dns::SystemDnsResolver; use rama_core::Service; use rama_core::error::BoxError; use rama_core::error::ErrorExt as _; @@ -38,8 +40,12 @@ where type Error = BoxError; async fn serve(&self, input: Input) -> Result { + let connector = TcpConnector::new(); + #[cfg(target_os = "macos")] + let connector = connector.with_dns(SystemDnsResolver); + if input.extensions().get::().is_some() { - return TcpConnector::new().serve(input).await; + return connector.serve(input).await; } let target = input @@ -48,7 +54,7 @@ where .host_with_port() .ok_or_else(|| OpaqueError::from_display("network target is missing a port"))?; - TcpConnector::new() + connector .with_connector(TargetCheckedStreamConnector { state: self.state.clone(), target, diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index dd8df15aca..ae33185fd7 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -25,6 +25,8 @@ mod responses; mod runtime; mod socks5; mod state; +#[cfg(target_os = "macos")] +mod system_dns; mod upstream; #[cfg(target_os = "windows")] mod windows_proxy_ingress; diff --git a/codex-rs/network-proxy/src/system_dns.rs b/codex-rs/network-proxy/src/system_dns.rs new file mode 100644 index 0000000000..5149bccb41 --- /dev/null +++ b/codex-rs/network-proxy/src/system_dns.rs @@ -0,0 +1,56 @@ +//! This macOS-only module implements DNS resolution +//! using macOS's native system resolver. We have to +//! do this rather than relying on the default behavior +//! (which reads /etc/resolv.conf) since default behavior +//! breaks Codex's ability to interact with network paths +//! that are provided by applications that modify / hook +//! into macOS DNS behavior (i.e. proxies, VPNs, etc) +use rama_dns::DnsResolver; +use rama_net::address::Domain; +use std::io; +use std::net::IpAddr; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; +use tokio::net::lookup_host; + +/// Resolve TCP destinations through macOS's native resolver, including supplemental +/// resolvers and VPN split DNS that are not represented in `/etc/resolv.conf`. +#[derive(Clone)] +pub(crate) struct SystemDnsResolver; + +impl DnsResolver for SystemDnsResolver { + type Error = io::Error; + + async fn ipv4_lookup(&self, domain: Domain) -> io::Result> { + Ok(lookup_host((domain.as_str(), 0)) + .await? + .filter_map(|addr| match addr.ip() { + IpAddr::V4(ip) => Some(ip), + IpAddr::V6(_) => None, + }) + .collect()) + } + + async fn ipv6_lookup(&self, domain: Domain) -> io::Result> { + Ok(lookup_host((domain.as_str(), 0)) + .await? + .filter_map(|addr| match addr.ip() { + IpAddr::V4(_) => None, + IpAddr::V6(ip) => Some(ip), + }) + .collect()) + } + + async fn txt_lookup(&self, _domain: Domain) -> io::Result>> { + // TCP connectors only need address lookups. Do not silently fall back to + // a resolver with different DNS routing for unsupported record types. + Err(io::Error::new( + io::ErrorKind::Unsupported, + "the system address resolver does not support TXT lookups", + )) + } +} + +#[cfg(test)] +#[path = "system_dns_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/system_dns_tests.rs b/codex-rs/network-proxy/src/system_dns_tests.rs new file mode 100644 index 0000000000..4ad4990a74 --- /dev/null +++ b/codex-rs/network-proxy/src/system_dns_tests.rs @@ -0,0 +1,95 @@ +use super::*; +use crate::config::NetworkProxyConfig; +use crate::connect_policy::TargetCheckedTcpConnector; +use crate::state::network_proxy_state_for_policy; +use pretty_assertions::assert_eq; +use rama_core::Service; +use rama_core::extensions::ExtensionsMut; +use rama_net::address::Host; +use rama_net::address::HostWithPort; +use rama_net::address::ProxyAddress; +use rama_net::stream::Socket; +use rama_tcp::client::Request; +use std::sync::Arc; +use tokio::net::TcpListener; + +#[tokio::test] +async fn native_resolution_supports_both_address_families() { + let domain: Domain = "localhost".parse().expect("valid domain"); + let (ipv4, ipv6) = tokio::join!( + SystemDnsResolver.ipv4_lookup(domain.clone()), + SystemDnsResolver.ipv6_lookup(domain), + ); + + assert_eq!( + ipv4.expect("resolve IPv4 localhost"), + vec![Ipv4Addr::LOCALHOST] + ); + assert_eq!( + ipv6.expect("resolve IPv6 localhost"), + vec![Ipv6Addr::LOCALHOST] + ); +} + +#[tokio::test] +async fn connector_reaches_native_hostname_over_ipv4_and_ipv6() { + for ip in [ + IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ] { + let listener = TcpListener::bind((ip, 0)).await.expect("bind listener"); + let target = listener.local_addr().expect("local addr"); + let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy( + NetworkProxyConfig { + allow_local_binding: true, + ..NetworkProxyConfig::default() + }, + ))); + let request = Request::new(HostWithPort::new(Host::LOCALHOST_NAME, target.port())); + + let connection = connector + .serve(request) + .await + .expect("connect to localhost"); + + assert_eq!(connection.conn.peer_addr().expect("peer addr"), target); + } +} + +#[tokio::test] +async fn native_resolution_preserves_local_network_rejection() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind listener"); + let target = listener.local_addr().expect("local addr"); + let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy( + NetworkProxyConfig::default(), + ))); + let request = Request::new(HostWithPort::new(Host::LOCALHOST_NAME, target.port())); + + connector + .serve(request) + .await + .expect_err("resolved loopback addresses must still be checked against policy"); +} + +#[tokio::test] +async fn connector_resolves_upstream_proxy_instead_of_destination() { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .expect("bind listener"); + let target = listener.local_addr().expect("local addr"); + let connector = TargetCheckedTcpConnector::new(Arc::new(network_proxy_state_for_policy( + NetworkProxyConfig::default(), + ))); + let mut request = + Request::new(HostWithPort::try_from("destination.invalid:80").expect("valid destination")); + request.extensions_mut().insert( + ProxyAddress::try_from(format!("http://localhost:{}", target.port())) + .expect("valid upstream proxy"), + ); + + let connection = connector.serve(request).await.expect("connect to proxy"); + + assert_eq!(connection.conn.peer_addr().expect("peer addr"), target); +}