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::unix_socket_permissions_supported;
6use crate::socks5;
7use crate::state::NetworkProxyState;
8use anyhow::Context;
9use anyhow::Result;
10use clap::Parser;
11use std::collections::HashMap;
12use std::net::SocketAddr;
13use std::net::TcpListener as StdTcpListener;
14use std::sync::Arc;
15use std::sync::Mutex;
16use tokio::task::JoinHandle;
17use tracing::warn;
18
19#[derive(Debug, Clone, Parser)]
20#[command(name = "zerobox-network-proxy", about = "Codex network sandbox proxy")]
21pub struct Args {}
22
23#[derive(Debug)]
24struct ReservedListeners {
25    http: Mutex<Option<StdTcpListener>>,
26    socks: Mutex<Option<StdTcpListener>>,
27}
28
29impl ReservedListeners {
30    fn new(http: StdTcpListener, socks: Option<StdTcpListener>) -> Self {
31        Self {
32            http: Mutex::new(Some(http)),
33            socks: Mutex::new(socks),
34        }
35    }
36
37    fn take_http(&self) -> Option<StdTcpListener> {
38        let mut guard = self
39            .http
40            .lock()
41            .unwrap_or_else(std::sync::PoisonError::into_inner);
42        guard.take()
43    }
44
45    fn take_socks(&self) -> Option<StdTcpListener> {
46        let mut guard = self
47            .socks
48            .lock()
49            .unwrap_or_else(std::sync::PoisonError::into_inner);
50        guard.take()
51    }
52}
53
54struct ReservedListenerSet {
55    http_listener: StdTcpListener,
56    socks_listener: Option<StdTcpListener>,
57}
58
59impl ReservedListenerSet {
60    fn new(http_listener: StdTcpListener, socks_listener: Option<StdTcpListener>) -> Self {
61        Self {
62            http_listener,
63            socks_listener,
64        }
65    }
66
67    fn http_addr(&self) -> Result<SocketAddr> {
68        self.http_listener
69            .local_addr()
70            .context("failed to read reserved HTTP proxy address")
71    }
72
73    fn socks_addr(&self, default_addr: SocketAddr) -> Result<SocketAddr> {
74        self.socks_listener
75            .as_ref()
76            .map_or(Ok(default_addr), |listener| {
77                listener
78                    .local_addr()
79                    .context("failed to read reserved SOCKS5 proxy address")
80            })
81    }
82
83    fn into_reserved_listeners(self) -> Arc<ReservedListeners> {
84        Arc::new(ReservedListeners::new(
85            self.http_listener,
86            self.socks_listener,
87        ))
88    }
89}
90
91#[derive(Clone)]
92pub struct NetworkProxyBuilder {
93    state: Option<Arc<NetworkProxyState>>,
94    http_addr: Option<SocketAddr>,
95    socks_addr: Option<SocketAddr>,
96    managed_by_codex: bool,
97    policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
98    blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
99}
100
101impl Default for NetworkProxyBuilder {
102    fn default() -> Self {
103        Self {
104            state: None,
105            http_addr: None,
106            socks_addr: None,
107            managed_by_codex: true,
108            policy_decider: None,
109            blocked_request_observer: None,
110        }
111    }
112}
113
114impl NetworkProxyBuilder {
115    pub fn state(mut self, state: Arc<NetworkProxyState>) -> Self {
116        self.state = Some(state);
117        self
118    }
119
120    pub fn http_addr(mut self, addr: SocketAddr) -> Self {
121        self.http_addr = Some(addr);
122        self
123    }
124
125    pub fn socks_addr(mut self, addr: SocketAddr) -> Self {
126        self.socks_addr = Some(addr);
127        self
128    }
129
130    pub fn managed_by_codex(mut self, managed_by_codex: bool) -> Self {
131        self.managed_by_codex = managed_by_codex;
132        self
133    }
134
135    pub fn policy_decider<D>(mut self, decider: D) -> Self
136    where
137        D: NetworkPolicyDecider,
138    {
139        self.policy_decider = Some(Arc::new(decider));
140        self
141    }
142
143    pub fn policy_decider_arc(mut self, decider: Arc<dyn NetworkPolicyDecider>) -> Self {
144        self.policy_decider = Some(decider);
145        self
146    }
147
148    pub fn blocked_request_observer<O>(mut self, observer: O) -> Self
149    where
150        O: BlockedRequestObserver,
151    {
152        self.blocked_request_observer = Some(Arc::new(observer));
153        self
154    }
155
156    pub fn blocked_request_observer_arc(
157        mut self,
158        observer: Arc<dyn BlockedRequestObserver>,
159    ) -> Self {
160        self.blocked_request_observer = Some(observer);
161        self
162    }
163
164    pub async fn build(self) -> Result<NetworkProxy> {
165        let state = self.state.ok_or_else(|| {
166            anyhow::anyhow!(
167                "NetworkProxyBuilder requires a state; supply one via builder.state(...)"
168            )
169        })?;
170        state
171            .set_blocked_request_observer(self.blocked_request_observer.clone())
172            .await;
173        let current_cfg = state.current_cfg().await?;
174        let (requested_http_addr, requested_socks_addr, reserved_listeners) = if self
175            .managed_by_codex
176        {
177            let runtime = config::resolve_runtime(&current_cfg)?;
178            #[cfg(target_os = "windows")]
179            let (managed_http_addr, managed_socks_addr) = config::clamp_bind_addrs(
180                runtime.http_addr,
181                runtime.socks_addr,
182                &current_cfg.network,
183            );
184            #[cfg(target_os = "windows")]
185            let reserved = reserve_windows_managed_listeners(
186                managed_http_addr,
187                managed_socks_addr,
188                current_cfg.network.enable_socks5,
189            )
190            .context("reserve managed loopback proxy listeners")?;
191            #[cfg(not(target_os = "windows"))]
192            let reserved = reserve_loopback_ephemeral_listeners(current_cfg.network.enable_socks5)
193                .context("reserve managed loopback proxy listeners")?;
194            let http_addr = reserved.http_addr()?;
195            let socks_addr = reserved.socks_addr(runtime.socks_addr)?;
196            (
197                http_addr,
198                socks_addr,
199                Some(reserved.into_reserved_listeners()),
200            )
201        } else {
202            let runtime = config::resolve_runtime(&current_cfg)?;
203            (
204                self.http_addr.unwrap_or(runtime.http_addr),
205                self.socks_addr.unwrap_or(runtime.socks_addr),
206                None,
207            )
208        };
209
210        // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only.
211        let (http_addr, socks_addr) = config::clamp_bind_addrs(
212            requested_http_addr,
213            requested_socks_addr,
214            &current_cfg.network,
215        );
216
217        Ok(NetworkProxy {
218            state,
219            http_addr,
220            socks_addr,
221            socks_enabled: current_cfg.network.enable_socks5,
222            allow_local_binding: current_cfg.network.allow_local_binding,
223            allow_unix_sockets: current_cfg.network.allow_unix_sockets(),
224            dangerously_allow_all_unix_sockets: current_cfg
225                .network
226                .dangerously_allow_all_unix_sockets,
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(Clone)]
298pub struct NetworkProxy {
299    state: Arc<NetworkProxyState>,
300    http_addr: SocketAddr,
301    socks_addr: SocketAddr,
302    socks_enabled: bool,
303    allow_local_binding: bool,
304    allow_unix_sockets: Vec<String>,
305    dangerously_allow_all_unix_sockets: bool,
306    reserved_listeners: Option<Arc<ReservedListeners>>,
307    policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
308}
309
310impl std::fmt::Debug for NetworkProxy {
311    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
312        // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
313        // and may contain sensitive paths.
314        f.debug_struct("NetworkProxy")
315            .field("http_addr", &self.http_addr)
316            .field("socks_addr", &self.socks_addr)
317            .finish_non_exhaustive()
318    }
319}
320
321impl PartialEq for NetworkProxy {
322    fn eq(&self, other: &Self) -> bool {
323        self.http_addr == other.http_addr
324            && self.socks_addr == other.socks_addr
325            && self.allow_local_binding == other.allow_local_binding
326    }
327}
328
329impl Eq for NetworkProxy {}
330
331pub const PROXY_URL_ENV_KEYS: &[&str] = &[
332    "HTTP_PROXY",
333    "HTTPS_PROXY",
334    "WS_PROXY",
335    "WSS_PROXY",
336    "ALL_PROXY",
337    "FTP_PROXY",
338    "YARN_HTTP_PROXY",
339    "YARN_HTTPS_PROXY",
340    "NPM_CONFIG_HTTP_PROXY",
341    "NPM_CONFIG_HTTPS_PROXY",
342    "NPM_CONFIG_PROXY",
343    "BUNDLE_HTTP_PROXY",
344    "BUNDLE_HTTPS_PROXY",
345    "PIP_PROXY",
346    "DOCKER_HTTP_PROXY",
347    "DOCKER_HTTPS_PROXY",
348];
349
350pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"];
351pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
352
353const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
354const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"];
355
356pub const NO_PROXY_ENV_KEYS: &[&str] = &[
357    "NO_PROXY",
358    "no_proxy",
359    "npm_config_noproxy",
360    "NPM_CONFIG_NOPROXY",
361    "YARN_NO_PROXY",
362    "BUNDLE_NO_PROXY",
363];
364
365pub const DEFAULT_NO_PROXY_VALUE: &str = concat!(
366    "localhost,127.0.0.1,::1,",
367    "*.local,.local,",
368    "169.254.0.0/16,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
369);
370
371pub fn proxy_url_env_value<'a>(
372    env: &'a HashMap<String, String>,
373    canonical_key: &str,
374) -> Option<&'a str> {
375    if let Some(value) = env.get(canonical_key) {
376        return Some(value.as_str());
377    }
378    let lower_key = canonical_key.to_ascii_lowercase();
379    env.get(lower_key.as_str()).map(String::as_str)
380}
381
382pub fn has_proxy_url_env_vars(env: &HashMap<String, String>) -> bool {
383    PROXY_URL_ENV_KEYS
384        .iter()
385        .any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty()))
386}
387
388fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
389    for key in keys {
390        env.insert((*key).to_string(), value.to_string());
391    }
392}
393
394fn apply_proxy_env_overrides(
395    env: &mut HashMap<String, String>,
396    http_addr: SocketAddr,
397    socks_addr: SocketAddr,
398    socks_enabled: bool,
399    allow_local_binding: bool,
400) {
401    let http_proxy_url = format!("http://{http_addr}");
402    let socks_proxy_url = format!("socks5h://{socks_addr}");
403    env.insert(
404        ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
405        if allow_local_binding {
406            "1".to_string()
407        } else {
408            "0".to_string()
409        },
410    );
411
412    // HTTP-based clients are best served by explicit HTTP proxy URLs.
413    set_env_keys(
414        env,
415        &[
416            "HTTP_PROXY",
417            "HTTPS_PROXY",
418            "http_proxy",
419            "https_proxy",
420            "YARN_HTTP_PROXY",
421            "YARN_HTTPS_PROXY",
422            "npm_config_http_proxy",
423            "npm_config_https_proxy",
424            "npm_config_proxy",
425            "NPM_CONFIG_HTTP_PROXY",
426            "NPM_CONFIG_HTTPS_PROXY",
427            "NPM_CONFIG_PROXY",
428            "BUNDLE_HTTP_PROXY",
429            "BUNDLE_HTTPS_PROXY",
430            "PIP_PROXY",
431            "DOCKER_HTTP_PROXY",
432            "DOCKER_HTTPS_PROXY",
433        ],
434        &http_proxy_url,
435    );
436    // Some websocket clients look for dedicated WS/WSS proxy environment variables instead of
437    // HTTP(S)_PROXY. Keep them aligned with the managed HTTP proxy endpoint.
438    set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);
439
440    // Keep local/private targets direct so local IPC and metadata endpoints avoid the proxy.
441    set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
442
443    env.insert("ELECTRON_GET_USE_PROXY".to_string(), "true".to_string());
444
445    // Node.js 22.21+ / 24+: make built-in fetch() respect HTTP_PROXY/HTTPS_PROXY
446    env.insert("NODE_USE_ENV_PROXY".to_string(), "1".to_string());
447
448    // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if
449    // those vars contain SOCKS URLs. We only switch ALL_PROXY here.
450    //
451    if socks_enabled {
452        set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
453        set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
454    } else {
455        set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url);
456        set_env_keys(env, FTP_PROXY_ENV_KEYS, &http_proxy_url);
457    }
458
459    #[cfg(target_os = "macos")]
460    if socks_enabled {
461        // Preserve existing SSH wrappers (for example: Secretive/Teleport setups)
462        // and only provide a SOCKS ProxyCommand fallback when one is not present.
463        env.entry("GIT_SSH_COMMAND".to_string())
464            .or_insert_with(|| format!("ssh -o ProxyCommand='nc -X 5 -x {socks_addr} %h %p'"));
465    }
466}
467
468impl NetworkProxy {
469    pub fn builder() -> NetworkProxyBuilder {
470        NetworkProxyBuilder::default()
471    }
472
473    pub fn http_addr(&self) -> SocketAddr {
474        self.http_addr
475    }
476
477    pub fn socks_addr(&self) -> SocketAddr {
478        self.socks_addr
479    }
480
481    pub async fn current_cfg(&self) -> Result<config::NetworkProxyConfig> {
482        self.state.current_cfg().await
483    }
484
485    pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
486        self.state.add_allowed_domain(host).await
487    }
488
489    pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
490        self.state.add_denied_domain(host).await
491    }
492
493    pub fn allow_local_binding(&self) -> bool {
494        self.allow_local_binding
495    }
496
497    pub fn allow_unix_sockets(&self) -> &[String] {
498        &self.allow_unix_sockets
499    }
500
501    pub fn dangerously_allow_all_unix_sockets(&self) -> bool {
502        self.dangerously_allow_all_unix_sockets
503    }
504
505    pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
506        // Enforce proxying for child processes. We intentionally override existing values so
507        // command-level environment cannot bypass the managed proxy endpoint.
508        apply_proxy_env_overrides(
509            env,
510            self.http_addr,
511            self.socks_addr,
512            self.socks_enabled,
513            self.allow_local_binding,
514        );
515    }
516
517    pub async fn run(&self) -> Result<NetworkProxyHandle> {
518        let current_cfg = self.state.current_cfg().await?;
519        if !current_cfg.network.enabled {
520            warn!("network.enabled is false; skipping proxy listeners");
521            return Ok(NetworkProxyHandle::noop());
522        }
523
524        if !unix_socket_permissions_supported() {
525            warn!(
526                "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform"
527            );
528        }
529
530        let reserved_listeners = self.reserved_listeners.as_ref();
531        let http_listener = reserved_listeners.and_then(|listeners| listeners.take_http());
532        let socks_listener = reserved_listeners.and_then(|listeners| listeners.take_socks());
533
534        let http_state = self.state.clone();
535        let http_decider = self.policy_decider.clone();
536        let http_addr = self.http_addr;
537        let http_task = tokio::spawn(async move {
538            match http_listener {
539                Some(listener) => {
540                    http_proxy::run_http_proxy_with_std_listener(http_state, listener, http_decider)
541                        .await
542                }
543                None => http_proxy::run_http_proxy(http_state, http_addr, http_decider).await,
544            }
545        });
546
547        let socks_task = if current_cfg.network.enable_socks5 {
548            let socks_state = self.state.clone();
549            let socks_decider = self.policy_decider.clone();
550            let socks_addr = self.socks_addr;
551            let enable_socks5_udp = current_cfg.network.enable_socks5_udp;
552            Some(tokio::spawn(async move {
553                match socks_listener {
554                    Some(listener) => {
555                        socks5::run_socks5_with_std_listener(
556                            socks_state,
557                            listener,
558                            socks_decider,
559                            enable_socks5_udp,
560                        )
561                        .await
562                    }
563                    None => {
564                        socks5::run_socks5(
565                            socks_state,
566                            socks_addr,
567                            socks_decider,
568                            enable_socks5_udp,
569                        )
570                        .await
571                    }
572                }
573            }))
574        } else {
575            None
576        };
577
578        Ok(NetworkProxyHandle {
579            http_task: Some(http_task),
580            socks_task,
581            completed: false,
582        })
583    }
584}
585
586pub struct NetworkProxyHandle {
587    http_task: Option<JoinHandle<Result<()>>>,
588    socks_task: Option<JoinHandle<Result<()>>>,
589    completed: bool,
590}
591
592impl NetworkProxyHandle {
593    fn noop() -> Self {
594        Self {
595            http_task: Some(tokio::spawn(async { Ok(()) })),
596            socks_task: None,
597            completed: true,
598        }
599    }
600
601    pub async fn wait(mut self) -> Result<()> {
602        let http_task = self.http_task.take().context("missing http proxy task")?;
603        let socks_task = self.socks_task.take();
604        let http_result = http_task.await;
605        let socks_result = match socks_task {
606            Some(task) => Some(task.await),
607            None => None,
608        };
609        self.completed = true;
610        http_result??;
611        if let Some(socks_result) = socks_result {
612            socks_result??;
613        }
614        Ok(())
615    }
616
617    pub async fn shutdown(mut self) -> Result<()> {
618        abort_tasks(self.http_task.take(), self.socks_task.take()).await;
619        self.completed = true;
620        Ok(())
621    }
622}
623
624async fn abort_task(task: Option<JoinHandle<Result<()>>>) {
625    if let Some(task) = task {
626        task.abort();
627        let _ = task.await;
628    }
629}
630
631async fn abort_tasks(
632    http_task: Option<JoinHandle<Result<()>>>,
633    socks_task: Option<JoinHandle<Result<()>>>,
634) {
635    abort_task(http_task).await;
636    abort_task(socks_task).await;
637}
638
639impl Drop for NetworkProxyHandle {
640    fn drop(&mut self) {
641        if self.completed {
642            return;
643        }
644        let http_task = self.http_task.take();
645        let socks_task = self.socks_task.take();
646        tokio::spawn(async move {
647            abort_tasks(http_task, socks_task).await;
648        });
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655    use crate::config::NetworkProxySettings;
656    use crate::state::network_proxy_state_for_policy;
657    use pretty_assertions::assert_eq;
658    use std::net::IpAddr;
659    use std::net::Ipv4Addr;
660
661    #[tokio::test]
662    async fn managed_proxy_builder_uses_loopback_ports() {
663        let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
664            proxy_url: "http://127.0.0.1:43128".to_string(),
665            socks_url: "http://127.0.0.1:48081".to_string(),
666            ..NetworkProxySettings::default()
667        }));
668        let proxy = match NetworkProxy::builder().state(state).build().await {
669            Ok(proxy) => proxy,
670            Err(err) => {
671                if err
672                    .chain()
673                    .any(|cause| cause.to_string().contains("Operation not permitted"))
674                {
675                    return;
676                }
677                panic!("failed to build managed proxy: {err:#}");
678            }
679        };
680
681        assert!(proxy.http_addr.ip().is_loopback());
682        assert!(proxy.socks_addr.ip().is_loopback());
683        #[cfg(target_os = "windows")]
684        {
685            assert_eq!(
686                proxy.http_addr,
687                "127.0.0.1:43128".parse::<SocketAddr>().unwrap()
688            );
689            assert_eq!(
690                proxy.socks_addr,
691                "127.0.0.1:48081".parse::<SocketAddr>().unwrap()
692            );
693        }
694        #[cfg(not(target_os = "windows"))]
695        {
696            assert_ne!(proxy.http_addr.port(), 0);
697            assert_ne!(proxy.socks_addr.port(), 0);
698        }
699    }
700
701    #[tokio::test]
702    async fn non_codex_managed_proxy_builder_uses_configured_ports() {
703        let settings = NetworkProxySettings {
704            proxy_url: "http://127.0.0.1:43128".to_string(),
705            socks_url: "http://127.0.0.1:48081".to_string(),
706            ..NetworkProxySettings::default()
707        };
708        let state = Arc::new(network_proxy_state_for_policy(settings));
709        let proxy = NetworkProxy::builder()
710            .state(state)
711            .managed_by_codex(/*managed_by_codex*/ false)
712            .build()
713            .await
714            .unwrap();
715
716        assert_eq!(
717            proxy.http_addr,
718            "127.0.0.1:43128".parse::<SocketAddr>().unwrap()
719        );
720        assert_eq!(
721            proxy.socks_addr,
722            "127.0.0.1:48081".parse::<SocketAddr>().unwrap()
723        );
724    }
725
726    #[tokio::test]
727    async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
728        let settings = NetworkProxySettings {
729            enable_socks5: false,
730            proxy_url: "http://127.0.0.1:43128".to_string(),
731            socks_url: "http://127.0.0.1:43129".to_string(),
732            ..NetworkProxySettings::default()
733        };
734        let state = Arc::new(network_proxy_state_for_policy(settings));
735        let proxy = match NetworkProxy::builder().state(state).build().await {
736            Ok(proxy) => proxy,
737            Err(err) => {
738                if err
739                    .chain()
740                    .any(|cause| cause.to_string().contains("Operation not permitted"))
741                {
742                    return;
743                }
744                panic!("failed to build managed proxy: {err:#}");
745            }
746        };
747
748        assert!(proxy.http_addr.ip().is_loopback());
749        assert_ne!(proxy.http_addr.port(), 0);
750        assert_eq!(
751            proxy.socks_addr,
752            "127.0.0.1:43129".parse::<SocketAddr>().unwrap()
753        );
754        assert!(
755            proxy
756                .reserved_listeners
757                .as_ref()
758                .expect("managed builder should reserve listeners")
759                .take_socks()
760                .is_none()
761        );
762    }
763
764    #[cfg(target_os = "windows")]
765    #[test]
766    fn windows_managed_loopback_addr_clamps_non_loopback_inputs() {
767        assert_eq!(
768            windows_managed_loopback_addr("0.0.0.0:3128".parse::<SocketAddr>().unwrap()),
769            "127.0.0.1:3128".parse::<SocketAddr>().unwrap()
770        );
771        assert_eq!(
772            windows_managed_loopback_addr("[::]:8081".parse::<SocketAddr>().unwrap()),
773            "127.0.0.1:8081".parse::<SocketAddr>().unwrap()
774        );
775    }
776
777    #[cfg(target_os = "windows")]
778    #[test]
779    fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() {
780        let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
781        let busy_port = occupied.local_addr().unwrap().port();
782
783        let reserved = reserve_windows_managed_listeners(
784            SocketAddr::from(([127, 0, 0, 1], busy_port)),
785            SocketAddr::from(([127, 0, 0, 1], 48081)),
786            /*reserve_socks_listener*/ false,
787        )
788        .unwrap();
789
790        assert!(reserved.socks_listener.is_none());
791        assert!(
792            reserved
793                .http_listener
794                .local_addr()
795                .unwrap()
796                .ip()
797                .is_loopback()
798        );
799        assert_ne!(
800            reserved.http_listener.local_addr().unwrap().port(),
801            busy_port
802        );
803    }
804
805    #[test]
806    fn proxy_url_env_value_resolves_lowercase_aliases() {
807        let mut env = HashMap::new();
808        env.insert(
809            "http_proxy".to_string(),
810            "http://127.0.0.1:3128".to_string(),
811        );
812
813        assert_eq!(
814            proxy_url_env_value(&env, "HTTP_PROXY"),
815            Some("http://127.0.0.1:3128")
816        );
817    }
818
819    #[test]
820    fn has_proxy_url_env_vars_detects_lowercase_aliases() {
821        let mut env = HashMap::new();
822        env.insert(
823            "all_proxy".to_string(),
824            "socks5h://127.0.0.1:8081".to_string(),
825        );
826
827        assert_eq!(has_proxy_url_env_vars(&env), true);
828    }
829
830    #[test]
831    fn has_proxy_url_env_vars_detects_websocket_proxy_keys() {
832        let mut env = HashMap::new();
833        env.insert("wss_proxy".to_string(), "http://127.0.0.1:3128".to_string());
834
835        assert_eq!(has_proxy_url_env_vars(&env), true);
836    }
837
838    #[test]
839    fn apply_proxy_env_overrides_sets_common_tool_vars() {
840        let mut env = HashMap::new();
841        apply_proxy_env_overrides(
842            &mut env,
843            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
844            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
845            /*socks_enabled*/ true,
846            /*allow_local_binding*/ false,
847        );
848
849        assert_eq!(
850            env.get("HTTP_PROXY"),
851            Some(&"http://127.0.0.1:3128".to_string())
852        );
853        assert_eq!(
854            env.get("WS_PROXY"),
855            Some(&"http://127.0.0.1:3128".to_string())
856        );
857        assert_eq!(
858            env.get("WSS_PROXY"),
859            Some(&"http://127.0.0.1:3128".to_string())
860        );
861        assert_eq!(
862            env.get("npm_config_proxy"),
863            Some(&"http://127.0.0.1:3128".to_string())
864        );
865        assert_eq!(
866            env.get("ALL_PROXY"),
867            Some(&"socks5h://127.0.0.1:8081".to_string())
868        );
869        assert_eq!(
870            env.get("FTP_PROXY"),
871            Some(&"socks5h://127.0.0.1:8081".to_string())
872        );
873        assert_eq!(
874            env.get("NO_PROXY"),
875            Some(&DEFAULT_NO_PROXY_VALUE.to_string())
876        );
877        assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string()));
878        assert_eq!(env.get("ELECTRON_GET_USE_PROXY"), Some(&"true".to_string()));
879        #[cfg(target_os = "macos")]
880        assert_eq!(
881            env.get("GIT_SSH_COMMAND"),
882            Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
883        );
884        #[cfg(not(target_os = "macos"))]
885        assert_eq!(env.get("GIT_SSH_COMMAND"), None);
886    }
887
888    #[test]
889    fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() {
890        let mut env = HashMap::new();
891        apply_proxy_env_overrides(
892            &mut env,
893            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
894            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
895            /*socks_enabled*/ false,
896            /*allow_local_binding*/ true,
897        );
898
899        assert_eq!(
900            env.get("ALL_PROXY"),
901            Some(&"http://127.0.0.1:3128".to_string())
902        );
903        assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"1".to_string()));
904    }
905
906    #[test]
907    fn apply_proxy_env_overrides_uses_plain_http_proxy_url() {
908        let mut env = HashMap::new();
909        apply_proxy_env_overrides(
910            &mut env,
911            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
912            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
913            /*socks_enabled*/ true,
914            /*allow_local_binding*/ false,
915        );
916
917        assert_eq!(
918            env.get("HTTP_PROXY"),
919            Some(&"http://127.0.0.1:3128".to_string())
920        );
921        assert_eq!(
922            env.get("HTTPS_PROXY"),
923            Some(&"http://127.0.0.1:3128".to_string())
924        );
925        assert_eq!(
926            env.get("WS_PROXY"),
927            Some(&"http://127.0.0.1:3128".to_string())
928        );
929        assert_eq!(
930            env.get("WSS_PROXY"),
931            Some(&"http://127.0.0.1:3128".to_string())
932        );
933        assert_eq!(
934            env.get("ALL_PROXY"),
935            Some(&"socks5h://127.0.0.1:8081".to_string())
936        );
937        #[cfg(target_os = "macos")]
938        assert_eq!(
939            env.get("GIT_SSH_COMMAND"),
940            Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
941        );
942        #[cfg(not(target_os = "macos"))]
943        assert_eq!(env.get("GIT_SSH_COMMAND"), None);
944    }
945
946    #[cfg(target_os = "macos")]
947    #[test]
948    fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() {
949        let mut env = HashMap::new();
950        env.insert(
951            "GIT_SSH_COMMAND".to_string(),
952            "ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(),
953        );
954        apply_proxy_env_overrides(
955            &mut env,
956            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
957            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
958            /*socks_enabled*/ true,
959            /*allow_local_binding*/ false,
960        );
961
962        assert_eq!(
963            env.get("GIT_SSH_COMMAND"),
964            Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string())
965        );
966    }
967}