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 ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
367
368const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
369const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"];
370
371pub const NO_PROXY_ENV_KEYS: &[&str] = &[
372    "NO_PROXY",
373    "no_proxy",
374    "npm_config_noproxy",
375    "NPM_CONFIG_NOPROXY",
376    "YARN_NO_PROXY",
377    "BUNDLE_NO_PROXY",
378];
379
380pub const DEFAULT_NO_PROXY_VALUE: &str = concat!(
381    "localhost,127.0.0.1,::1,",
382    "169.254.0.0/16,",
383    "10.0.0.0/8,",
384    "172.16.0.0/12,",
385    "192.168.0.0/16"
386);
387
388pub fn proxy_url_env_value<'a>(
389    env: &'a HashMap<String, String>,
390    canonical_key: &str,
391) -> Option<&'a str> {
392    if let Some(value) = env.get(canonical_key) {
393        return Some(value.as_str());
394    }
395    let lower_key = canonical_key.to_ascii_lowercase();
396    env.get(lower_key.as_str()).map(String::as_str)
397}
398
399pub fn has_proxy_url_env_vars(env: &HashMap<String, String>) -> bool {
400    PROXY_URL_ENV_KEYS
401        .iter()
402        .any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty()))
403}
404
405fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
406    for key in keys {
407        env.insert((*key).to_string(), value.to_string());
408    }
409}
410
411fn apply_proxy_env_overrides(
412    env: &mut HashMap<String, String>,
413    http_addr: SocketAddr,
414    socks_addr: SocketAddr,
415    socks_enabled: bool,
416    allow_local_binding: bool,
417) {
418    let http_proxy_url = format!("http://{http_addr}");
419    let socks_proxy_url = format!("socks5h://{socks_addr}");
420    env.insert(
421        ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
422        if allow_local_binding {
423            "1".to_string()
424        } else {
425            "0".to_string()
426        },
427    );
428
429    // HTTP-based clients are best served by explicit HTTP proxy URLs.
430    set_env_keys(
431        env,
432        &[
433            "HTTP_PROXY",
434            "HTTPS_PROXY",
435            "http_proxy",
436            "https_proxy",
437            "YARN_HTTP_PROXY",
438            "YARN_HTTPS_PROXY",
439            "npm_config_http_proxy",
440            "npm_config_https_proxy",
441            "npm_config_proxy",
442            "NPM_CONFIG_HTTP_PROXY",
443            "NPM_CONFIG_HTTPS_PROXY",
444            "NPM_CONFIG_PROXY",
445            "BUNDLE_HTTP_PROXY",
446            "BUNDLE_HTTPS_PROXY",
447            "PIP_PROXY",
448            "DOCKER_HTTP_PROXY",
449            "DOCKER_HTTPS_PROXY",
450        ],
451        &http_proxy_url,
452    );
453    // Some websocket clients look for dedicated WS/WSS proxy environment variables instead of
454    // HTTP(S)_PROXY. Keep them aligned with the managed HTTP proxy endpoint.
455    set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);
456
457    // Keep loopback and IP-literal private targets direct so local IPC/LAN access avoids the proxy.
458    // Do not include hostname suffixes here: those can force clients to resolve internal names
459    // locally instead of letting the proxy resolve them.
460    set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
461
462    env.insert("ELECTRON_GET_USE_PROXY".to_string(), "true".to_string());
463
464    // Node.js 22.21+ / 24+: make built-in fetch() respect HTTP_PROXY/HTTPS_PROXY
465    env.insert("NODE_USE_ENV_PROXY".to_string(), "1".to_string());
466
467    // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if
468    // those vars contain SOCKS URLs. We only switch ALL_PROXY here.
469    //
470    if socks_enabled {
471        set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
472        set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
473    } else {
474        set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url);
475        set_env_keys(env, FTP_PROXY_ENV_KEYS, &http_proxy_url);
476    }
477
478    #[cfg(target_os = "macos")]
479    if socks_enabled {
480        // Preserve existing SSH wrappers (for example: Secretive/Teleport setups)
481        // and only provide a SOCKS ProxyCommand fallback when one is not present.
482        env.entry("GIT_SSH_COMMAND".to_string())
483            .or_insert_with(|| format!("ssh -o ProxyCommand='nc -X 5 -x {socks_addr} %h %p'"));
484    }
485}
486
487impl NetworkProxy {
488    pub fn builder() -> NetworkProxyBuilder {
489        NetworkProxyBuilder::default()
490    }
491
492    pub fn http_addr(&self) -> SocketAddr {
493        self.http_addr
494    }
495
496    pub fn socks_addr(&self) -> SocketAddr {
497        self.socks_addr
498    }
499
500    pub async fn current_cfg(&self) -> Result<config::NetworkProxyConfig> {
501        self.state.current_cfg().await
502    }
503
504    pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
505        self.state.add_allowed_domain(host).await
506    }
507
508    pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
509        self.state.add_denied_domain(host).await
510    }
511
512    pub fn allow_local_binding(&self) -> bool {
513        self.runtime_settings().allow_local_binding
514    }
515
516    pub fn allow_unix_sockets(&self) -> Arc<[String]> {
517        self.runtime_settings().allow_unix_sockets
518    }
519
520    pub fn dangerously_allow_all_unix_sockets(&self) -> bool {
521        self.runtime_settings().dangerously_allow_all_unix_sockets
522    }
523
524    pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
525        let allow_local_binding = self.allow_local_binding();
526        // Enforce proxying for child processes. We intentionally override existing values so
527        // command-level environment cannot bypass the managed proxy endpoint.
528        apply_proxy_env_overrides(
529            env,
530            self.http_addr,
531            self.socks_addr,
532            self.socks_enabled,
533            allow_local_binding,
534        );
535    }
536
537    pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> {
538        let current_cfg = self.state.current_cfg().await?;
539        anyhow::ensure!(
540            new_state.config.network.enabled == current_cfg.network.enabled,
541            "cannot update network.enabled on a running proxy"
542        );
543        anyhow::ensure!(
544            new_state.config.network.proxy_url == current_cfg.network.proxy_url,
545            "cannot update network.proxy_url on a running proxy"
546        );
547        anyhow::ensure!(
548            new_state.config.network.socks_url == current_cfg.network.socks_url,
549            "cannot update network.socks_url on a running proxy"
550        );
551        anyhow::ensure!(
552            new_state.config.network.enable_socks5 == current_cfg.network.enable_socks5,
553            "cannot update network.enable_socks5 on a running proxy"
554        );
555        anyhow::ensure!(
556            new_state.config.network.enable_socks5_udp == current_cfg.network.enable_socks5_udp,
557            "cannot update network.enable_socks5_udp on a running proxy"
558        );
559
560        let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config);
561        self.state.replace_config_state(new_state).await?;
562        let mut guard = self
563            .runtime_settings
564            .write()
565            .unwrap_or_else(std::sync::PoisonError::into_inner);
566        *guard = settings;
567        Ok(())
568    }
569
570    fn runtime_settings(&self) -> NetworkProxyRuntimeSettings {
571        self.runtime_settings
572            .read()
573            .unwrap_or_else(std::sync::PoisonError::into_inner)
574            .clone()
575    }
576
577    pub async fn run(&self) -> Result<NetworkProxyHandle> {
578        let current_cfg = self.state.current_cfg().await?;
579        if !current_cfg.network.enabled {
580            warn!("network.enabled is false; skipping proxy listeners");
581            return Ok(NetworkProxyHandle::noop());
582        }
583
584        if !unix_socket_permissions_supported() {
585            warn!(
586                "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform"
587            );
588        }
589
590        let reserved_listeners = self.reserved_listeners.as_ref();
591        let http_listener = reserved_listeners.and_then(|listeners| listeners.take_http());
592        let socks_listener = reserved_listeners.and_then(|listeners| listeners.take_socks());
593
594        let http_state = self.state.clone();
595        let http_decider = self.policy_decider.clone();
596        let http_addr = self.http_addr;
597        let http_task = tokio::spawn(async move {
598            match http_listener {
599                Some(listener) => {
600                    http_proxy::run_http_proxy_with_std_listener(http_state, listener, http_decider)
601                        .await
602                }
603                None => http_proxy::run_http_proxy(http_state, http_addr, http_decider).await,
604            }
605        });
606
607        let socks_task = if current_cfg.network.enable_socks5 {
608            let socks_state = self.state.clone();
609            let socks_decider = self.policy_decider.clone();
610            let socks_addr = self.socks_addr;
611            let enable_socks5_udp = current_cfg.network.enable_socks5_udp;
612            Some(tokio::spawn(async move {
613                match socks_listener {
614                    Some(listener) => {
615                        socks5::run_socks5_with_std_listener(
616                            socks_state,
617                            listener,
618                            socks_decider,
619                            enable_socks5_udp,
620                        )
621                        .await
622                    }
623                    None => {
624                        socks5::run_socks5(
625                            socks_state,
626                            socks_addr,
627                            socks_decider,
628                            enable_socks5_udp,
629                        )
630                        .await
631                    }
632                }
633            }))
634        } else {
635            None
636        };
637
638        Ok(NetworkProxyHandle {
639            http_task: Some(http_task),
640            socks_task,
641            completed: false,
642        })
643    }
644}
645
646pub struct NetworkProxyHandle {
647    http_task: Option<JoinHandle<Result<()>>>,
648    socks_task: Option<JoinHandle<Result<()>>>,
649    completed: bool,
650}
651
652impl NetworkProxyHandle {
653    fn noop() -> Self {
654        Self {
655            http_task: Some(tokio::spawn(async { Ok(()) })),
656            socks_task: None,
657            completed: true,
658        }
659    }
660
661    pub async fn wait(mut self) -> Result<()> {
662        let http_task = self.http_task.take().context("missing http proxy task")?;
663        let socks_task = self.socks_task.take();
664        let http_result = http_task.await;
665        let socks_result = match socks_task {
666            Some(task) => Some(task.await),
667            None => None,
668        };
669        self.completed = true;
670        http_result??;
671        if let Some(socks_result) = socks_result {
672            socks_result??;
673        }
674        Ok(())
675    }
676
677    pub async fn shutdown(mut self) -> Result<()> {
678        abort_tasks(self.http_task.take(), self.socks_task.take()).await;
679        self.completed = true;
680        Ok(())
681    }
682}
683
684async fn abort_task(task: Option<JoinHandle<Result<()>>>) {
685    if let Some(task) = task {
686        task.abort();
687        let _ = task.await;
688    }
689}
690
691async fn abort_tasks(
692    http_task: Option<JoinHandle<Result<()>>>,
693    socks_task: Option<JoinHandle<Result<()>>>,
694) {
695    abort_task(http_task).await;
696    abort_task(socks_task).await;
697}
698
699impl Drop for NetworkProxyHandle {
700    fn drop(&mut self) {
701        if self.completed {
702            return;
703        }
704        let http_task = self.http_task.take();
705        let socks_task = self.socks_task.take();
706        tokio::spawn(async move {
707            abort_tasks(http_task, socks_task).await;
708        });
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::config::NetworkProxySettings;
716    use crate::state::network_proxy_state_for_policy;
717    use pretty_assertions::assert_eq;
718    use std::net::IpAddr;
719    use std::net::Ipv4Addr;
720
721    #[tokio::test]
722    async fn managed_proxy_builder_uses_loopback_ports() {
723        let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
724            proxy_url: "http://127.0.0.1:43128".to_string(),
725            socks_url: "http://127.0.0.1:48081".to_string(),
726            ..NetworkProxySettings::default()
727        }));
728        let proxy = match NetworkProxy::builder().state(state).build().await {
729            Ok(proxy) => proxy,
730            Err(err) => {
731                if err
732                    .chain()
733                    .any(|cause| cause.to_string().contains("Operation not permitted"))
734                {
735                    return;
736                }
737                panic!("failed to build managed proxy: {err:#}");
738            }
739        };
740
741        assert!(proxy.http_addr.ip().is_loopback());
742        assert!(proxy.socks_addr.ip().is_loopback());
743        #[cfg(target_os = "windows")]
744        {
745            assert_eq!(
746                proxy.http_addr,
747                "127.0.0.1:43128".parse::<SocketAddr>().unwrap()
748            );
749            assert_eq!(
750                proxy.socks_addr,
751                "127.0.0.1:48081".parse::<SocketAddr>().unwrap()
752            );
753        }
754        #[cfg(not(target_os = "windows"))]
755        {
756            assert_ne!(proxy.http_addr.port(), 0);
757            assert_ne!(proxy.socks_addr.port(), 0);
758        }
759    }
760
761    #[tokio::test]
762    async fn non_codex_managed_proxy_builder_uses_configured_ports() {
763        let settings = NetworkProxySettings {
764            proxy_url: "http://127.0.0.1:43128".to_string(),
765            socks_url: "http://127.0.0.1:48081".to_string(),
766            ..NetworkProxySettings::default()
767        };
768        let state = Arc::new(network_proxy_state_for_policy(settings));
769        let proxy = NetworkProxy::builder()
770            .state(state)
771            .managed_by_codex(/*managed_by_codex*/ false)
772            .build()
773            .await
774            .unwrap();
775
776        assert_eq!(
777            proxy.http_addr,
778            "127.0.0.1:43128".parse::<SocketAddr>().unwrap()
779        );
780        assert_eq!(
781            proxy.socks_addr,
782            "127.0.0.1:48081".parse::<SocketAddr>().unwrap()
783        );
784    }
785
786    #[tokio::test]
787    async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
788        let settings = NetworkProxySettings {
789            enable_socks5: false,
790            proxy_url: "http://127.0.0.1:43128".to_string(),
791            socks_url: "http://127.0.0.1:43129".to_string(),
792            ..NetworkProxySettings::default()
793        };
794        let state = Arc::new(network_proxy_state_for_policy(settings));
795        let proxy = match NetworkProxy::builder().state(state).build().await {
796            Ok(proxy) => proxy,
797            Err(err) => {
798                if err
799                    .chain()
800                    .any(|cause| cause.to_string().contains("Operation not permitted"))
801                {
802                    return;
803                }
804                panic!("failed to build managed proxy: {err:#}");
805            }
806        };
807
808        assert!(proxy.http_addr.ip().is_loopback());
809        assert_ne!(proxy.http_addr.port(), 0);
810        assert_eq!(
811            proxy.socks_addr,
812            "127.0.0.1:43129".parse::<SocketAddr>().unwrap()
813        );
814        assert!(
815            proxy
816                .reserved_listeners
817                .as_ref()
818                .expect("managed builder should reserve listeners")
819                .take_socks()
820                .is_none()
821        );
822    }
823
824    #[cfg(target_os = "windows")]
825    #[test]
826    fn windows_managed_loopback_addr_clamps_non_loopback_inputs() {
827        assert_eq!(
828            windows_managed_loopback_addr("0.0.0.0:3128".parse::<SocketAddr>().unwrap()),
829            "127.0.0.1:3128".parse::<SocketAddr>().unwrap()
830        );
831        assert_eq!(
832            windows_managed_loopback_addr("[::]:8081".parse::<SocketAddr>().unwrap()),
833            "127.0.0.1:8081".parse::<SocketAddr>().unwrap()
834        );
835    }
836
837    #[cfg(target_os = "windows")]
838    #[test]
839    fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() {
840        let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
841        let busy_port = occupied.local_addr().unwrap().port();
842
843        let reserved = reserve_windows_managed_listeners(
844            SocketAddr::from(([127, 0, 0, 1], busy_port)),
845            SocketAddr::from(([127, 0, 0, 1], 48081)),
846            /*reserve_socks_listener*/ false,
847        )
848        .unwrap();
849
850        assert!(reserved.socks_listener.is_none());
851        assert!(
852            reserved
853                .http_listener
854                .local_addr()
855                .unwrap()
856                .ip()
857                .is_loopback()
858        );
859        assert_ne!(
860            reserved.http_listener.local_addr().unwrap().port(),
861            busy_port
862        );
863    }
864
865    #[test]
866    fn proxy_url_env_value_resolves_lowercase_aliases() {
867        let mut env = HashMap::new();
868        env.insert(
869            "http_proxy".to_string(),
870            "http://127.0.0.1:3128".to_string(),
871        );
872
873        assert_eq!(
874            proxy_url_env_value(&env, "HTTP_PROXY"),
875            Some("http://127.0.0.1:3128")
876        );
877    }
878
879    #[test]
880    fn has_proxy_url_env_vars_detects_lowercase_aliases() {
881        let mut env = HashMap::new();
882        env.insert(
883            "all_proxy".to_string(),
884            "socks5h://127.0.0.1:8081".to_string(),
885        );
886
887        assert_eq!(has_proxy_url_env_vars(&env), true);
888    }
889
890    #[test]
891    fn has_proxy_url_env_vars_detects_websocket_proxy_keys() {
892        let mut env = HashMap::new();
893        env.insert("wss_proxy".to_string(), "http://127.0.0.1:3128".to_string());
894
895        assert_eq!(has_proxy_url_env_vars(&env), true);
896    }
897
898    #[test]
899    fn apply_proxy_env_overrides_sets_common_tool_vars() {
900        let mut env = HashMap::new();
901        apply_proxy_env_overrides(
902            &mut env,
903            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
904            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
905            /*socks_enabled*/ true,
906            /*allow_local_binding*/ false,
907        );
908
909        assert_eq!(
910            env.get("HTTP_PROXY"),
911            Some(&"http://127.0.0.1:3128".to_string())
912        );
913        assert_eq!(
914            env.get("WS_PROXY"),
915            Some(&"http://127.0.0.1:3128".to_string())
916        );
917        assert_eq!(
918            env.get("WSS_PROXY"),
919            Some(&"http://127.0.0.1:3128".to_string())
920        );
921        assert_eq!(
922            env.get("npm_config_proxy"),
923            Some(&"http://127.0.0.1:3128".to_string())
924        );
925        assert_eq!(
926            env.get("ALL_PROXY"),
927            Some(&"socks5h://127.0.0.1:8081".to_string())
928        );
929        assert_eq!(
930            env.get("FTP_PROXY"),
931            Some(&"socks5h://127.0.0.1:8081".to_string())
932        );
933        assert_eq!(
934            env.get("NO_PROXY"),
935            Some(&DEFAULT_NO_PROXY_VALUE.to_string())
936        );
937        let no_proxy = env.get("NO_PROXY").expect("NO_PROXY should be set");
938        assert!(no_proxy.contains("10.0.0.0/8"));
939        assert!(no_proxy.contains("172.16.0.0/12"));
940        assert!(no_proxy.contains("192.168.0.0/16"));
941        assert!(no_proxy.contains("169.254.0.0/16"));
942        assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string()));
943        assert_eq!(env.get("ELECTRON_GET_USE_PROXY"), Some(&"true".to_string()));
944        #[cfg(target_os = "macos")]
945        assert_eq!(
946            env.get("GIT_SSH_COMMAND"),
947            Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
948        );
949        #[cfg(not(target_os = "macos"))]
950        assert_eq!(env.get("GIT_SSH_COMMAND"), None);
951    }
952
953    #[test]
954    fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() {
955        let mut env = HashMap::new();
956        apply_proxy_env_overrides(
957            &mut env,
958            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
959            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
960            /*socks_enabled*/ false,
961            /*allow_local_binding*/ true,
962        );
963
964        assert_eq!(
965            env.get("ALL_PROXY"),
966            Some(&"http://127.0.0.1:3128".to_string())
967        );
968        assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"1".to_string()));
969    }
970
971    #[test]
972    fn apply_proxy_env_overrides_uses_plain_http_proxy_url() {
973        let mut env = HashMap::new();
974        apply_proxy_env_overrides(
975            &mut env,
976            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
977            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
978            /*socks_enabled*/ true,
979            /*allow_local_binding*/ false,
980        );
981
982        assert_eq!(
983            env.get("HTTP_PROXY"),
984            Some(&"http://127.0.0.1:3128".to_string())
985        );
986        assert_eq!(
987            env.get("HTTPS_PROXY"),
988            Some(&"http://127.0.0.1:3128".to_string())
989        );
990        assert_eq!(
991            env.get("WS_PROXY"),
992            Some(&"http://127.0.0.1:3128".to_string())
993        );
994        assert_eq!(
995            env.get("WSS_PROXY"),
996            Some(&"http://127.0.0.1:3128".to_string())
997        );
998        assert_eq!(
999            env.get("ALL_PROXY"),
1000            Some(&"socks5h://127.0.0.1:8081".to_string())
1001        );
1002        #[cfg(target_os = "macos")]
1003        assert_eq!(
1004            env.get("GIT_SSH_COMMAND"),
1005            Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
1006        );
1007        #[cfg(not(target_os = "macos"))]
1008        assert_eq!(env.get("GIT_SSH_COMMAND"), None);
1009    }
1010
1011    #[cfg(target_os = "macos")]
1012    #[test]
1013    fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() {
1014        let mut env = HashMap::new();
1015        env.insert(
1016            "GIT_SSH_COMMAND".to_string(),
1017            "ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(),
1018        );
1019        apply_proxy_env_overrides(
1020            &mut env,
1021            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1022            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1023            /*socks_enabled*/ true,
1024            /*allow_local_binding*/ false,
1025        );
1026
1027        assert_eq!(
1028            env.get("GIT_SSH_COMMAND"),
1029            Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string())
1030        );
1031    }
1032}