Files
codex/codex-rs/network-proxy/src/proxy.rs
T
Winston Howes bca18cba40 Wire managed MITM CA trust into child env (#22668)
## Stack
1. Parent PR: #18240 uses named MITM permissions config.
2. This PR wires managed MITM CA trust into spawned child processes.

## Why
When Codex terminates HTTPS for limited mode or MITM hooks, child HTTPS
clients need to trust Codex's managed MITM CA. Exporting proxy URLs
alone is not enough, but blindly replacing user CA settings would be
wrong: it can break custom enterprise/test roots, leak unreadable CA
files into generated bundles, or make the child env disagree with its
sandbox policy.

## Summary
1. Build immutable managed CA bundles under `$CODEX_HOME/proxy` that
include native roots, the managed MITM CA, and only inherited or
command-scoped CA bundles the child is allowed to read.
2. Export curated CA env vars alongside managed proxy env vars while
preserving user CA override semantics, including nested Codex
`SSL_CERT_FILE` precedence.
3. Thread generated CA bundle paths into child sandbox readable roots,
including debug sandbox execution, so the exported env vars work inside
sandboxed commands.
4. Remove only Codex-generated MITM CA bundle env when a child
intentionally drops managed proxying for escalation or no-proxy retry.
5. Document the managed CA bundle behavior and cover env injection,
per-child bundle generation, sandbox readable roots, and no-proxy
cleanup in tests.

## Validation
1. Ran `just test -p codex-network-proxy`.
2. Ran `just test -p codex-protocol`.
3. Ran `just fix -p codex-network-proxy -p codex-protocol`.
4. Tried focused `codex-core` validation, but the crate currently fails
to compile in `core/tests/suite/guardian_review.rs` because an existing
`Op::UserInput` initializer is missing `additional_context`.

---------

Co-authored-by: Eva Wong <evawong@openai.com>
2026-06-01 23:23:59 +00:00

1296 lines
42 KiB
Rust

use crate::config;
use crate::http_proxy;
use crate::network_policy::NetworkPolicyDecider;
use crate::runtime::BlockedRequestObserver;
use crate::runtime::ConfigState;
use crate::runtime::unix_socket_permissions_supported;
use crate::socks5;
use crate::state::NetworkProxyState;
use anyhow::Context;
use anyhow::Result;
use clap::Parser;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::net::TcpListener as StdTcpListener;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::RwLock;
use tokio::task::JoinHandle;
use tracing::warn;
#[derive(Debug, Clone, Parser)]
#[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")]
pub struct Args {}
#[derive(Debug)]
struct ReservedListeners {
http: Mutex<Option<StdTcpListener>>,
socks: Mutex<Option<StdTcpListener>>,
}
impl ReservedListeners {
fn new(http: StdTcpListener, socks: Option<StdTcpListener>) -> Self {
Self {
http: Mutex::new(Some(http)),
socks: Mutex::new(socks),
}
}
fn take_http(&self) -> Option<StdTcpListener> {
let mut guard = self
.http
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.take()
}
fn take_socks(&self) -> Option<StdTcpListener> {
let mut guard = self
.socks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.take()
}
}
struct ReservedListenerSet {
http_listener: StdTcpListener,
socks_listener: Option<StdTcpListener>,
}
impl ReservedListenerSet {
fn new(http_listener: StdTcpListener, socks_listener: Option<StdTcpListener>) -> Self {
Self {
http_listener,
socks_listener,
}
}
fn http_addr(&self) -> Result<SocketAddr> {
self.http_listener
.local_addr()
.context("failed to read reserved HTTP proxy address")
}
fn socks_addr(&self, default_addr: SocketAddr) -> Result<SocketAddr> {
self.socks_listener
.as_ref()
.map_or(Ok(default_addr), |listener| {
listener
.local_addr()
.context("failed to read reserved SOCKS5 proxy address")
})
}
fn into_reserved_listeners(self) -> Arc<ReservedListeners> {
Arc::new(ReservedListeners::new(
self.http_listener,
self.socks_listener,
))
}
}
#[derive(Clone)]
pub struct NetworkProxyBuilder {
state: Option<Arc<NetworkProxyState>>,
http_addr: Option<SocketAddr>,
socks_addr: Option<SocketAddr>,
managed_by_codex: bool,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
}
impl Default for NetworkProxyBuilder {
fn default() -> Self {
Self {
state: None,
http_addr: None,
socks_addr: None,
managed_by_codex: true,
policy_decider: None,
blocked_request_observer: None,
}
}
}
impl NetworkProxyBuilder {
pub fn state(mut self, state: Arc<NetworkProxyState>) -> Self {
self.state = Some(state);
self
}
pub fn http_addr(mut self, addr: SocketAddr) -> Self {
self.http_addr = Some(addr);
self
}
pub fn socks_addr(mut self, addr: SocketAddr) -> Self {
self.socks_addr = Some(addr);
self
}
pub fn managed_by_codex(mut self, managed_by_codex: bool) -> Self {
self.managed_by_codex = managed_by_codex;
self
}
pub fn policy_decider<D>(mut self, decider: D) -> Self
where
D: NetworkPolicyDecider,
{
self.policy_decider = Some(Arc::new(decider));
self
}
pub fn policy_decider_arc(mut self, decider: Arc<dyn NetworkPolicyDecider>) -> Self {
self.policy_decider = Some(decider);
self
}
pub fn blocked_request_observer<O>(mut self, observer: O) -> Self
where
O: BlockedRequestObserver,
{
self.blocked_request_observer = Some(Arc::new(observer));
self
}
pub fn blocked_request_observer_arc(
mut self,
observer: Arc<dyn BlockedRequestObserver>,
) -> Self {
self.blocked_request_observer = Some(observer);
self
}
pub async fn build(self) -> Result<NetworkProxy> {
let state = self.state.ok_or_else(|| {
anyhow::anyhow!(
"NetworkProxyBuilder requires a state; supply one via builder.state(...)"
)
})?;
state
.set_blocked_request_observer(self.blocked_request_observer.clone())
.await;
let current_cfg = state.current_cfg().await?;
let (requested_http_addr, requested_socks_addr, reserved_listeners) = if self
.managed_by_codex
{
let runtime = config::resolve_runtime(&current_cfg)?;
#[cfg(target_os = "windows")]
let (managed_http_addr, managed_socks_addr) = config::clamp_bind_addrs(
runtime.http_addr,
runtime.socks_addr,
&current_cfg.network,
);
#[cfg(target_os = "windows")]
let reserved = reserve_windows_managed_listeners(
managed_http_addr,
managed_socks_addr,
current_cfg.network.enable_socks5,
)
.context("reserve managed loopback proxy listeners")?;
#[cfg(not(target_os = "windows"))]
let reserved = reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5)
.context("reserve managed loopback proxy listeners")?;
let http_addr = reserved.http_addr()?;
let socks_addr = reserved.socks_addr(runtime.socks_addr)?;
(
http_addr,
socks_addr,
Some(reserved.into_reserved_listeners()),
)
} else {
let runtime = config::resolve_runtime(&current_cfg)?;
(
self.http_addr.unwrap_or(runtime.http_addr),
self.socks_addr.unwrap_or(runtime.socks_addr),
None,
)
};
// Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only.
let (http_addr, socks_addr) = config::clamp_bind_addrs(
requested_http_addr,
requested_socks_addr,
&current_cfg.network,
);
Ok(NetworkProxy {
state,
http_addr,
socks_addr,
socks_enabled: current_cfg.network.enable_socks5,
runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config(
&current_cfg,
)?)),
reserved_listeners,
policy_decider: self.policy_decider,
})
}
}
fn reserve_loopback_ephemeral_listeners(
reserve_socks_listener: bool,
) -> Result<ReservedListenerSet> {
let http_listener =
reserve_loopback_ephemeral_listener().context("reserve HTTP proxy listener")?;
let socks_listener = if reserve_socks_listener {
Some(reserve_loopback_ephemeral_listener().context("reserve SOCKS5 proxy listener")?)
} else {
None
};
Ok(ReservedListenerSet::new(http_listener, socks_listener))
}
#[cfg(target_os = "windows")]
fn reserve_windows_managed_listeners(
http_addr: SocketAddr,
socks_addr: SocketAddr,
reserve_socks_listener: bool,
) -> Result<ReservedListenerSet> {
let http_addr = windows_managed_loopback_addr(http_addr);
let socks_addr = windows_managed_loopback_addr(socks_addr);
match try_reserve_windows_managed_listeners(http_addr, socks_addr, reserve_socks_listener) {
Ok(listeners) => Ok(listeners),
Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
warn!("managed Windows proxy ports are busy; falling back to ephemeral loopback ports");
reserve_loopback_ephemeral_listeners(reserve_socks_listener)
.context("reserve fallback loopback proxy listeners")
}
Err(err) => Err(err).context("reserve Windows managed proxy listeners"),
}
}
#[cfg(target_os = "windows")]
fn try_reserve_windows_managed_listeners(
http_addr: SocketAddr,
socks_addr: SocketAddr,
reserve_socks_listener: bool,
) -> std::io::Result<ReservedListenerSet> {
let http_listener = StdTcpListener::bind(http_addr)?;
let socks_listener = if reserve_socks_listener {
Some(StdTcpListener::bind(socks_addr)?)
} else {
None
};
Ok(ReservedListenerSet::new(http_listener, socks_listener))
}
#[cfg(target_os = "windows")]
fn windows_managed_loopback_addr(addr: SocketAddr) -> SocketAddr {
if !addr.ip().is_loopback() {
warn!(
"managed Windows proxies must bind to loopback; clamping {addr} to 127.0.0.1:{}",
addr.port()
);
}
SocketAddr::from(([127, 0, 0, 1], addr.port()))
}
fn reserve_loopback_ephemeral_listener() -> Result<StdTcpListener> {
StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
.context("bind loopback ephemeral port")
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NetworkProxyRuntimeSettings {
allow_local_binding: bool,
allow_unix_sockets: Arc<[String]>,
dangerously_allow_all_unix_sockets: bool,
mitm_ca_trust_bundle: Option<crate::certs::ManagedMitmCaTrustBundle>,
}
impl NetworkProxyRuntimeSettings {
fn from_config(config: &config::NetworkProxyConfig) -> Result<Self> {
let mitm_ca_trust_bundle = if config.network.mitm {
let env = crate::certs::CUSTOM_CA_ENV_KEYS
.into_iter()
.filter_map(|key| std::env::var(key).ok().map(|value| (key, value)))
.collect();
Some(crate::certs::managed_ca_trust_bundle(&env)?)
} else {
None
};
Ok(Self {
allow_local_binding: config.network.allow_local_binding,
allow_unix_sockets: config.network.allow_unix_sockets().into(),
dangerously_allow_all_unix_sockets: config.network.dangerously_allow_all_unix_sockets,
mitm_ca_trust_bundle,
})
}
}
#[derive(Clone)]
pub struct NetworkProxy {
state: Arc<NetworkProxyState>,
http_addr: SocketAddr,
socks_addr: SocketAddr,
socks_enabled: bool,
runtime_settings: Arc<RwLock<NetworkProxyRuntimeSettings>>,
reserved_listeners: Option<Arc<ReservedListeners>>,
policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
}
impl std::fmt::Debug for NetworkProxy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
// and may contain sensitive paths.
f.debug_struct("NetworkProxy")
.field("http_addr", &self.http_addr)
.field("socks_addr", &self.socks_addr)
.finish_non_exhaustive()
}
}
impl PartialEq for NetworkProxy {
fn eq(&self, other: &Self) -> bool {
self.http_addr == other.http_addr
&& self.socks_addr == other.socks_addr
&& self.runtime_settings() == other.runtime_settings()
}
}
impl Eq for NetworkProxy {}
pub const PROXY_URL_ENV_KEYS: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
"WS_PROXY",
"WSS_PROXY",
"ALL_PROXY",
"FTP_PROXY",
"YARN_HTTP_PROXY",
"YARN_HTTPS_PROXY",
"NPM_CONFIG_HTTP_PROXY",
"NPM_CONFIG_HTTPS_PROXY",
"NPM_CONFIG_PROXY",
"BUNDLE_HTTP_PROXY",
"BUNDLE_HTTPS_PROXY",
"PIP_PROXY",
"DOCKER_HTTP_PROXY",
"DOCKER_HTTPS_PROXY",
];
pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"];
pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE";
pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY";
const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY";
#[cfg(any(target_os = "macos", test))]
const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND";
pub const PROXY_ENV_KEYS: &[&str] = &[
PROXY_ACTIVE_ENV_KEY,
ALLOW_LOCAL_BINDING_ENV_KEY,
ELECTRON_GET_USE_PROXY_ENV_KEY,
NODE_USE_ENV_PROXY_ENV_KEY,
"HTTP_PROXY",
"HTTPS_PROXY",
"http_proxy",
"https_proxy",
"YARN_HTTP_PROXY",
"YARN_HTTPS_PROXY",
"npm_config_http_proxy",
"npm_config_https_proxy",
"npm_config_proxy",
"NPM_CONFIG_HTTP_PROXY",
"NPM_CONFIG_HTTPS_PROXY",
"NPM_CONFIG_PROXY",
"BUNDLE_HTTP_PROXY",
"BUNDLE_HTTPS_PROXY",
"PIP_PROXY",
"DOCKER_HTTP_PROXY",
"DOCKER_HTTPS_PROXY",
"WS_PROXY",
"WSS_PROXY",
"ws_proxy",
"wss_proxy",
"NO_PROXY",
"no_proxy",
"npm_config_noproxy",
"NPM_CONFIG_NOPROXY",
"YARN_NO_PROXY",
"BUNDLE_NO_PROXY",
"ALL_PROXY",
"all_proxy",
"FTP_PROXY",
"ftp_proxy",
];
#[cfg(target_os = "macos")]
pub const PROXY_GIT_SSH_COMMAND_ENV_KEY: &str = GIT_SSH_COMMAND_ENV_KEY;
const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"];
pub const NO_PROXY_ENV_KEYS: &[&str] = &[
"NO_PROXY",
"no_proxy",
"npm_config_noproxy",
"NPM_CONFIG_NOPROXY",
"YARN_NO_PROXY",
"BUNDLE_NO_PROXY",
];
pub const DEFAULT_NO_PROXY_VALUE: &str = concat!(
"localhost,127.0.0.1,::1,",
"10.0.0.0/8,",
"172.16.0.0/12,",
"192.168.0.0/16"
);
#[cfg(target_os = "macos")]
pub const CODEX_PROXY_GIT_SSH_COMMAND_MARKER: &str = "CODEX_PROXY_GIT_SSH_COMMAND=1 ";
#[cfg(target_os = "macos")]
const CODEX_PROXY_GIT_SSH_COMMAND_PREFIX: &str =
"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x ";
#[cfg(target_os = "macos")]
const CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX: &str = " %h %p'";
pub fn proxy_url_env_value<'a>(
env: &'a HashMap<String, String>,
canonical_key: &str,
) -> Option<&'a str> {
if let Some(value) = env.get(canonical_key) {
return Some(value.as_str());
}
let lower_key = canonical_key.to_ascii_lowercase();
env.get(lower_key.as_str()).map(String::as_str)
}
pub fn has_proxy_url_env_vars(env: &HashMap<String, String>) -> bool {
PROXY_URL_ENV_KEYS
.iter()
.any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty()))
}
fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
for key in keys {
env.insert((*key).to_string(), value.to_string());
}
}
#[cfg(target_os = "macos")]
fn codex_proxy_git_ssh_command(socks_addr: SocketAddr) -> String {
format!("{CODEX_PROXY_GIT_SSH_COMMAND_PREFIX}{socks_addr}{CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX}")
}
#[cfg(target_os = "macos")]
fn is_codex_proxy_git_ssh_command(command: &str) -> bool {
command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_PREFIX)
&& command.ends_with(CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX)
}
fn apply_proxy_env_overrides(
env: &mut HashMap<String, String>,
http_addr: SocketAddr,
socks_addr: SocketAddr,
socks_enabled: bool,
allow_local_binding: bool,
mitm_ca_trust_bundle: Option<&crate::certs::ManagedMitmCaTrustBundle>,
) {
let http_proxy_url = format!("http://{http_addr}");
let socks_proxy_url = format!("socks5h://{socks_addr}");
env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string());
env.insert(
ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
if allow_local_binding {
"1".to_string()
} else {
"0".to_string()
},
);
// HTTP-based clients are best served by explicit HTTP proxy URLs.
set_env_keys(
env,
&[
"HTTP_PROXY",
"HTTPS_PROXY",
"http_proxy",
"https_proxy",
"YARN_HTTP_PROXY",
"YARN_HTTPS_PROXY",
"npm_config_http_proxy",
"npm_config_https_proxy",
"npm_config_proxy",
"NPM_CONFIG_HTTP_PROXY",
"NPM_CONFIG_HTTPS_PROXY",
"NPM_CONFIG_PROXY",
"BUNDLE_HTTP_PROXY",
"BUNDLE_HTTPS_PROXY",
"PIP_PROXY",
"DOCKER_HTTP_PROXY",
"DOCKER_HTTPS_PROXY",
],
&http_proxy_url,
);
// Some websocket clients look for dedicated WS/WSS proxy environment variables instead of
// HTTP(S)_PROXY. Keep them aligned with the managed HTTP proxy endpoint.
set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);
// Keep loopback and IP-literal private targets direct so local IPC/LAN access avoids the proxy.
// Do not include hostname suffixes here: those can force clients to resolve internal names
// locally instead of letting the proxy resolve them.
set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
env.insert(
ELECTRON_GET_USE_PROXY_ENV_KEY.to_string(),
"true".to_string(),
);
// Node.js built-in HTTP clients only honor proxy environment variables when this is enabled.
env.insert(NODE_USE_ENV_PROXY_ENV_KEY.to_string(), "1".to_string());
// Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if
// those vars contain SOCKS URLs. We only switch ALL_PROXY here.
//
if socks_enabled {
set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
} else {
set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url);
set_env_keys(env, FTP_PROXY_ENV_KEYS, &http_proxy_url);
}
#[cfg(target_os = "macos")]
if socks_enabled {
// Preserve existing SSH wrappers (for example: Secretive/Teleport setups)
// but refresh a previously injected Codex fallback so it cannot point
// at a stale proxy port after the proxy is restarted.
match env.get(GIT_SSH_COMMAND_ENV_KEY) {
Some(command) if !is_codex_proxy_git_ssh_command(command) => {}
_ => {
env.insert(
GIT_SSH_COMMAND_ENV_KEY.to_string(),
codex_proxy_git_ssh_command(socks_addr),
);
}
}
}
if let Some(mitm_ca_trust_bundle) = mitm_ca_trust_bundle {
let managed_path = mitm_ca_trust_bundle.path.to_string_lossy().into_owned();
for key in crate::certs::CUSTOM_CA_ENV_KEYS {
if env
.get(key)
.filter(|value| !value.is_empty())
.is_some_and(|value| {
value != &managed_path
&& mitm_ca_trust_bundle.startup_env_values.get(key) != Some(value)
})
{
// TODO(winston): Materialize policy-checked per-child bundles for readable
// startup and command-scoped CA overrides. For now startup overrides are
// replaced with the default bundle and later command-scoped overrides are
// preserved, either of which can make intercepted TLS fail.
continue;
}
env.insert(key.to_string(), managed_path.clone());
}
}
}
impl NetworkProxy {
pub fn builder() -> NetworkProxyBuilder {
NetworkProxyBuilder::default()
}
pub fn http_addr(&self) -> SocketAddr {
self.http_addr
}
pub fn socks_addr(&self) -> SocketAddr {
self.socks_addr
}
pub async fn current_cfg(&self) -> Result<config::NetworkProxyConfig> {
self.state.current_cfg().await
}
pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
self.state.add_allowed_domain(host).await
}
pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
self.state.add_denied_domain(host).await
}
pub fn allow_local_binding(&self) -> bool {
self.runtime_settings().allow_local_binding
}
pub fn allow_unix_sockets(&self) -> Arc<[String]> {
self.runtime_settings().allow_unix_sockets
}
pub fn dangerously_allow_all_unix_sockets(&self) -> bool {
self.runtime_settings().dangerously_allow_all_unix_sockets
}
/// Returns the generated MITM CA bundle path child sandboxes should expose to TLS clients.
pub fn managed_mitm_ca_trust_bundle_path(&self) -> Option<AbsolutePathBuf> {
self.runtime_settings()
.mitm_ca_trust_bundle
.and_then(|bundle| {
AbsolutePathBuf::from_absolute_path(bundle.path)
.map_err(|err| warn!("managed MITM CA trust bundle path is invalid: {err}"))
.ok()
})
}
pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
let runtime_settings = self.runtime_settings();
// Enforce proxying for child processes. Proxy endpoint values are always rewritten;
// managed MITM CA vars preserve child-scoped overrides after proxy startup.
apply_proxy_env_overrides(
env,
self.http_addr,
self.socks_addr,
self.socks_enabled,
runtime_settings.allow_local_binding,
runtime_settings.mitm_ca_trust_bundle.as_ref(),
);
}
pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> {
let current_cfg = self.state.current_cfg().await?;
anyhow::ensure!(
new_state.config.network.enabled == current_cfg.network.enabled,
"cannot update network.enabled on a running proxy"
);
anyhow::ensure!(
new_state.config.network.proxy_url == current_cfg.network.proxy_url,
"cannot update network.proxy_url on a running proxy"
);
anyhow::ensure!(
new_state.config.network.socks_url == current_cfg.network.socks_url,
"cannot update network.socks_url on a running proxy"
);
anyhow::ensure!(
new_state.config.network.enable_socks5 == current_cfg.network.enable_socks5,
"cannot update network.enable_socks5 on a running proxy"
);
anyhow::ensure!(
new_state.config.network.enable_socks5_udp == current_cfg.network.enable_socks5_udp,
"cannot update network.enable_socks5_udp on a running proxy"
);
let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config)?;
self.state.replace_config_state(new_state).await?;
let mut guard = self
.runtime_settings
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = settings;
Ok(())
}
fn runtime_settings(&self) -> NetworkProxyRuntimeSettings {
self.runtime_settings
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
pub async fn run(&self) -> Result<NetworkProxyHandle> {
let current_cfg = self.state.current_cfg().await?;
if !current_cfg.network.enabled {
warn!("network.enabled is false; skipping proxy listeners");
return Ok(NetworkProxyHandle::noop());
}
if !unix_socket_permissions_supported() {
warn!(
"allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform"
);
}
let reserved_listeners = self.reserved_listeners.as_ref();
let http_listener = reserved_listeners.and_then(|listeners| listeners.take_http());
let socks_listener = reserved_listeners.and_then(|listeners| listeners.take_socks());
let http_state = self.state.clone();
let http_decider = self.policy_decider.clone();
let http_addr = self.http_addr;
let http_task = tokio::spawn(async move {
match http_listener {
Some(listener) => {
http_proxy::run_http_proxy_with_std_listener(http_state, listener, http_decider)
.await
}
None => http_proxy::run_http_proxy(http_state, http_addr, http_decider).await,
}
});
let socks_task = if current_cfg.network.enable_socks5 {
let socks_state = self.state.clone();
let socks_decider = self.policy_decider.clone();
let socks_addr = self.socks_addr;
let enable_socks5_udp = current_cfg.network.enable_socks5_udp;
Some(tokio::spawn(async move {
match socks_listener {
Some(listener) => {
socks5::run_socks5_with_std_listener(
socks_state,
listener,
socks_decider,
enable_socks5_udp,
)
.await
}
None => {
socks5::run_socks5(
socks_state,
socks_addr,
socks_decider,
enable_socks5_udp,
)
.await
}
}
}))
} else {
None
};
Ok(NetworkProxyHandle {
http_task: Some(http_task),
socks_task,
completed: false,
})
}
}
pub struct NetworkProxyHandle {
http_task: Option<JoinHandle<Result<()>>>,
socks_task: Option<JoinHandle<Result<()>>>,
completed: bool,
}
impl NetworkProxyHandle {
fn noop() -> Self {
Self {
http_task: Some(tokio::spawn(async { Ok(()) })),
socks_task: None,
completed: true,
}
}
pub async fn wait(mut self) -> Result<()> {
let http_task = self.http_task.take().context("missing http proxy task")?;
let socks_task = self.socks_task.take();
let http_result = http_task.await;
let socks_result = match socks_task {
Some(task) => Some(task.await),
None => None,
};
self.completed = true;
http_result??;
if let Some(socks_result) = socks_result {
socks_result??;
}
Ok(())
}
pub async fn shutdown(mut self) -> Result<()> {
abort_tasks(self.http_task.take(), self.socks_task.take()).await;
self.completed = true;
Ok(())
}
}
async fn abort_task(task: Option<JoinHandle<Result<()>>>) {
if let Some(task) = task {
task.abort();
let _ = task.await;
}
}
async fn abort_tasks(
http_task: Option<JoinHandle<Result<()>>>,
socks_task: Option<JoinHandle<Result<()>>>,
) {
abort_task(http_task).await;
abort_task(socks_task).await;
}
impl Drop for NetworkProxyHandle {
fn drop(&mut self) {
if self.completed {
return;
}
let http_task = self.http_task.take();
let socks_task = self.socks_task.take();
tokio::spawn(async move {
abort_tasks(http_task, socks_task).await;
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::NetworkProxySettings;
use crate::state::network_proxy_state_for_policy;
use pretty_assertions::assert_eq;
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::path::Path;
#[tokio::test]
async fn managed_proxy_builder_uses_loopback_ports() {
let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
let http_addr = http_listener.local_addr().unwrap();
let socks_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
let socks_addr = socks_listener.local_addr().unwrap();
drop(http_listener);
drop(socks_listener);
let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
proxy_url: format!("http://{http_addr}"),
socks_url: format!("http://{socks_addr}"),
..NetworkProxySettings::default()
}));
let proxy = match NetworkProxy::builder().state(state).build().await {
Ok(proxy) => proxy,
Err(err) => {
if err
.chain()
.any(|cause| cause.to_string().contains("Operation not permitted"))
{
return;
}
panic!("failed to build managed proxy: {err:#}");
}
};
assert!(proxy.http_addr.ip().is_loopback());
assert!(proxy.socks_addr.ip().is_loopback());
#[cfg(target_os = "windows")]
{
assert_eq!(proxy.http_addr, http_addr);
assert_eq!(proxy.socks_addr, socks_addr);
}
#[cfg(not(target_os = "windows"))]
{
assert_ne!(proxy.http_addr.port(), 0);
assert_ne!(proxy.socks_addr.port(), 0);
}
}
#[tokio::test]
async fn non_codex_managed_proxy_builder_uses_configured_ports() {
let settings = NetworkProxySettings {
proxy_url: "http://127.0.0.1:43128".to_string(),
socks_url: "http://127.0.0.1:48081".to_string(),
..NetworkProxySettings::default()
};
let state = Arc::new(network_proxy_state_for_policy(settings));
let proxy = NetworkProxy::builder()
.state(state)
.managed_by_codex(/*managed_by_codex*/ false)
.build()
.await
.unwrap();
assert_eq!(
proxy.http_addr,
"127.0.0.1:43128".parse::<SocketAddr>().unwrap()
);
assert_eq!(
proxy.socks_addr,
"127.0.0.1:48081".parse::<SocketAddr>().unwrap()
);
}
#[tokio::test]
async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
let settings = NetworkProxySettings {
enable_socks5: false,
proxy_url: "http://127.0.0.1:43128".to_string(),
socks_url: "http://127.0.0.1:43129".to_string(),
..NetworkProxySettings::default()
};
let state = Arc::new(network_proxy_state_for_policy(settings));
let proxy = match NetworkProxy::builder().state(state).build().await {
Ok(proxy) => proxy,
Err(err) => {
if err
.chain()
.any(|cause| cause.to_string().contains("Operation not permitted"))
{
return;
}
panic!("failed to build managed proxy: {err:#}");
}
};
assert!(proxy.http_addr.ip().is_loopback());
assert_ne!(proxy.http_addr.port(), 0);
assert_eq!(
proxy.socks_addr,
"127.0.0.1:43129".parse::<SocketAddr>().unwrap()
);
assert!(
proxy
.reserved_listeners
.as_ref()
.expect("managed builder should reserve listeners")
.take_socks()
.is_none()
);
}
#[cfg(target_os = "windows")]
#[test]
fn windows_managed_loopback_addr_clamps_non_loopback_inputs() {
assert_eq!(
windows_managed_loopback_addr("0.0.0.0:3128".parse::<SocketAddr>().unwrap()),
"127.0.0.1:3128".parse::<SocketAddr>().unwrap()
);
assert_eq!(
windows_managed_loopback_addr("[::]:8081".parse::<SocketAddr>().unwrap()),
"127.0.0.1:8081".parse::<SocketAddr>().unwrap()
);
}
#[cfg(target_os = "windows")]
#[test]
fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() {
let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
let busy_port = occupied.local_addr().unwrap().port();
let reserved = reserve_windows_managed_listeners(
SocketAddr::from(([127, 0, 0, 1], busy_port)),
SocketAddr::from(([127, 0, 0, 1], 48081)),
/*reserve_socks_listener*/ false,
)
.unwrap();
assert!(reserved.socks_listener.is_none());
assert!(
reserved
.http_listener
.local_addr()
.unwrap()
.ip()
.is_loopback()
);
assert_ne!(
reserved.http_listener.local_addr().unwrap().port(),
busy_port
);
}
#[test]
fn proxy_url_env_value_resolves_lowercase_aliases() {
let mut env = HashMap::new();
env.insert(
"http_proxy".to_string(),
"http://127.0.0.1:3128".to_string(),
);
assert_eq!(
proxy_url_env_value(&env, "HTTP_PROXY"),
Some("http://127.0.0.1:3128")
);
}
#[test]
fn has_proxy_url_env_vars_detects_lowercase_aliases() {
let mut env = HashMap::new();
env.insert(
"all_proxy".to_string(),
"socks5h://127.0.0.1:8081".to_string(),
);
assert_eq!(has_proxy_url_env_vars(&env), true);
}
#[test]
fn has_proxy_url_env_vars_detects_websocket_proxy_keys() {
let mut env = HashMap::new();
env.insert("wss_proxy".to_string(), "http://127.0.0.1:3128".to_string());
assert_eq!(has_proxy_url_env_vars(&env), true);
}
#[test]
fn apply_proxy_env_overrides_sets_common_tool_vars() {
let mut env = HashMap::new();
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
/*socks_enabled*/ true,
/*allow_local_binding*/ false,
/*mitm_ca_trust_bundle*/ None,
);
assert_eq!(
env.get("HTTP_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WS_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WSS_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("npm_config_proxy"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("ALL_PROXY"),
Some(&"socks5h://127.0.0.1:8081".to_string())
);
assert_eq!(
env.get("FTP_PROXY"),
Some(&"socks5h://127.0.0.1:8081".to_string())
);
assert_eq!(
env.get("NO_PROXY"),
Some(&DEFAULT_NO_PROXY_VALUE.to_string())
);
let no_proxy = env.get("NO_PROXY").expect("NO_PROXY should be set");
assert!(no_proxy.contains("10.0.0.0/8"));
assert!(no_proxy.contains("172.16.0.0/12"));
assert!(no_proxy.contains("192.168.0.0/16"));
assert!(!no_proxy.contains("169.254.0.0/16"));
assert_eq!(env.get(PROXY_ACTIVE_ENV_KEY), Some(&"1".to_string()));
assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string()));
assert_eq!(
env.get(ELECTRON_GET_USE_PROXY_ENV_KEY),
Some(&"true".to_string())
);
assert_eq!(env.get(NODE_USE_ENV_PROXY_ENV_KEY), Some(&"1".to_string()));
#[cfg(target_os = "macos")]
assert_eq!(
env.get(GIT_SSH_COMMAND_ENV_KEY),
Some(
&"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'"
.to_string()
)
);
#[cfg(not(target_os = "macos"))]
assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
}
#[test]
fn apply_proxy_env_overrides_sets_only_expected_env_keys() {
let mut env = HashMap::new();
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
/*socks_enabled*/ true,
/*allow_local_binding*/ false,
/*mitm_ca_trust_bundle*/ None,
);
for key in env.keys() {
let is_managed_git_ssh_key =
cfg!(target_os = "macos") && key == GIT_SSH_COMMAND_ENV_KEY;
assert!(
PROXY_ENV_KEYS.contains(&key.as_str()) || is_managed_git_ssh_key,
"proxy env writer set unexpected key: {key}"
);
}
}
#[test]
fn apply_proxy_env_overrides_sets_mitm_ca_trust_bundle_vars() {
let mut env = HashMap::new();
let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem");
let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle {
path: mitm_ca_trust_bundle_path.to_path_buf(),
startup_env_values: HashMap::new(),
};
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
/*socks_enabled*/ true,
/*allow_local_binding*/ false,
Some(&mitm_ca_trust_bundle),
);
for key in crate::certs::CUSTOM_CA_ENV_KEYS {
assert_eq!(
env.get(key),
Some(&mitm_ca_trust_bundle_path.display().to_string())
);
}
}
#[test]
fn apply_proxy_env_overrides_preserves_command_scoped_mitm_ca_override() {
let command_ca_bundle_path = "/tmp/command-ca.pem".to_string();
let mut env = HashMap::from([(
"REQUESTS_CA_BUNDLE".to_string(),
command_ca_bundle_path.clone(),
)]);
let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem");
let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle {
path: mitm_ca_trust_bundle_path.to_path_buf(),
startup_env_values: HashMap::new(),
};
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
/*socks_enabled*/ true,
/*allow_local_binding*/ false,
Some(&mitm_ca_trust_bundle),
);
assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&command_ca_bundle_path));
assert_eq!(
env.get("SSL_CERT_FILE"),
Some(&mitm_ca_trust_bundle_path.display().to_string())
);
}
#[test]
fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() {
let mut env = HashMap::new();
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
/*socks_enabled*/ false,
/*allow_local_binding*/ true,
/*mitm_ca_trust_bundle*/ None,
);
assert_eq!(
env.get("ALL_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"1".to_string()));
}
#[test]
fn apply_proxy_env_overrides_uses_plain_http_proxy_url() {
let mut env = HashMap::new();
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
/*socks_enabled*/ true,
/*allow_local_binding*/ false,
/*mitm_ca_trust_bundle*/ None,
);
assert_eq!(
env.get("HTTP_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("HTTPS_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WS_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("WSS_PROXY"),
Some(&"http://127.0.0.1:3128".to_string())
);
assert_eq!(
env.get("ALL_PROXY"),
Some(&"socks5h://127.0.0.1:8081".to_string())
);
#[cfg(target_os = "macos")]
assert_eq!(
env.get(GIT_SSH_COMMAND_ENV_KEY),
Some(
&"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'"
.to_string()
)
);
#[cfg(not(target_os = "macos"))]
assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
}
#[cfg(target_os = "macos")]
#[test]
fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() {
let mut env = HashMap::new();
env.insert(
GIT_SSH_COMMAND_ENV_KEY.to_string(),
"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(),
);
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
/*socks_enabled*/ true,
/*allow_local_binding*/ false,
/*mitm_ca_trust_bundle*/ None,
);
assert_eq!(
env.get(GIT_SSH_COMMAND_ENV_KEY),
Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string())
);
}
#[cfg(target_os = "macos")]
#[test]
fn apply_proxy_env_overrides_preserves_unmarked_git_ssh_command_with_proxy_shape() {
let mut env = HashMap::new();
env.insert(
GIT_SSH_COMMAND_ENV_KEY.to_string(),
"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string(),
);
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
/*socks_enabled*/ true,
/*allow_local_binding*/ false,
/*mitm_ca_trust_bundle*/ None,
);
assert_eq!(
env.get(GIT_SSH_COMMAND_ENV_KEY),
Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
);
}
#[cfg(target_os = "macos")]
#[test]
fn apply_proxy_env_overrides_refreshes_previous_codex_proxy_git_ssh_command() {
let mut env = HashMap::new();
env.insert(
GIT_SSH_COMMAND_ENV_KEY.to_string(),
codex_proxy_git_ssh_command(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081)),
);
apply_proxy_env_overrides(
&mut env,
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 43128),
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
/*socks_enabled*/ true,
/*allow_local_binding*/ false,
/*mitm_ca_trust_bundle*/ None,
);
assert_eq!(
env.get(GIT_SSH_COMMAND_ENV_KEY),
Some(&codex_proxy_git_ssh_command(SocketAddr::new(
IpAddr::V4(Ipv4Addr::LOCALHOST),
48081,
)))
);
}
}