Skip to main content

zerobox_network_proxy/
proxy.rs

1use crate::config;
2use crate::http_proxy;
3use crate::network_policy::NetworkPolicyDecider;
4use crate::runtime::BlockedRequestObserver;
5use crate::runtime::ConfigState;
6use crate::runtime::unix_socket_permissions_supported;
7use crate::socks5;
8use crate::state::NetworkProxyState;
9use anyhow::Context;
10use anyhow::Result;
11use clap::Parser;
12use std::collections::HashMap;
13use std::net::SocketAddr;
14use std::net::TcpListener as StdTcpListener;
15use std::sync::Arc;
16use std::sync::Mutex;
17use std::sync::RwLock;
18use tokio::task::JoinHandle;
19use tracing::warn;
20
21#[derive(Debug, Clone, Parser)]
22#[command(name = "zerobox-network-proxy", about = "Codex network sandbox proxy")]
23pub struct Args {}
24
25#[derive(Debug)]
26struct ReservedListeners {
27    http: Mutex<Option<StdTcpListener>>,
28    socks: Mutex<Option<StdTcpListener>>,
29}
30
31impl ReservedListeners {
32    fn new(http: StdTcpListener, socks: Option<StdTcpListener>) -> Self {
33        Self {
34            http: Mutex::new(Some(http)),
35            socks: Mutex::new(socks),
36        }
37    }
38
39    fn take_http(&self) -> Option<StdTcpListener> {
40        let mut guard = self
41            .http
42            .lock()
43            .unwrap_or_else(std::sync::PoisonError::into_inner);
44        guard.take()
45    }
46
47    fn take_socks(&self) -> Option<StdTcpListener> {
48        let mut guard = self
49            .socks
50            .lock()
51            .unwrap_or_else(std::sync::PoisonError::into_inner);
52        guard.take()
53    }
54}
55
56struct ReservedListenerSet {
57    http_listener: StdTcpListener,
58    socks_listener: Option<StdTcpListener>,
59}
60
61impl ReservedListenerSet {
62    fn new(http_listener: StdTcpListener, socks_listener: Option<StdTcpListener>) -> Self {
63        Self {
64            http_listener,
65            socks_listener,
66        }
67    }
68
69    fn http_addr(&self) -> Result<SocketAddr> {
70        self.http_listener
71            .local_addr()
72            .context("failed to read reserved HTTP proxy address")
73    }
74
75    fn socks_addr(&self, default_addr: SocketAddr) -> Result<SocketAddr> {
76        self.socks_listener
77            .as_ref()
78            .map_or(Ok(default_addr), |listener| {
79                listener
80                    .local_addr()
81                    .context("failed to read reserved SOCKS5 proxy address")
82            })
83    }
84
85    fn into_reserved_listeners(self) -> Arc<ReservedListeners> {
86        Arc::new(ReservedListeners::new(
87            self.http_listener,
88            self.socks_listener,
89        ))
90    }
91}
92
93#[derive(Clone)]
94pub struct NetworkProxyBuilder {
95    state: Option<Arc<NetworkProxyState>>,
96    http_addr: Option<SocketAddr>,
97    socks_addr: Option<SocketAddr>,
98    managed_by_codex: bool,
99    policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
100    blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
101}
102
103impl Default for NetworkProxyBuilder {
104    fn default() -> Self {
105        Self {
106            state: None,
107            http_addr: None,
108            socks_addr: None,
109            managed_by_codex: true,
110            policy_decider: None,
111            blocked_request_observer: None,
112        }
113    }
114}
115
116impl NetworkProxyBuilder {
117    pub fn state(mut self, state: Arc<NetworkProxyState>) -> Self {
118        self.state = Some(state);
119        self
120    }
121
122    pub fn http_addr(mut self, addr: SocketAddr) -> Self {
123        self.http_addr = Some(addr);
124        self
125    }
126
127    pub fn socks_addr(mut self, addr: SocketAddr) -> Self {
128        self.socks_addr = Some(addr);
129        self
130    }
131
132    pub fn managed_by_codex(mut self, managed_by_codex: bool) -> Self {
133        self.managed_by_codex = managed_by_codex;
134        self
135    }
136
137    pub fn policy_decider<D>(mut self, decider: D) -> Self
138    where
139        D: NetworkPolicyDecider,
140    {
141        self.policy_decider = Some(Arc::new(decider));
142        self
143    }
144
145    pub fn policy_decider_arc(mut self, decider: Arc<dyn NetworkPolicyDecider>) -> Self {
146        self.policy_decider = Some(decider);
147        self
148    }
149
150    pub fn blocked_request_observer<O>(mut self, observer: O) -> Self
151    where
152        O: BlockedRequestObserver,
153    {
154        self.blocked_request_observer = Some(Arc::new(observer));
155        self
156    }
157
158    pub fn blocked_request_observer_arc(
159        mut self,
160        observer: Arc<dyn BlockedRequestObserver>,
161    ) -> Self {
162        self.blocked_request_observer = Some(observer);
163        self
164    }
165
166    pub async fn build(self) -> Result<NetworkProxy> {
167        let state = self.state.ok_or_else(|| {
168            anyhow::anyhow!(
169                "NetworkProxyBuilder requires a state; supply one via builder.state(...)"
170            )
171        })?;
172        state
173            .set_blocked_request_observer(self.blocked_request_observer.clone())
174            .await;
175        let current_cfg = state.current_cfg().await?;
176        let (requested_http_addr, requested_socks_addr, reserved_listeners) = if self
177            .managed_by_codex
178        {
179            let runtime = config::resolve_runtime(&current_cfg)?;
180            #[cfg(target_os = "windows")]
181            let (managed_http_addr, managed_socks_addr) = config::clamp_bind_addrs(
182                runtime.http_addr,
183                runtime.socks_addr,
184                &current_cfg.network,
185            );
186            #[cfg(target_os = "windows")]
187            let reserved = reserve_windows_managed_listeners(
188                managed_http_addr,
189                managed_socks_addr,
190                current_cfg.network.enable_socks5,
191            )
192            .context("reserve managed loopback proxy listeners")?;
193            #[cfg(not(target_os = "windows"))]
194            let reserved = reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5)
195                .context("reserve managed loopback proxy listeners")?;
196            let http_addr = reserved.http_addr()?;
197            let socks_addr = reserved.socks_addr(runtime.socks_addr)?;
198            (
199                http_addr,
200                socks_addr,
201                Some(reserved.into_reserved_listeners()),
202            )
203        } else {
204            let runtime = config::resolve_runtime(&current_cfg)?;
205            (
206                self.http_addr.unwrap_or(runtime.http_addr),
207                self.socks_addr.unwrap_or(runtime.socks_addr),
208                None,
209            )
210        };
211
212        // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only.
213        let (http_addr, socks_addr) = config::clamp_bind_addrs(
214            requested_http_addr,
215            requested_socks_addr,
216            &current_cfg.network,
217        );
218
219        Ok(NetworkProxy {
220            state,
221            http_addr,
222            socks_addr,
223            socks_enabled: current_cfg.network.enable_socks5,
224            runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config(
225                &current_cfg,
226            ))),
227            reserved_listeners,
228            policy_decider: self.policy_decider,
229        })
230    }
231}
232
233fn reserve_loopback_ephemeral_listeners(
234    reserve_socks_listener: bool,
235) -> Result<ReservedListenerSet> {
236    let http_listener =
237        reserve_loopback_ephemeral_listener().context("reserve HTTP proxy listener")?;
238    let socks_listener = if reserve_socks_listener {
239        Some(reserve_loopback_ephemeral_listener().context("reserve SOCKS5 proxy listener")?)
240    } else {
241        None
242    };
243    Ok(ReservedListenerSet::new(http_listener, socks_listener))
244}
245
246#[cfg(target_os = "windows")]
247fn reserve_windows_managed_listeners(
248    http_addr: SocketAddr,
249    socks_addr: SocketAddr,
250    reserve_socks_listener: bool,
251) -> Result<ReservedListenerSet> {
252    let http_addr = windows_managed_loopback_addr(http_addr);
253    let socks_addr = windows_managed_loopback_addr(socks_addr);
254
255    match try_reserve_windows_managed_listeners(http_addr, socks_addr, reserve_socks_listener) {
256        Ok(listeners) => Ok(listeners),
257        Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
258            warn!("managed Windows proxy ports are busy; falling back to ephemeral loopback ports");
259            reserve_loopback_ephemeral_listeners(reserve_socks_listener)
260                .context("reserve fallback loopback proxy listeners")
261        }
262        Err(err) => Err(err).context("reserve Windows managed proxy listeners"),
263    }
264}
265
266#[cfg(target_os = "windows")]
267fn try_reserve_windows_managed_listeners(
268    http_addr: SocketAddr,
269    socks_addr: SocketAddr,
270    reserve_socks_listener: bool,
271) -> std::io::Result<ReservedListenerSet> {
272    let http_listener = StdTcpListener::bind(http_addr)?;
273    let socks_listener = if reserve_socks_listener {
274        Some(StdTcpListener::bind(socks_addr)?)
275    } else {
276        None
277    };
278    Ok(ReservedListenerSet::new(http_listener, socks_listener))
279}
280
281#[cfg(target_os = "windows")]
282fn windows_managed_loopback_addr(addr: SocketAddr) -> SocketAddr {
283    if !addr.ip().is_loopback() {
284        warn!(
285            "managed Windows proxies must bind to loopback; clamping {addr} to 127.0.0.1:{}",
286            addr.port()
287        );
288    }
289    SocketAddr::from(([127, 0, 0, 1], addr.port()))
290}
291
292fn reserve_loopback_ephemeral_listener() -> Result<StdTcpListener> {
293    StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
294        .context("bind loopback ephemeral port")
295}
296
297#[derive(Debug, Clone, PartialEq, Eq)]
298struct NetworkProxyRuntimeSettings {
299    allow_local_binding: bool,
300    allow_unix_sockets: Arc<[String]>,
301    dangerously_allow_all_unix_sockets: bool,
302}
303
304impl NetworkProxyRuntimeSettings {
305    fn from_config(config: &config::NetworkProxyConfig) -> Self {
306        Self {
307            allow_local_binding: config.network.allow_local_binding,
308            allow_unix_sockets: config.network.allow_unix_sockets().into(),
309            dangerously_allow_all_unix_sockets: config.network.dangerously_allow_all_unix_sockets,
310        }
311    }
312}
313
314#[derive(Clone)]
315pub struct NetworkProxy {
316    state: Arc<NetworkProxyState>,
317    http_addr: SocketAddr,
318    socks_addr: SocketAddr,
319    socks_enabled: bool,
320    runtime_settings: Arc<RwLock<NetworkProxyRuntimeSettings>>,
321    reserved_listeners: Option<Arc<ReservedListeners>>,
322    policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
323}
324
325impl std::fmt::Debug for NetworkProxy {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
328        // and may contain sensitive paths.
329        f.debug_struct("NetworkProxy")
330            .field("http_addr", &self.http_addr)
331            .field("socks_addr", &self.socks_addr)
332            .finish_non_exhaustive()
333    }
334}
335
336impl PartialEq for NetworkProxy {
337    fn eq(&self, other: &Self) -> bool {
338        self.http_addr == other.http_addr
339            && self.socks_addr == other.socks_addr
340            && self.runtime_settings() == other.runtime_settings()
341    }
342}
343
344impl Eq for NetworkProxy {}
345
346pub const PROXY_URL_ENV_KEYS: &[&str] = &[
347    "HTTP_PROXY",
348    "HTTPS_PROXY",
349    "WS_PROXY",
350    "WSS_PROXY",
351    "ALL_PROXY",
352    "FTP_PROXY",
353    "YARN_HTTP_PROXY",
354    "YARN_HTTPS_PROXY",
355    "NPM_CONFIG_HTTP_PROXY",
356    "NPM_CONFIG_HTTPS_PROXY",
357    "NPM_CONFIG_PROXY",
358    "BUNDLE_HTTP_PROXY",
359    "BUNDLE_HTTPS_PROXY",
360    "PIP_PROXY",
361    "DOCKER_HTTP_PROXY",
362    "DOCKER_HTTPS_PROXY",
363];
364
365pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"];
366pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE";
367pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
368const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY";
369#[cfg(any(target_os = "macos", test))]
370const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND";
371pub const PROXY_ENV_KEYS: &[&str] = &[
372    PROXY_ACTIVE_ENV_KEY,
373    ALLOW_LOCAL_BINDING_ENV_KEY,
374    ELECTRON_GET_USE_PROXY_ENV_KEY,
375    "NODE_USE_ENV_PROXY",
376    "HTTP_PROXY",
377    "HTTPS_PROXY",
378    "http_proxy",
379    "https_proxy",
380    "YARN_HTTP_PROXY",
381    "YARN_HTTPS_PROXY",
382    "npm_config_http_proxy",
383    "npm_config_https_proxy",
384    "npm_config_proxy",
385    "NPM_CONFIG_HTTP_PROXY",
386    "NPM_CONFIG_HTTPS_PROXY",
387    "NPM_CONFIG_PROXY",
388    "BUNDLE_HTTP_PROXY",
389    "BUNDLE_HTTPS_PROXY",
390    "PIP_PROXY",
391    "DOCKER_HTTP_PROXY",
392    "DOCKER_HTTPS_PROXY",
393    "WS_PROXY",
394    "WSS_PROXY",
395    "ws_proxy",
396    "wss_proxy",
397    "NO_PROXY",
398    "no_proxy",
399    "npm_config_noproxy",
400    "NPM_CONFIG_NOPROXY",
401    "YARN_NO_PROXY",
402    "BUNDLE_NO_PROXY",
403    "ALL_PROXY",
404    "all_proxy",
405    "FTP_PROXY",
406    "ftp_proxy",
407];
408
409#[cfg(target_os = "macos")]
410pub const PROXY_GIT_SSH_COMMAND_ENV_KEY: &str = GIT_SSH_COMMAND_ENV_KEY;
411
412const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
413const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"];
414
415pub const NO_PROXY_ENV_KEYS: &[&str] = &[
416    "NO_PROXY",
417    "no_proxy",
418    "npm_config_noproxy",
419    "NPM_CONFIG_NOPROXY",
420    "YARN_NO_PROXY",
421    "BUNDLE_NO_PROXY",
422];
423
424pub const DEFAULT_NO_PROXY_VALUE: &str = concat!(
425    "localhost,127.0.0.1,::1,",
426    "10.0.0.0/8,",
427    "172.16.0.0/12,",
428    "192.168.0.0/16"
429);
430
431#[cfg(target_os = "macos")]
432pub const CODEX_PROXY_GIT_SSH_COMMAND_MARKER: &str = "CODEX_PROXY_GIT_SSH_COMMAND=1 ";
433#[cfg(target_os = "macos")]
434const CODEX_PROXY_GIT_SSH_COMMAND_PREFIX: &str =
435    "CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x ";
436#[cfg(target_os = "macos")]
437const CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX: &str = " %h %p'";
438
439pub fn proxy_url_env_value<'a>(
440    env: &'a HashMap<String, String>,
441    canonical_key: &str,
442) -> Option<&'a str> {
443    if let Some(value) = env.get(canonical_key) {
444        return Some(value.as_str());
445    }
446    let lower_key = canonical_key.to_ascii_lowercase();
447    env.get(lower_key.as_str()).map(String::as_str)
448}
449
450pub fn has_proxy_url_env_vars(env: &HashMap<String, String>) -> bool {
451    PROXY_URL_ENV_KEYS
452        .iter()
453        .any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty()))
454}
455
456fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
457    for key in keys {
458        env.insert((*key).to_string(), value.to_string());
459    }
460}
461
462#[cfg(target_os = "macos")]
463fn codex_proxy_git_ssh_command(socks_addr: SocketAddr) -> String {
464    format!("{CODEX_PROXY_GIT_SSH_COMMAND_PREFIX}{socks_addr}{CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX}")
465}
466
467#[cfg(target_os = "macos")]
468fn is_codex_proxy_git_ssh_command(command: &str) -> bool {
469    command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_PREFIX)
470        && command.ends_with(CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX)
471}
472
473fn apply_proxy_env_overrides(
474    env: &mut HashMap<String, String>,
475    http_addr: SocketAddr,
476    socks_addr: SocketAddr,
477    socks_enabled: bool,
478    allow_local_binding: bool,
479) {
480    let http_proxy_url = format!("http://{http_addr}");
481    let socks_proxy_url = format!("socks5h://{socks_addr}");
482    env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string());
483    env.insert(
484        ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
485        if allow_local_binding {
486            "1".to_string()
487        } else {
488            "0".to_string()
489        },
490    );
491
492    // HTTP-based clients are best served by explicit HTTP proxy URLs.
493    set_env_keys(
494        env,
495        &[
496            "HTTP_PROXY",
497            "HTTPS_PROXY",
498            "http_proxy",
499            "https_proxy",
500            "YARN_HTTP_PROXY",
501            "YARN_HTTPS_PROXY",
502            "npm_config_http_proxy",
503            "npm_config_https_proxy",
504            "npm_config_proxy",
505            "NPM_CONFIG_HTTP_PROXY",
506            "NPM_CONFIG_HTTPS_PROXY",
507            "NPM_CONFIG_PROXY",
508            "BUNDLE_HTTP_PROXY",
509            "BUNDLE_HTTPS_PROXY",
510            "PIP_PROXY",
511            "DOCKER_HTTP_PROXY",
512            "DOCKER_HTTPS_PROXY",
513        ],
514        &http_proxy_url,
515    );
516    // Some websocket clients look for dedicated WS/WSS proxy environment variables instead of
517    // HTTP(S)_PROXY. Keep them aligned with the managed HTTP proxy endpoint.
518    set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);
519
520    // Keep loopback and IP-literal private targets direct so local IPC/LAN access avoids the proxy.
521    // Do not include hostname suffixes here: those can force clients to resolve internal names
522    // locally instead of letting the proxy resolve them.
523    set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
524
525    env.insert(
526        ELECTRON_GET_USE_PROXY_ENV_KEY.to_string(),
527        "true".to_string(),
528    );
529
530    // Node.js 22.21+ / 24+: make built-in fetch() respect HTTP_PROXY/HTTPS_PROXY
531    env.insert("NODE_USE_ENV_PROXY".to_string(), "1".to_string());
532
533    // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if
534    // those vars contain SOCKS URLs. We only switch ALL_PROXY here.
535    //
536    if socks_enabled {
537        set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
538        set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
539    } else {
540        set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url);
541        set_env_keys(env, FTP_PROXY_ENV_KEYS, &http_proxy_url);
542    }
543
544    #[cfg(target_os = "macos")]
545    if socks_enabled {
546        // Preserve existing SSH wrappers (for example: Secretive/Teleport setups)
547        // but refresh a previously injected Codex fallback so it cannot point
548        // at a stale proxy port after the proxy is restarted.
549        match env.get(GIT_SSH_COMMAND_ENV_KEY) {
550            Some(command) if !is_codex_proxy_git_ssh_command(command) => {}
551            _ => {
552                env.insert(
553                    GIT_SSH_COMMAND_ENV_KEY.to_string(),
554                    codex_proxy_git_ssh_command(socks_addr),
555                );
556            }
557        }
558    }
559}
560
561impl NetworkProxy {
562    pub fn builder() -> NetworkProxyBuilder {
563        NetworkProxyBuilder::default()
564    }
565
566    pub fn http_addr(&self) -> SocketAddr {
567        self.http_addr
568    }
569
570    pub fn socks_addr(&self) -> SocketAddr {
571        self.socks_addr
572    }
573
574    pub async fn current_cfg(&self) -> Result<config::NetworkProxyConfig> {
575        self.state.current_cfg().await
576    }
577
578    pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
579        self.state.add_allowed_domain(host).await
580    }
581
582    pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
583        self.state.add_denied_domain(host).await
584    }
585
586    pub fn allow_local_binding(&self) -> bool {
587        self.runtime_settings().allow_local_binding
588    }
589
590    pub fn allow_unix_sockets(&self) -> Arc<[String]> {
591        self.runtime_settings().allow_unix_sockets
592    }
593
594    pub fn dangerously_allow_all_unix_sockets(&self) -> bool {
595        self.runtime_settings().dangerously_allow_all_unix_sockets
596    }
597
598    pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
599        let allow_local_binding = self.allow_local_binding();
600        // Enforce proxying for child processes. We intentionally override existing values so
601        // command-level environment cannot bypass the managed proxy endpoint.
602        apply_proxy_env_overrides(
603            env,
604            self.http_addr,
605            self.socks_addr,
606            self.socks_enabled,
607            allow_local_binding,
608        );
609    }
610
611    pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> {
612        let current_cfg = self.state.current_cfg().await?;
613        anyhow::ensure!(
614            new_state.config.network.enabled == current_cfg.network.enabled,
615            "cannot update network.enabled on a running proxy"
616        );
617        anyhow::ensure!(
618            new_state.config.network.proxy_url == current_cfg.network.proxy_url,
619            "cannot update network.proxy_url on a running proxy"
620        );
621        anyhow::ensure!(
622            new_state.config.network.socks_url == current_cfg.network.socks_url,
623            "cannot update network.socks_url on a running proxy"
624        );
625        anyhow::ensure!(
626            new_state.config.network.enable_socks5 == current_cfg.network.enable_socks5,
627            "cannot update network.enable_socks5 on a running proxy"
628        );
629        anyhow::ensure!(
630            new_state.config.network.enable_socks5_udp == current_cfg.network.enable_socks5_udp,
631            "cannot update network.enable_socks5_udp on a running proxy"
632        );
633
634        let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config);
635        self.state.replace_config_state(new_state).await?;
636        let mut guard = self
637            .runtime_settings
638            .write()
639            .unwrap_or_else(std::sync::PoisonError::into_inner);
640        *guard = settings;
641        Ok(())
642    }
643
644    fn runtime_settings(&self) -> NetworkProxyRuntimeSettings {
645        self.runtime_settings
646            .read()
647            .unwrap_or_else(std::sync::PoisonError::into_inner)
648            .clone()
649    }
650
651    pub async fn run(&self) -> Result<NetworkProxyHandle> {
652        let current_cfg = self.state.current_cfg().await?;
653        if !current_cfg.network.enabled {
654            warn!("network.enabled is false; skipping proxy listeners");
655            return Ok(NetworkProxyHandle::noop());
656        }
657
658        if !unix_socket_permissions_supported() {
659            warn!(
660                "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform"
661            );
662        }
663
664        let reserved_listeners = self.reserved_listeners.as_ref();
665        let http_listener = reserved_listeners.and_then(|listeners| listeners.take_http());
666        let socks_listener = reserved_listeners.and_then(|listeners| listeners.take_socks());
667
668        let http_state = self.state.clone();
669        let http_decider = self.policy_decider.clone();
670        let http_addr = self.http_addr;
671        let http_task = tokio::spawn(async move {
672            match http_listener {
673                Some(listener) => {
674                    http_proxy::run_http_proxy_with_std_listener(http_state, listener, http_decider)
675                        .await
676                }
677                None => http_proxy::run_http_proxy(http_state, http_addr, http_decider).await,
678            }
679        });
680
681        let socks_task = if current_cfg.network.enable_socks5 {
682            let socks_state = self.state.clone();
683            let socks_decider = self.policy_decider.clone();
684            let socks_addr = self.socks_addr;
685            let enable_socks5_udp = current_cfg.network.enable_socks5_udp;
686            Some(tokio::spawn(async move {
687                match socks_listener {
688                    Some(listener) => {
689                        socks5::run_socks5_with_std_listener(
690                            socks_state,
691                            listener,
692                            socks_decider,
693                            enable_socks5_udp,
694                        )
695                        .await
696                    }
697                    None => {
698                        socks5::run_socks5(
699                            socks_state,
700                            socks_addr,
701                            socks_decider,
702                            enable_socks5_udp,
703                        )
704                        .await
705                    }
706                }
707            }))
708        } else {
709            None
710        };
711
712        Ok(NetworkProxyHandle {
713            http_task: Some(http_task),
714            socks_task,
715            completed: false,
716        })
717    }
718}
719
720pub struct NetworkProxyHandle {
721    http_task: Option<JoinHandle<Result<()>>>,
722    socks_task: Option<JoinHandle<Result<()>>>,
723    completed: bool,
724}
725
726impl NetworkProxyHandle {
727    fn noop() -> Self {
728        Self {
729            http_task: Some(tokio::spawn(async { Ok(()) })),
730            socks_task: None,
731            completed: true,
732        }
733    }
734
735    pub async fn wait(mut self) -> Result<()> {
736        let http_task = self.http_task.take().context("missing http proxy task")?;
737        let socks_task = self.socks_task.take();
738        let http_result = http_task.await;
739        let socks_result = match socks_task {
740            Some(task) => Some(task.await),
741            None => None,
742        };
743        self.completed = true;
744        http_result??;
745        if let Some(socks_result) = socks_result {
746            socks_result??;
747        }
748        Ok(())
749    }
750
751    pub async fn shutdown(mut self) -> Result<()> {
752        abort_tasks(self.http_task.take(), self.socks_task.take()).await;
753        self.completed = true;
754        Ok(())
755    }
756}
757
758async fn abort_task(task: Option<JoinHandle<Result<()>>>) {
759    if let Some(task) = task {
760        task.abort();
761        let _ = task.await;
762    }
763}
764
765async fn abort_tasks(
766    http_task: Option<JoinHandle<Result<()>>>,
767    socks_task: Option<JoinHandle<Result<()>>>,
768) {
769    abort_task(http_task).await;
770    abort_task(socks_task).await;
771}
772
773impl Drop for NetworkProxyHandle {
774    fn drop(&mut self) {
775        if self.completed {
776            return;
777        }
778        let http_task = self.http_task.take();
779        let socks_task = self.socks_task.take();
780        tokio::spawn(async move {
781            abort_tasks(http_task, socks_task).await;
782        });
783    }
784}
785
786#[cfg(test)]
787mod tests {
788    use super::*;
789    use crate::config::NetworkProxySettings;
790    use crate::state::network_proxy_state_for_policy;
791    use pretty_assertions::assert_eq;
792    use std::net::IpAddr;
793    use std::net::Ipv4Addr;
794
795    #[tokio::test]
796    async fn managed_proxy_builder_uses_loopback_ports() {
797        let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
798        let http_addr = http_listener.local_addr().unwrap();
799        let socks_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
800        let socks_addr = socks_listener.local_addr().unwrap();
801        drop(http_listener);
802        drop(socks_listener);
803
804        let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
805            proxy_url: format!("http://{http_addr}"),
806            socks_url: format!("http://{socks_addr}"),
807            ..NetworkProxySettings::default()
808        }));
809        let proxy = match NetworkProxy::builder().state(state).build().await {
810            Ok(proxy) => proxy,
811            Err(err) => {
812                if err
813                    .chain()
814                    .any(|cause| cause.to_string().contains("Operation not permitted"))
815                {
816                    return;
817                }
818                panic!("failed to build managed proxy: {err:#}");
819            }
820        };
821
822        assert!(proxy.http_addr.ip().is_loopback());
823        assert!(proxy.socks_addr.ip().is_loopback());
824        #[cfg(target_os = "windows")]
825        {
826            assert_eq!(proxy.http_addr, http_addr);
827            assert_eq!(proxy.socks_addr, socks_addr);
828        }
829        #[cfg(not(target_os = "windows"))]
830        {
831            assert_ne!(proxy.http_addr.port(), 0);
832            assert_ne!(proxy.socks_addr.port(), 0);
833        }
834    }
835
836    #[tokio::test]
837    async fn non_codex_managed_proxy_builder_uses_configured_ports() {
838        let settings = NetworkProxySettings {
839            proxy_url: "http://127.0.0.1:43128".to_string(),
840            socks_url: "http://127.0.0.1:48081".to_string(),
841            ..NetworkProxySettings::default()
842        };
843        let state = Arc::new(network_proxy_state_for_policy(settings));
844        let proxy = NetworkProxy::builder()
845            .state(state)
846            .managed_by_codex(/*managed_by_codex*/ false)
847            .build()
848            .await
849            .unwrap();
850
851        assert_eq!(
852            proxy.http_addr,
853            "127.0.0.1:43128".parse::<SocketAddr>().unwrap()
854        );
855        assert_eq!(
856            proxy.socks_addr,
857            "127.0.0.1:48081".parse::<SocketAddr>().unwrap()
858        );
859    }
860
861    #[tokio::test]
862    async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
863        let settings = NetworkProxySettings {
864            enable_socks5: false,
865            proxy_url: "http://127.0.0.1:43128".to_string(),
866            socks_url: "http://127.0.0.1:43129".to_string(),
867            ..NetworkProxySettings::default()
868        };
869        let state = Arc::new(network_proxy_state_for_policy(settings));
870        let proxy = match NetworkProxy::builder().state(state).build().await {
871            Ok(proxy) => proxy,
872            Err(err) => {
873                if err
874                    .chain()
875                    .any(|cause| cause.to_string().contains("Operation not permitted"))
876                {
877                    return;
878                }
879                panic!("failed to build managed proxy: {err:#}");
880            }
881        };
882
883        assert!(proxy.http_addr.ip().is_loopback());
884        assert_ne!(proxy.http_addr.port(), 0);
885        assert_eq!(
886            proxy.socks_addr,
887            "127.0.0.1:43129".parse::<SocketAddr>().unwrap()
888        );
889        assert!(
890            proxy
891                .reserved_listeners
892                .as_ref()
893                .expect("managed builder should reserve listeners")
894                .take_socks()
895                .is_none()
896        );
897    }
898
899    #[cfg(target_os = "windows")]
900    #[test]
901    fn windows_managed_loopback_addr_clamps_non_loopback_inputs() {
902        assert_eq!(
903            windows_managed_loopback_addr("0.0.0.0:3128".parse::<SocketAddr>().unwrap()),
904            "127.0.0.1:3128".parse::<SocketAddr>().unwrap()
905        );
906        assert_eq!(
907            windows_managed_loopback_addr("[::]:8081".parse::<SocketAddr>().unwrap()),
908            "127.0.0.1:8081".parse::<SocketAddr>().unwrap()
909        );
910    }
911
912    #[cfg(target_os = "windows")]
913    #[test]
914    fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() {
915        let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
916        let busy_port = occupied.local_addr().unwrap().port();
917
918        let reserved = reserve_windows_managed_listeners(
919            SocketAddr::from(([127, 0, 0, 1], busy_port)),
920            SocketAddr::from(([127, 0, 0, 1], 48081)),
921            /*reserve_socks_listener*/ false,
922        )
923        .unwrap();
924
925        assert!(reserved.socks_listener.is_none());
926        assert!(
927            reserved
928                .http_listener
929                .local_addr()
930                .unwrap()
931                .ip()
932                .is_loopback()
933        );
934        assert_ne!(
935            reserved.http_listener.local_addr().unwrap().port(),
936            busy_port
937        );
938    }
939
940    #[test]
941    fn proxy_url_env_value_resolves_lowercase_aliases() {
942        let mut env = HashMap::new();
943        env.insert(
944            "http_proxy".to_string(),
945            "http://127.0.0.1:3128".to_string(),
946        );
947
948        assert_eq!(
949            proxy_url_env_value(&env, "HTTP_PROXY"),
950            Some("http://127.0.0.1:3128")
951        );
952    }
953
954    #[test]
955    fn has_proxy_url_env_vars_detects_lowercase_aliases() {
956        let mut env = HashMap::new();
957        env.insert(
958            "all_proxy".to_string(),
959            "socks5h://127.0.0.1:8081".to_string(),
960        );
961
962        assert_eq!(has_proxy_url_env_vars(&env), true);
963    }
964
965    #[test]
966    fn has_proxy_url_env_vars_detects_websocket_proxy_keys() {
967        let mut env = HashMap::new();
968        env.insert("wss_proxy".to_string(), "http://127.0.0.1:3128".to_string());
969
970        assert_eq!(has_proxy_url_env_vars(&env), true);
971    }
972
973    #[test]
974    fn apply_proxy_env_overrides_sets_common_tool_vars() {
975        let mut env = HashMap::new();
976        apply_proxy_env_overrides(
977            &mut env,
978            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
979            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
980            /*socks_enabled*/ true,
981            /*allow_local_binding*/ false,
982        );
983
984        assert_eq!(
985            env.get("HTTP_PROXY"),
986            Some(&"http://127.0.0.1:3128".to_string())
987        );
988        assert_eq!(
989            env.get("WS_PROXY"),
990            Some(&"http://127.0.0.1:3128".to_string())
991        );
992        assert_eq!(
993            env.get("WSS_PROXY"),
994            Some(&"http://127.0.0.1:3128".to_string())
995        );
996        assert_eq!(
997            env.get("npm_config_proxy"),
998            Some(&"http://127.0.0.1:3128".to_string())
999        );
1000        assert_eq!(
1001            env.get("ALL_PROXY"),
1002            Some(&"socks5h://127.0.0.1:8081".to_string())
1003        );
1004        assert_eq!(
1005            env.get("FTP_PROXY"),
1006            Some(&"socks5h://127.0.0.1:8081".to_string())
1007        );
1008        assert_eq!(
1009            env.get("NO_PROXY"),
1010            Some(&DEFAULT_NO_PROXY_VALUE.to_string())
1011        );
1012        let no_proxy = env.get("NO_PROXY").expect("NO_PROXY should be set");
1013        assert!(no_proxy.contains("10.0.0.0/8"));
1014        assert!(no_proxy.contains("172.16.0.0/12"));
1015        assert!(no_proxy.contains("192.168.0.0/16"));
1016        assert!(!no_proxy.contains("169.254.0.0/16"));
1017        assert_eq!(env.get(PROXY_ACTIVE_ENV_KEY), Some(&"1".to_string()));
1018        assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string()));
1019        assert_eq!(
1020            env.get(ELECTRON_GET_USE_PROXY_ENV_KEY),
1021            Some(&"true".to_string())
1022        );
1023        #[cfg(target_os = "macos")]
1024        assert_eq!(
1025            env.get(GIT_SSH_COMMAND_ENV_KEY),
1026            Some(
1027                &"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'"
1028                    .to_string()
1029            )
1030        );
1031        #[cfg(not(target_os = "macos"))]
1032        assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
1033    }
1034
1035    #[test]
1036    fn apply_proxy_env_overrides_sets_only_expected_env_keys() {
1037        let mut env = HashMap::new();
1038        apply_proxy_env_overrides(
1039            &mut env,
1040            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1041            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1042            /*socks_enabled*/ true,
1043            /*allow_local_binding*/ false,
1044        );
1045
1046        for key in env.keys() {
1047            let is_managed_git_ssh_key =
1048                cfg!(target_os = "macos") && key == GIT_SSH_COMMAND_ENV_KEY;
1049            assert!(
1050                PROXY_ENV_KEYS.contains(&key.as_str()) || is_managed_git_ssh_key,
1051                "proxy env writer set unexpected key: {key}"
1052            );
1053        }
1054    }
1055
1056    #[test]
1057    fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() {
1058        let mut env = HashMap::new();
1059        apply_proxy_env_overrides(
1060            &mut env,
1061            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1062            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1063            /*socks_enabled*/ false,
1064            /*allow_local_binding*/ true,
1065        );
1066
1067        assert_eq!(
1068            env.get("ALL_PROXY"),
1069            Some(&"http://127.0.0.1:3128".to_string())
1070        );
1071        assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"1".to_string()));
1072    }
1073
1074    #[test]
1075    fn apply_proxy_env_overrides_uses_plain_http_proxy_url() {
1076        let mut env = HashMap::new();
1077        apply_proxy_env_overrides(
1078            &mut env,
1079            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1080            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1081            /*socks_enabled*/ true,
1082            /*allow_local_binding*/ false,
1083        );
1084
1085        assert_eq!(
1086            env.get("HTTP_PROXY"),
1087            Some(&"http://127.0.0.1:3128".to_string())
1088        );
1089        assert_eq!(
1090            env.get("HTTPS_PROXY"),
1091            Some(&"http://127.0.0.1:3128".to_string())
1092        );
1093        assert_eq!(
1094            env.get("WS_PROXY"),
1095            Some(&"http://127.0.0.1:3128".to_string())
1096        );
1097        assert_eq!(
1098            env.get("WSS_PROXY"),
1099            Some(&"http://127.0.0.1:3128".to_string())
1100        );
1101        assert_eq!(
1102            env.get("ALL_PROXY"),
1103            Some(&"socks5h://127.0.0.1:8081".to_string())
1104        );
1105        #[cfg(target_os = "macos")]
1106        assert_eq!(
1107            env.get(GIT_SSH_COMMAND_ENV_KEY),
1108            Some(
1109                &"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'"
1110                    .to_string()
1111            )
1112        );
1113        #[cfg(not(target_os = "macos"))]
1114        assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
1115    }
1116
1117    #[cfg(target_os = "macos")]
1118    #[test]
1119    fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() {
1120        let mut env = HashMap::new();
1121        env.insert(
1122            GIT_SSH_COMMAND_ENV_KEY.to_string(),
1123            "ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(),
1124        );
1125        apply_proxy_env_overrides(
1126            &mut env,
1127            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1128            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1129            /*socks_enabled*/ true,
1130            /*allow_local_binding*/ false,
1131        );
1132
1133        assert_eq!(
1134            env.get(GIT_SSH_COMMAND_ENV_KEY),
1135            Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string())
1136        );
1137    }
1138
1139    #[cfg(target_os = "macos")]
1140    #[test]
1141    fn apply_proxy_env_overrides_preserves_unmarked_git_ssh_command_with_proxy_shape() {
1142        let mut env = HashMap::new();
1143        env.insert(
1144            GIT_SSH_COMMAND_ENV_KEY.to_string(),
1145            "ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string(),
1146        );
1147        apply_proxy_env_overrides(
1148            &mut env,
1149            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1150            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
1151            /*socks_enabled*/ true,
1152            /*allow_local_binding*/ false,
1153        );
1154
1155        assert_eq!(
1156            env.get(GIT_SSH_COMMAND_ENV_KEY),
1157            Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
1158        );
1159    }
1160
1161    #[cfg(target_os = "macos")]
1162    #[test]
1163    fn apply_proxy_env_overrides_refreshes_previous_codex_proxy_git_ssh_command() {
1164        let mut env = HashMap::new();
1165        env.insert(
1166            GIT_SSH_COMMAND_ENV_KEY.to_string(),
1167            codex_proxy_git_ssh_command(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081)),
1168        );
1169
1170        apply_proxy_env_overrides(
1171            &mut env,
1172            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 43128),
1173            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
1174            /*socks_enabled*/ true,
1175            /*allow_local_binding*/ false,
1176        );
1177
1178        assert_eq!(
1179            env.get(GIT_SSH_COMMAND_ENV_KEY),
1180            Some(&codex_proxy_git_ssh_command(SocketAddr::new(
1181                IpAddr::V4(Ipv4Addr::LOCALHOST),
1182                48081,
1183            )))
1184        );
1185    }
1186}