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
This commit is contained in:
Jonathan Kula
2026-09-16 16:34:11 +00:00
committed by copyberry
parent 7b6dd0c7b8
commit 53401a2808
6 changed files with 163 additions and 2 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -4126,6 +4126,7 @@ dependencies = [
"opentelemetry",
"pretty_assertions",
"rama-core",
"rama-dns",
"rama-http",
"rama-http-backend",
"rama-net",

View File

@@ -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]

View File

@@ -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<Self::Output, Self::Error> {
let connector = TcpConnector::new();
#[cfg(target_os = "macos")]
let connector = connector.with_dns(SystemDnsResolver);
if input.extensions().get::<ProxyAddress>().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,

View File

@@ -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;

View File

@@ -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<Vec<Ipv4Addr>> {
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<Vec<Ipv6Addr>> {
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<Vec<Vec<u8>>> {
// 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;

View File

@@ -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);
}