Skip to main content

codex_network_proxy/
proxy.rs

1mod execution_scope;
2
3use crate::attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY;
4use crate::config;
5use crate::credential_broker::BROKERED_CREDENTIALS_ENV_KEY;
6use crate::credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY;
7use crate::http_proxy;
8use crate::network_policy::NetworkPolicyDecider;
9use crate::runtime::BlockedRequestObserver;
10use crate::runtime::ConfigState;
11use crate::runtime::unix_socket_permissions_supported;
12use crate::socks5;
13use crate::state::NetworkProxyState;
14#[cfg(target_os = "windows")]
15use crate::windows_proxy_ingress::WindowsProxyIngress;
16#[cfg(target_os = "windows")]
17use crate::windows_proxy_ingress::WindowsProxyRoute;
18#[cfg(target_os = "windows")]
19use crate::windows_proxy_ingress::WindowsRouteService;
20use anyhow::Context;
21use anyhow::Result;
22use clap::Parser;
23use codex_utils_absolute_path::AbsolutePathBuf;
24use serde::Deserialize;
25use serde::Serialize;
26use std::collections::HashMap;
27use std::net::SocketAddr;
28use std::net::TcpListener as StdTcpListener;
29use std::sync::Arc;
30use std::sync::Mutex;
31use std::sync::RwLock;
32use tokio::task::JoinHandle;
33use tracing::warn;
34
35use self::execution_scope::ExecutionScope;
36
37#[derive(Debug, Clone, Parser)]
38#[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")]
39pub struct Args {}
40
41#[derive(Debug)]
42struct ReservedListeners {
43    http: Mutex<Option<StdTcpListener>>,
44    socks: Mutex<Option<StdTcpListener>>,
45}
46
47impl ReservedListeners {
48    #[cfg(not(target_os = "windows"))]
49    fn new(http: StdTcpListener, socks: Option<StdTcpListener>) -> Self {
50        Self {
51            http: Mutex::new(Some(http)),
52            socks: Mutex::new(socks),
53        }
54    }
55
56    fn take_http(&self) -> Option<StdTcpListener> {
57        let mut guard = self
58            .http
59            .lock()
60            .unwrap_or_else(std::sync::PoisonError::into_inner);
61        guard.take()
62    }
63
64    fn take_socks(&self) -> Option<StdTcpListener> {
65        let mut guard = self
66            .socks
67            .lock()
68            .unwrap_or_else(std::sync::PoisonError::into_inner);
69        guard.take()
70    }
71}
72
73pub(super) struct ReservedListenerSet {
74    http_listener: StdTcpListener,
75    socks_listener: Option<StdTcpListener>,
76}
77
78impl ReservedListenerSet {
79    fn new(http_listener: StdTcpListener, socks_listener: Option<StdTcpListener>) -> Self {
80        Self {
81            http_listener,
82            socks_listener,
83        }
84    }
85
86    pub(super) fn http_addr(&self) -> Result<SocketAddr> {
87        self.http_listener
88            .local_addr()
89            .context("failed to read reserved HTTP proxy address")
90    }
91
92    pub(super) fn socks_addr(&self, default_addr: SocketAddr) -> Result<SocketAddr> {
93        self.socks_listener
94            .as_ref()
95            .map_or(Ok(default_addr), |listener| {
96                listener
97                    .local_addr()
98                    .context("failed to read reserved SOCKS5 proxy address")
99            })
100    }
101
102    #[cfg(not(target_os = "windows"))]
103    fn into_reserved_listeners(self) -> Arc<ReservedListeners> {
104        Arc::new(ReservedListeners::new(
105            self.http_listener,
106            self.socks_listener,
107        ))
108    }
109
110    #[cfg(target_os = "windows")]
111    pub(super) fn into_listeners(self) -> (StdTcpListener, Option<StdTcpListener>) {
112        (self.http_listener, self.socks_listener)
113    }
114}
115
116#[derive(Clone)]
117pub struct NetworkProxyBuilder {
118    state: Option<Arc<NetworkProxyState>>,
119    http_addr: Option<SocketAddr>,
120    socks_addr: Option<SocketAddr>,
121    managed_by_codex: bool,
122    policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
123    blocked_request_observer: Option<Arc<dyn BlockedRequestObserver>>,
124}
125
126impl Default for NetworkProxyBuilder {
127    fn default() -> Self {
128        Self {
129            state: None,
130            http_addr: None,
131            socks_addr: None,
132            managed_by_codex: true,
133            policy_decider: None,
134            blocked_request_observer: None,
135        }
136    }
137}
138
139impl NetworkProxyBuilder {
140    pub fn state(mut self, state: Arc<NetworkProxyState>) -> Self {
141        self.state = Some(state);
142        self
143    }
144
145    pub fn http_addr(mut self, addr: SocketAddr) -> Self {
146        self.http_addr = Some(addr);
147        self
148    }
149
150    pub fn socks_addr(mut self, addr: SocketAddr) -> Self {
151        self.socks_addr = Some(addr);
152        self
153    }
154
155    pub fn managed_by_codex(mut self, managed_by_codex: bool) -> Self {
156        self.managed_by_codex = managed_by_codex;
157        self
158    }
159
160    pub fn policy_decider<D>(mut self, decider: D) -> Self
161    where
162        D: NetworkPolicyDecider,
163    {
164        self.policy_decider = Some(Arc::new(decider));
165        self
166    }
167
168    pub fn policy_decider_arc(mut self, decider: Arc<dyn NetworkPolicyDecider>) -> Self {
169        self.policy_decider = Some(decider);
170        self
171    }
172
173    pub fn blocked_request_observer<O>(mut self, observer: O) -> Self
174    where
175        O: BlockedRequestObserver,
176    {
177        self.blocked_request_observer = Some(Arc::new(observer));
178        self
179    }
180
181    pub fn blocked_request_observer_arc(
182        mut self,
183        observer: Arc<dyn BlockedRequestObserver>,
184    ) -> Self {
185        self.blocked_request_observer = Some(observer);
186        self
187    }
188
189    pub async fn build(self) -> Result<NetworkProxy> {
190        let state = self.state.ok_or_else(|| {
191            anyhow::anyhow!(
192                "NetworkProxyBuilder requires a state; supply one via builder.state(...)"
193            )
194        })?;
195        state
196            .set_blocked_request_observer(self.blocked_request_observer.clone())
197            .await;
198        let current_cfg = state.current_cfg().await?;
199        #[cfg(target_os = "windows")]
200        let runtime_settings = NetworkProxyRuntimeSettings::from_config(&current_cfg)?;
201        #[cfg(target_os = "windows")]
202        let mut windows_ingress = None;
203        let (requested_http_addr, requested_socks_addr, reserved_listeners) = if self
204            .managed_by_codex
205        {
206            let runtime = config::resolve_runtime(&current_cfg)?;
207            #[cfg(target_os = "windows")]
208            {
209                let (managed_http_addr, managed_socks_addr) =
210                    config::clamp_bind_addrs(runtime.http_addr, runtime.socks_addr, &current_cfg);
211                let ingress = WindowsProxyIngress::shared(
212                    managed_http_addr,
213                    managed_socks_addr,
214                    current_cfg.enable_socks5,
215                )?;
216                let http_addr = ingress.http_addr();
217                let socks_addr = ingress.socks_addr();
218                windows_ingress = Some(ingress);
219                (http_addr, socks_addr, None)
220            }
221            #[cfg(not(target_os = "windows"))]
222            {
223                let reserved = reserve_loopback_ephemeral_listeners(current_cfg.enable_socks5)
224                    .context("reserve managed loopback proxy listeners")?;
225                let http_addr = reserved.http_addr()?;
226                let socks_addr = reserved.socks_addr(runtime.socks_addr)?;
227                (
228                    http_addr,
229                    socks_addr,
230                    Some(reserved.into_reserved_listeners()),
231                )
232            }
233        } else {
234            let runtime = config::resolve_runtime(&current_cfg)?;
235            (
236                self.http_addr.unwrap_or(runtime.http_addr),
237                self.socks_addr.unwrap_or(runtime.socks_addr),
238                None,
239            )
240        };
241
242        // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only.
243        let (http_addr, socks_addr) =
244            config::clamp_bind_addrs(requested_http_addr, requested_socks_addr, &current_cfg);
245
246        #[cfg(target_os = "windows")]
247        let windows_runtime = windows_ingress.map(|ingress| {
248            let http = http_proxy::http_proxy_service(
249                Arc::clone(&state),
250                self.policy_decider.clone(),
251                /*environment_id*/ None,
252            );
253            let socks = current_cfg.enable_socks5.then(|| {
254                socks5::socks5_proxy_service(
255                    Arc::clone(&state),
256                    self.policy_decider.clone(),
257                    /*environment_id*/ None,
258                    current_cfg.enable_socks5_udp,
259                )
260            });
261            Arc::new(WindowsSharedProxyRuntime {
262                ingress,
263                http_service: http,
264                socks_service: socks,
265                active_route: Arc::new(Mutex::new(None)),
266            })
267        });
268
269        #[cfg(not(target_os = "windows"))]
270        let runtime_settings = NetworkProxyRuntimeSettings::from_config(&current_cfg)?;
271
272        Ok(NetworkProxy {
273            state,
274            http_addr,
275            socks_addr,
276            socks_enabled: current_cfg.enable_socks5,
277            socks5_udp_enabled: current_cfg.enable_socks5_udp,
278            runtime_settings: Arc::new(RwLock::new(runtime_settings)),
279            reserved_listeners,
280            policy_decider: self.policy_decider,
281            environment_proxies: Arc::new(Mutex::new(HashMap::new())),
282            execution_scope: None,
283            #[cfg(target_os = "windows")]
284            windows_runtime,
285        })
286    }
287}
288
289fn reserve_loopback_ephemeral_listeners(
290    reserve_socks_listener: bool,
291) -> Result<ReservedListenerSet> {
292    let http_listener =
293        reserve_loopback_ephemeral_listener().context("reserve HTTP proxy listener")?;
294    let socks_listener = if reserve_socks_listener {
295        Some(reserve_loopback_ephemeral_listener().context("reserve SOCKS5 proxy listener")?)
296    } else {
297        None
298    };
299    Ok(ReservedListenerSet::new(http_listener, socks_listener))
300}
301
302#[cfg(target_os = "windows")]
303pub(super) fn reserve_windows_managed_listeners(
304    http_addr: SocketAddr,
305    socks_addr: SocketAddr,
306    reserve_socks_listener: bool,
307) -> Result<ReservedListenerSet> {
308    let http_addr = windows_managed_loopback_addr(http_addr);
309    let socks_addr = windows_managed_loopback_addr(socks_addr);
310
311    match try_reserve_windows_managed_listeners(http_addr, socks_addr, reserve_socks_listener) {
312        Ok(listeners) => Ok(listeners),
313        Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
314            warn!("managed Windows proxy ports are busy; falling back to ephemeral loopback ports");
315            reserve_loopback_ephemeral_listeners(reserve_socks_listener)
316                .context("reserve fallback loopback proxy listeners")
317        }
318        Err(err) => Err(err).context("reserve Windows managed proxy listeners"),
319    }
320}
321
322#[cfg(target_os = "windows")]
323pub(super) fn reserve_windows_managed_socks_listener(
324    socks_addr: SocketAddr,
325) -> Result<StdTcpListener> {
326    let socks_addr = windows_managed_loopback_addr(socks_addr);
327    match StdTcpListener::bind(socks_addr) {
328        Ok(listener) => Ok(listener),
329        Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => {
330            warn!(
331                "managed Windows SOCKS5 proxy port is busy; falling back to an ephemeral loopback port"
332            );
333            reserve_loopback_ephemeral_listener()
334                .context("reserve fallback loopback SOCKS5 proxy listener")
335        }
336        Err(err) => Err(err).context("reserve Windows managed SOCKS5 proxy listener"),
337    }
338}
339
340#[cfg(target_os = "windows")]
341fn try_reserve_windows_managed_listeners(
342    http_addr: SocketAddr,
343    socks_addr: SocketAddr,
344    reserve_socks_listener: bool,
345) -> std::io::Result<ReservedListenerSet> {
346    let http_listener = StdTcpListener::bind(http_addr)?;
347    let socks_listener = if reserve_socks_listener {
348        Some(StdTcpListener::bind(socks_addr)?)
349    } else {
350        None
351    };
352    Ok(ReservedListenerSet::new(http_listener, socks_listener))
353}
354
355#[cfg(target_os = "windows")]
356pub(super) fn windows_managed_loopback_addr(addr: SocketAddr) -> SocketAddr {
357    if !addr.ip().is_loopback() {
358        warn!(
359            "managed Windows proxies must bind to loopback; clamping {addr} to 127.0.0.1:{}",
360            addr.port()
361        );
362    }
363    SocketAddr::from(([127, 0, 0, 1], addr.port()))
364}
365
366fn reserve_loopback_ephemeral_listener() -> Result<StdTcpListener> {
367    StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))
368        .context("bind loopback ephemeral port")
369}
370
371#[derive(Debug, Clone, PartialEq, Eq)]
372struct NetworkProxyRuntimeSettings {
373    allow_local_binding: bool,
374    allow_unix_sockets: Arc<[String]>,
375    dangerously_allow_all_unix_sockets: bool,
376    mitm_ca_trust_bundle: Option<crate::certs::ManagedMitmCaTrustBundle>,
377}
378
379impl NetworkProxyRuntimeSettings {
380    fn from_config(config: &config::NetworkProxyConfig) -> Result<Self> {
381        let mitm_ca_trust_bundle = if config.mitm {
382            let env = crate::certs::ca_env_from_process();
383            Some(crate::certs::managed_ca_trust_bundle(&env)?)
384        } else {
385            None
386        };
387        Ok(Self {
388            allow_local_binding: config.allow_local_binding,
389            allow_unix_sockets: config.allow_unix_sockets().into(),
390            dangerously_allow_all_unix_sockets: config.dangerously_allow_all_unix_sockets,
391            mitm_ca_trust_bundle,
392        })
393    }
394}
395
396#[derive(Clone, Copy, Debug, PartialEq, Eq)]
397struct EnvironmentProxyAddrs {
398    http_addr: SocketAddr,
399    socks_addr: SocketAddr,
400}
401
402/// Portable managed-network facts needed by an operating-system sandbox.
403#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
404#[serde(rename_all = "camelCase")]
405pub struct ManagedNetworkSandboxContext {
406    /// Loopback proxy ports that sandboxed commands may connect to.
407    #[serde(default)]
408    pub loopback_ports: Vec<u16>,
409    /// Whether the command may bind local sockets and exchange loopback traffic.
410    #[serde(default)]
411    pub allow_local_binding: bool,
412}
413
414/// Environment-specific managed-network settings prepared for one command launch.
415#[derive(Clone, Debug, Eq, PartialEq)]
416pub struct PreparedManagedNetwork {
417    /// Complete command environment with managed proxy variables applied.
418    pub env: HashMap<String, String>,
419    /// Matching portable sandbox inputs for the command environment.
420    pub sandbox_context: ManagedNetworkSandboxContext,
421}
422
423struct EnvironmentProxy {
424    addrs: EnvironmentProxyAddrs,
425    runtime: EnvironmentProxyRuntime,
426}
427
428#[derive(Clone, Copy, Debug, Eq, PartialEq)]
429enum EnvironmentProxyClient {
430    SandboxedProcess,
431    TrustedBridge,
432}
433
434enum EnvironmentProxyRuntime {
435    ListenerTasks {
436        http_task: JoinHandle<Result<()>>,
437        socks_task: Option<JoinHandle<Result<()>>>,
438    },
439    #[cfg(target_os = "windows")]
440    SharedIngress { _route: Arc<WindowsProxyRoute> },
441}
442
443impl EnvironmentProxyRuntime {
444    #[cfg(target_os = "windows")]
445    fn network_proxy_restricting_sid(&self) -> Option<String> {
446        match self {
447            Self::ListenerTasks { .. } => None,
448            Self::SharedIngress { _route: route } => Some(route.sid().to_string()),
449        }
450    }
451}
452
453#[cfg(target_os = "windows")]
454struct WindowsSharedProxyRuntime {
455    ingress: Arc<WindowsProxyIngress>,
456    http_service: WindowsRouteService,
457    socks_service: Option<WindowsRouteService>,
458    active_route: Arc<Mutex<Option<Arc<WindowsProxyRoute>>>>,
459}
460
461#[derive(Clone)]
462pub struct NetworkProxy {
463    state: Arc<NetworkProxyState>,
464    http_addr: SocketAddr,
465    socks_addr: SocketAddr,
466    socks_enabled: bool,
467    socks5_udp_enabled: bool,
468    runtime_settings: Arc<RwLock<NetworkProxyRuntimeSettings>>,
469    reserved_listeners: Option<Arc<ReservedListeners>>,
470    policy_decider: Option<Arc<dyn NetworkPolicyDecider>>,
471    environment_proxies: Arc<Mutex<HashMap<String, EnvironmentProxy>>>,
472    execution_scope: Option<Arc<ExecutionScope>>,
473    #[cfg(target_os = "windows")]
474    windows_runtime: Option<Arc<WindowsSharedProxyRuntime>>,
475}
476
477impl std::fmt::Debug for NetworkProxy {
478    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
479        // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy
480        // and may contain sensitive paths.
481        f.debug_struct("NetworkProxy")
482            .field("http_addr", &self.http_addr)
483            .field("socks_addr", &self.socks_addr())
484            .finish_non_exhaustive()
485    }
486}
487
488impl PartialEq for NetworkProxy {
489    fn eq(&self, other: &Self) -> bool {
490        self.http_addr == other.http_addr
491            && self.socks_addr() == other.socks_addr()
492            && self.runtime_settings() == other.runtime_settings()
493    }
494}
495
496impl Eq for NetworkProxy {}
497
498pub const PROXY_URL_ENV_KEYS: &[&str] = &[
499    "HTTP_PROXY",
500    "HTTPS_PROXY",
501    "WS_PROXY",
502    "WSS_PROXY",
503    "ALL_PROXY",
504    "FTP_PROXY",
505    "YARN_HTTP_PROXY",
506    "YARN_HTTPS_PROXY",
507    "NPM_CONFIG_HTTP_PROXY",
508    "NPM_CONFIG_HTTPS_PROXY",
509    "NPM_CONFIG_PROXY",
510    "BUNDLE_HTTP_PROXY",
511    "BUNDLE_HTTPS_PROXY",
512    "PIP_PROXY",
513    "DOCKER_HTTP_PROXY",
514    "DOCKER_HTTPS_PROXY",
515];
516
517pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"];
518pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE";
519pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
520// Internal wire format shared with windows-sandbox-rs/src/setup.rs. The value is a
521// comma-separated, sorted list of non-zero loopback proxy ports used only when computing the
522// Windows offline sandbox setup marker.
523#[cfg(target_os = "windows")]
524const WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY: &str = "CODEX_WINDOWS_SANDBOX_PROXY_PORTS";
525const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY";
526const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY";
527#[cfg(any(target_os = "macos", test))]
528const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND";
529pub const PROXY_ENV_KEYS: &[&str] = &[
530    PROXY_ACTIVE_ENV_KEY,
531    CREDENTIAL_BROKER_ACTIVE_ENV_KEY,
532    BROKERED_CREDENTIALS_ENV_KEY,
533    ALLOW_LOCAL_BINDING_ENV_KEY,
534    #[cfg(target_os = "windows")]
535    WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY,
536    PROXY_ATTRIBUTION_TOKEN_ENV_KEY,
537    ELECTRON_GET_USE_PROXY_ENV_KEY,
538    NODE_USE_ENV_PROXY_ENV_KEY,
539    "HTTP_PROXY",
540    "HTTPS_PROXY",
541    "http_proxy",
542    "https_proxy",
543    "YARN_HTTP_PROXY",
544    "YARN_HTTPS_PROXY",
545    "npm_config_http_proxy",
546    "npm_config_https_proxy",
547    "npm_config_proxy",
548    "NPM_CONFIG_HTTP_PROXY",
549    "NPM_CONFIG_HTTPS_PROXY",
550    "NPM_CONFIG_PROXY",
551    "BUNDLE_HTTP_PROXY",
552    "BUNDLE_HTTPS_PROXY",
553    "PIP_PROXY",
554    "DOCKER_HTTP_PROXY",
555    "DOCKER_HTTPS_PROXY",
556    "WS_PROXY",
557    "WSS_PROXY",
558    "ws_proxy",
559    "wss_proxy",
560    "NO_PROXY",
561    "no_proxy",
562    "npm_config_noproxy",
563    "NPM_CONFIG_NOPROXY",
564    "YARN_NO_PROXY",
565    "BUNDLE_NO_PROXY",
566    "ALL_PROXY",
567    "all_proxy",
568    "FTP_PROXY",
569    "ftp_proxy",
570];
571
572pub fn is_managed_proxy_env_var(key: &str, value: &str) -> bool {
573    if PROXY_ENV_KEYS.contains(&key) {
574        return true;
575    }
576    if crate::certs::CUSTOM_CA_ENV_KEYS.contains(&key) {
577        return crate::certs::is_managed_mitm_ca_trust_bundle_path(value);
578    }
579    #[cfg(target_os = "macos")]
580    {
581        key == PROXY_GIT_SSH_COMMAND_ENV_KEY
582            && value.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER)
583    }
584    #[cfg(not(target_os = "macos"))]
585    {
586        false
587    }
588}
589
590pub fn strip_managed_proxy_env(env: &mut HashMap<String, String>) {
591    env.retain(|key, value| !is_managed_proxy_env_var(key, value));
592}
593
594#[cfg(target_os = "macos")]
595pub const PROXY_GIT_SSH_COMMAND_ENV_KEY: &str = GIT_SSH_COMMAND_ENV_KEY;
596
597const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
598const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"];
599
600pub const NO_PROXY_ENV_KEYS: &[&str] = &[
601    "NO_PROXY",
602    "no_proxy",
603    "npm_config_noproxy",
604    "NPM_CONFIG_NOPROXY",
605    "YARN_NO_PROXY",
606    "BUNDLE_NO_PROXY",
607];
608
609pub const DEFAULT_NO_PROXY_VALUE: &str = concat!(
610    "localhost,127.0.0.1,::1,",
611    "10.0.0.0/8,",
612    "172.16.0.0/12,",
613    "192.168.0.0/16"
614);
615
616#[cfg(target_os = "macos")]
617pub const CODEX_PROXY_GIT_SSH_COMMAND_MARKER: &str = "CODEX_PROXY_GIT_SSH_COMMAND=1 ";
618#[cfg(target_os = "macos")]
619const CODEX_PROXY_GIT_SSH_COMMAND_PREFIX: &str =
620    "CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x ";
621#[cfg(target_os = "macos")]
622const CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX: &str = " %h %p'";
623
624pub fn proxy_url_env_value<'a>(
625    env: &'a HashMap<String, String>,
626    canonical_key: &str,
627) -> Option<&'a str> {
628    if let Some(value) = env.get(canonical_key) {
629        return Some(value.as_str());
630    }
631    let lower_key = canonical_key.to_ascii_lowercase();
632    env.get(lower_key.as_str()).map(String::as_str)
633}
634
635pub fn has_proxy_url_env_vars(env: &HashMap<String, String>) -> bool {
636    PROXY_URL_ENV_KEYS
637        .iter()
638        .any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty()))
639}
640
641fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
642    for key in keys {
643        env.insert((*key).to_string(), value.to_string());
644    }
645}
646
647#[cfg(target_os = "macos")]
648fn codex_proxy_git_ssh_command(socks_addr: SocketAddr) -> String {
649    format!("{CODEX_PROXY_GIT_SSH_COMMAND_PREFIX}{socks_addr}{CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX}")
650}
651
652#[cfg(target_os = "macos")]
653fn is_codex_proxy_git_ssh_command(command: &str) -> bool {
654    command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_PREFIX)
655        && command.ends_with(CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX)
656}
657
658fn apply_proxy_env_overrides(
659    env: &mut HashMap<String, String>,
660    http_addr: SocketAddr,
661    socks_addr: SocketAddr,
662    socks_enabled: bool,
663    allow_local_binding: bool,
664    mitm_ca_trust_bundle: Option<&crate::certs::ManagedMitmCaTrustBundle>,
665) {
666    let http_proxy_url = format!("http://{http_addr}");
667    let socks_proxy_url = format!("socks5h://{socks_addr}");
668    env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string());
669    env.insert(
670        ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
671        if allow_local_binding {
672            "1".to_string()
673        } else {
674            "0".to_string()
675        },
676    );
677
678    // HTTP-based clients are best served by explicit HTTP proxy URLs.
679    set_env_keys(
680        env,
681        &[
682            "HTTP_PROXY",
683            "HTTPS_PROXY",
684            "http_proxy",
685            "https_proxy",
686            "YARN_HTTP_PROXY",
687            "YARN_HTTPS_PROXY",
688            "npm_config_http_proxy",
689            "npm_config_https_proxy",
690            "npm_config_proxy",
691            "NPM_CONFIG_HTTP_PROXY",
692            "NPM_CONFIG_HTTPS_PROXY",
693            "NPM_CONFIG_PROXY",
694            "BUNDLE_HTTP_PROXY",
695            "BUNDLE_HTTPS_PROXY",
696            "PIP_PROXY",
697            "DOCKER_HTTP_PROXY",
698            "DOCKER_HTTPS_PROXY",
699        ],
700        &http_proxy_url,
701    );
702    // Some websocket clients look for dedicated WS/WSS proxy environment variables instead of
703    // HTTP(S)_PROXY. Keep them aligned with the managed HTTP proxy endpoint.
704    set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);
705
706    // Keep local targets direct only when local binding is enabled. Otherwise route them through
707    // the proxy so explicit literal allowlists and local-network restrictions can be enforced.
708    let no_proxy = if allow_local_binding {
709        DEFAULT_NO_PROXY_VALUE
710    } else {
711        ""
712    };
713    set_env_keys(env, NO_PROXY_ENV_KEYS, no_proxy);
714
715    env.insert(
716        ELECTRON_GET_USE_PROXY_ENV_KEY.to_string(),
717        "true".to_string(),
718    );
719    // Node.js built-in HTTP clients only honor proxy environment variables when this is enabled.
720    env.insert(NODE_USE_ENV_PROXY_ENV_KEY.to_string(), "1".to_string());
721
722    // Keep HTTP_PROXY/HTTPS_PROXY as HTTP endpoints. A lot of clients break if
723    // those vars contain SOCKS URLs. We only switch ALL_PROXY here.
724    //
725    if socks_enabled {
726        set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
727        set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
728    } else {
729        set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url);
730        set_env_keys(env, FTP_PROXY_ENV_KEYS, &http_proxy_url);
731    }
732
733    #[cfg(target_os = "macos")]
734    if socks_enabled {
735        // Preserve existing SSH wrappers (for example: Secretive/Teleport setups)
736        // but refresh a previously injected Codex fallback so it cannot point
737        // at a stale proxy port after the proxy is restarted.
738        match env.get(GIT_SSH_COMMAND_ENV_KEY) {
739            Some(command) if !is_codex_proxy_git_ssh_command(command) => {}
740            _ => {
741                env.insert(
742                    GIT_SSH_COMMAND_ENV_KEY.to_string(),
743                    codex_proxy_git_ssh_command(socks_addr),
744                );
745            }
746        }
747    }
748
749    if let Some(mitm_ca_trust_bundle) = mitm_ca_trust_bundle {
750        let managed_path = mitm_ca_trust_bundle.path.to_string_lossy().into_owned();
751        for key in crate::certs::CUSTOM_CA_ENV_KEYS {
752            if env
753                .get(key)
754                .filter(|value| !value.is_empty())
755                .is_some_and(|value| {
756                    value != &managed_path
757                        && mitm_ca_trust_bundle.startup_env_values.get(key) != Some(value)
758                })
759            {
760                // TODO(winston): Materialize policy-checked per-child bundles for readable
761                // startup and command-scoped CA overrides. For now startup overrides are
762                // replaced with the default bundle and later command-scoped overrides are
763                // preserved, either of which can make intercepted TLS fail.
764                continue;
765            }
766            env.insert(key.to_string(), managed_path.clone());
767        }
768    }
769}
770
771impl NetworkProxy {
772    pub fn builder() -> NetworkProxyBuilder {
773        NetworkProxyBuilder::default()
774    }
775
776    pub fn http_addr(&self) -> SocketAddr {
777        self.http_addr
778    }
779
780    pub fn socks_addr(&self) -> SocketAddr {
781        #[cfg(target_os = "windows")]
782        if let Some(runtime) = self.windows_runtime.as_ref() {
783            return runtime.ingress.socks_addr();
784        }
785        self.socks_addr
786    }
787
788    /// Returns the restricting SID that identifies this logical proxy route to the shared
789    /// Windows ingress. Environment routes are available after their proxy settings are prepared.
790    #[cfg(target_os = "windows")]
791    pub fn network_proxy_restricting_sid(&self, environment_id: Option<&str>) -> Option<String> {
792        match environment_id {
793            Some(environment_id) => self
794                .environment_proxies
795                .lock()
796                .unwrap_or_else(std::sync::PoisonError::into_inner)
797                .get(environment_id)
798                .and_then(|proxy| proxy.runtime.network_proxy_restricting_sid()),
799            None => self.windows_runtime.as_ref().and_then(|runtime| {
800                runtime
801                    .active_route
802                    .lock()
803                    .unwrap_or_else(std::sync::PoisonError::into_inner)
804                    .as_ref()
805                    .map(|route| route.sid().to_string())
806            }),
807        }
808    }
809
810    pub async fn current_cfg(&self) -> Result<config::NetworkProxyConfig> {
811        self.state.current_cfg().await
812    }
813
814    /// Captures the static inputs needed to launch a matching executor-local proxy.
815    pub async fn remote_launch_config(&self) -> Result<crate::RemoteNetworkProxyLaunchConfig> {
816        let proxy = crate::RemoteNetworkProxyConfig::from_effective_config(
817            &self.state.current_cfg().await?,
818        )?;
819        let (environment_id, execution_id) = self
820            .execution_scope
821            .as_ref()
822            .map(|scope| {
823                (
824                    Some(scope.environment_id.clone()),
825                    Some(scope.execution_id.clone()),
826                )
827            })
828            .unwrap_or_default();
829        Ok(crate::RemoteNetworkProxyLaunchConfig {
830            proxy,
831            audit_metadata: self.state.audit_metadata().clone(),
832            environment_id,
833            execution_id,
834        })
835    }
836
837    pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
838        self.state.add_allowed_domain(host).await
839    }
840
841    pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
842        self.state.add_denied_domain(host).await
843    }
844
845    pub fn allow_local_binding(&self) -> bool {
846        self.runtime_settings().allow_local_binding
847    }
848
849    pub fn allow_unix_sockets(&self) -> Arc<[String]> {
850        self.runtime_settings().allow_unix_sockets
851    }
852
853    pub fn dangerously_allow_all_unix_sockets(&self) -> bool {
854        self.runtime_settings().dangerously_allow_all_unix_sockets
855    }
856
857    /// Returns the generated MITM CA bundle path child sandboxes should expose to TLS clients.
858    pub fn managed_mitm_ca_trust_bundle_path(&self) -> Option<AbsolutePathBuf> {
859        self.runtime_settings()
860            .mitm_ca_trust_bundle
861            .and_then(|bundle| {
862                AbsolutePathBuf::from_absolute_path(bundle.path)
863                    .map_err(|err| warn!("managed MITM CA trust bundle path is invalid: {err}"))
864                    .ok()
865            })
866    }
867
868    fn prepare_for_addrs(
869        &self,
870        mut env: HashMap<String, String>,
871        addrs: EnvironmentProxyAddrs,
872        #[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
873        client: EnvironmentProxyClient,
874    ) -> PreparedManagedNetwork {
875        #[cfg(target_os = "windows")]
876        let shared_socks_addr = (client == EnvironmentProxyClient::SandboxedProcess)
877            .then(|| {
878                self.windows_runtime
879                    .as_ref()
880                    .and_then(|runtime| runtime.ingress.active_socks_addr())
881            })
882            .flatten();
883        #[cfg(target_os = "windows")]
884        let addrs = EnvironmentProxyAddrs {
885            socks_addr: shared_socks_addr.unwrap_or(addrs.socks_addr),
886            ..addrs
887        };
888        let runtime_settings = self.runtime_settings();
889        // Enforce proxying for child processes. Proxy endpoint values are always rewritten;
890        // managed MITM CA vars preserve child-scoped overrides after proxy startup.
891        apply_proxy_env_overrides(
892            &mut env,
893            addrs.http_addr,
894            addrs.socks_addr,
895            self.socks_enabled,
896            runtime_settings.allow_local_binding,
897            runtime_settings.mitm_ca_trust_bundle.as_ref(),
898        );
899        self.state.virtualize_child_credentials(&mut env);
900        if let Some(execution_scope) = self.execution_scope.as_ref() {
901            env.insert(
902                PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(),
903                execution_scope.attribution_token.clone(),
904            );
905        } else {
906            env.remove(PROXY_ATTRIBUTION_TOKEN_ENV_KEY);
907        }
908        let expose_socks_port = self.socks_enabled;
909        #[cfg(target_os = "windows")]
910        let expose_socks_port = expose_socks_port || shared_socks_addr.is_some();
911        let mut loopback_ports = [
912            Some(addrs.http_addr),
913            expose_socks_port.then_some(addrs.socks_addr),
914        ]
915        .into_iter()
916        .flatten()
917        .filter(|addr| addr.ip().is_loopback())
918        .map(|addr| addr.port())
919        .collect::<Vec<_>>();
920        loopback_ports.sort_unstable();
921        loopback_ports.dedup();
922        #[cfg(target_os = "windows")]
923        if client == EnvironmentProxyClient::SandboxedProcess && self.windows_runtime.is_some() {
924            env.insert(
925                WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY.to_string(),
926                loopback_ports
927                    .iter()
928                    .map(u16::to_string)
929                    .collect::<Vec<_>>()
930                    .join(","),
931            );
932        } else {
933            env.remove(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY);
934        }
935        PreparedManagedNetwork {
936            env,
937            sandbox_context: ManagedNetworkSandboxContext {
938                loopback_ports,
939                allow_local_binding: runtime_settings.allow_local_binding,
940            },
941        }
942    }
943
944    fn apply_to_env_for_addrs(
945        &self,
946        env: &mut HashMap<String, String>,
947        addrs: EnvironmentProxyAddrs,
948    ) {
949        let prepared = self.prepare_for_addrs(
950            std::mem::take(env),
951            addrs,
952            EnvironmentProxyClient::SandboxedProcess,
953        );
954        *env = prepared.env;
955    }
956
957    pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
958        self.apply_to_env_for_addrs(
959            env,
960            EnvironmentProxyAddrs {
961                http_addr: self.http_addr,
962                socks_addr: self.socks_addr,
963            },
964        );
965    }
966
967    pub fn apply_to_env_for_environment(
968        &self,
969        env: &mut HashMap<String, String>,
970        environment_id: &str,
971    ) -> Result<()> {
972        let addrs =
973            self.environment_proxy_addrs(environment_id, EnvironmentProxyClient::SandboxedProcess)?;
974        self.apply_to_env_for_addrs(env, addrs);
975        Ok(())
976    }
977
978    pub fn apply_to_env_for_optional_environment(
979        &self,
980        env: &mut HashMap<String, String>,
981        environment_id: Option<&str>,
982    ) -> Result<()> {
983        match environment_id {
984            Some(environment_id) => self.apply_to_env_for_environment(env, environment_id),
985            None => {
986                self.apply_to_env(env);
987                Ok(())
988            }
989        }
990    }
991
992    /// Applies the environment-specific proxy settings and returns the matching portable sandbox
993    /// projection from the same runtime configuration snapshot.
994    pub fn prepare_for_optional_environment(
995        &self,
996        env: HashMap<String, String>,
997        environment_id: Option<&str>,
998    ) -> Result<PreparedManagedNetwork> {
999        let addrs = match environment_id {
1000            Some(environment_id) => self.environment_proxy_addrs(
1001                environment_id,
1002                EnvironmentProxyClient::SandboxedProcess,
1003            )?,
1004            None => EnvironmentProxyAddrs {
1005                http_addr: self.http_addr,
1006                socks_addr: self.socks_addr,
1007            },
1008        };
1009        Ok(self.prepare_for_addrs(env, addrs, EnvironmentProxyClient::SandboxedProcess))
1010    }
1011
1012    /// Prepares proxy settings for a remote executor whose connection reaches this process through
1013    /// the trusted proxy bridge rather than directly from a locally spawned sandbox process.
1014    pub fn prepare_for_remote_environment(
1015        &self,
1016        env: HashMap<String, String>,
1017        environment_id: &str,
1018    ) -> Result<PreparedManagedNetwork> {
1019        let addrs =
1020            self.environment_proxy_addrs(environment_id, EnvironmentProxyClient::TrustedBridge)?;
1021        Ok(self.prepare_for_addrs(env, addrs, EnvironmentProxyClient::TrustedBridge))
1022    }
1023
1024    fn environment_proxy_addrs(
1025        &self,
1026        environment_id: &str,
1027        #[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
1028        client: EnvironmentProxyClient,
1029    ) -> Result<EnvironmentProxyAddrs> {
1030        if let Some(execution_scope) = self.execution_scope.as_ref() {
1031            anyhow::ensure!(
1032                execution_scope.environment_id == environment_id,
1033                "execution-scoped network proxy belongs to environment `{}`, not `{environment_id}`",
1034                execution_scope.environment_id
1035            );
1036        }
1037
1038        let mut proxies = self
1039            .environment_proxies
1040            .lock()
1041            .unwrap_or_else(std::sync::PoisonError::into_inner);
1042        if let Some(proxy) = proxies.get(environment_id) {
1043            #[cfg(target_os = "windows")]
1044            let uses_shared_ingress = client == EnvironmentProxyClient::SandboxedProcess
1045                && self.windows_runtime.is_some();
1046            #[cfg(target_os = "windows")]
1047            anyhow::ensure!(
1048                matches!(
1049                    (&proxy.runtime, uses_shared_ingress),
1050                    (EnvironmentProxyRuntime::SharedIngress { .. }, true)
1051                        | (EnvironmentProxyRuntime::ListenerTasks { .. }, false)
1052                ),
1053                "network proxy for environment `{environment_id}` was prepared for a different client type"
1054            );
1055            return Ok(proxy.addrs);
1056        }
1057
1058        #[cfg(target_os = "windows")]
1059        if client == EnvironmentProxyClient::SandboxedProcess
1060            && let Some(windows_runtime) = self.windows_runtime.as_ref()
1061        {
1062            let active_route = windows_runtime
1063                .active_route
1064                .lock()
1065                .unwrap_or_else(std::sync::PoisonError::into_inner);
1066            anyhow::ensure!(
1067                active_route.is_some(),
1068                "shared managed Windows proxy route is not running"
1069            );
1070            let environment_id = environment_id.to_string();
1071            let http = http_proxy::http_proxy_service(
1072                Arc::clone(&self.state),
1073                self.policy_decider.clone(),
1074                Some(environment_id.clone()),
1075            );
1076            let socks = self.socks_enabled.then(|| {
1077                socks5::socks5_proxy_service(
1078                    Arc::clone(&self.state),
1079                    self.policy_decider.clone(),
1080                    Some(environment_id.clone()),
1081                    self.socks5_udp_enabled,
1082                )
1083            });
1084            let route = Arc::new(windows_runtime.ingress.register_route(http, socks));
1085            let addrs = EnvironmentProxyAddrs {
1086                http_addr: self.http_addr,
1087                socks_addr: self.socks_addr,
1088            };
1089            proxies.insert(
1090                environment_id,
1091                EnvironmentProxy {
1092                    addrs,
1093                    runtime: EnvironmentProxyRuntime::SharedIngress { _route: route },
1094                },
1095            );
1096            return Ok(addrs);
1097        }
1098
1099        let runtime = tokio::runtime::Handle::try_current().with_context(|| {
1100            format!("failed to create network proxy for environment `{environment_id}`")
1101        })?;
1102        let listeners =
1103            reserve_loopback_ephemeral_listeners(self.socks_enabled).with_context(|| {
1104                format!("failed to reserve network proxy for environment `{environment_id}`")
1105            })?;
1106        let http_addr = listeners.http_addr().with_context(|| {
1107            format!("failed to read HTTP proxy address for environment `{environment_id}`")
1108        })?;
1109        let socks_addr = listeners.socks_addr(self.socks_addr).with_context(|| {
1110            format!("failed to read SOCKS proxy address for environment `{environment_id}`")
1111        })?;
1112        let addrs = EnvironmentProxyAddrs {
1113            http_addr,
1114            socks_addr,
1115        };
1116        let ReservedListenerSet {
1117            http_listener,
1118            socks_listener,
1119        } = listeners;
1120
1121        let environment_id = environment_id.to_string();
1122        let http_state = self.state.clone();
1123        let http_decider = self.policy_decider.clone();
1124        let http_environment_id = Some(environment_id.clone());
1125        let http_task = runtime.spawn(async move {
1126            http_proxy::run_http_proxy_with_std_listener(
1127                http_state,
1128                http_listener,
1129                http_decider,
1130                http_environment_id,
1131            )
1132            .await
1133        });
1134
1135        let socks_task = if self.socks_enabled {
1136            let socks_state = self.state.clone();
1137            let socks_decider = self.policy_decider.clone();
1138            let socks_environment_id = Some(environment_id.clone());
1139            let socks5_udp_enabled = self.socks5_udp_enabled;
1140            socks_listener.map(|listener| {
1141                runtime.spawn(async move {
1142                    socks5::run_socks5_with_std_listener(
1143                        socks_state,
1144                        listener,
1145                        socks_decider,
1146                        socks_environment_id,
1147                        socks5_udp_enabled,
1148                    )
1149                    .await
1150                })
1151            })
1152        } else {
1153            None
1154        };
1155
1156        proxies.insert(
1157            environment_id,
1158            EnvironmentProxy {
1159                addrs,
1160                runtime: EnvironmentProxyRuntime::ListenerTasks {
1161                    http_task,
1162                    socks_task,
1163                },
1164            },
1165        );
1166        Ok(addrs)
1167    }
1168
1169    pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> {
1170        let current_cfg = self.state.current_cfg().await?;
1171        anyhow::ensure!(
1172            new_state.config.enabled == current_cfg.enabled,
1173            "cannot update network.enabled on a running proxy"
1174        );
1175        anyhow::ensure!(
1176            new_state.config.proxy_url == current_cfg.proxy_url,
1177            "cannot update network.proxy_url on a running proxy"
1178        );
1179        anyhow::ensure!(
1180            new_state.config.socks_url == current_cfg.socks_url,
1181            "cannot update network.socks_url on a running proxy"
1182        );
1183        anyhow::ensure!(
1184            new_state.config.enable_socks5 == current_cfg.enable_socks5,
1185            "cannot update network.enable_socks5 on a running proxy"
1186        );
1187        anyhow::ensure!(
1188            new_state.config.enable_socks5_udp == current_cfg.enable_socks5_udp,
1189            "cannot update network.enable_socks5_udp on a running proxy"
1190        );
1191        let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config)?;
1192        self.state.replace_config_state(new_state).await?;
1193        let mut guard = self
1194            .runtime_settings
1195            .write()
1196            .unwrap_or_else(std::sync::PoisonError::into_inner);
1197        *guard = settings;
1198        Ok(())
1199    }
1200
1201    fn runtime_settings(&self) -> NetworkProxyRuntimeSettings {
1202        self.runtime_settings
1203            .read()
1204            .unwrap_or_else(std::sync::PoisonError::into_inner)
1205            .clone()
1206    }
1207
1208    pub async fn run(&self) -> Result<NetworkProxyHandle> {
1209        anyhow::ensure!(
1210            self.execution_scope.is_none(),
1211            "execution-scoped network proxy is already running"
1212        );
1213        let current_cfg = self.state.current_cfg().await?;
1214        if !current_cfg.enabled {
1215            warn!("network.enabled is false; skipping proxy listeners");
1216            return Ok(NetworkProxyHandle::noop());
1217        }
1218
1219        if !unix_socket_permissions_supported() {
1220            warn!(
1221                "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform"
1222            );
1223        }
1224
1225        #[cfg(target_os = "windows")]
1226        if let Some(windows_runtime) = self.windows_runtime.as_ref() {
1227            let mut active_route = windows_runtime
1228                .active_route
1229                .lock()
1230                .unwrap_or_else(std::sync::PoisonError::into_inner);
1231            anyhow::ensure!(
1232                active_route.is_none(),
1233                "shared managed Windows proxy route is already running"
1234            );
1235            *active_route = Some(Arc::new(windows_runtime.ingress.register_route(
1236                windows_runtime.http_service.clone(),
1237                windows_runtime.socks_service.clone(),
1238            )));
1239            drop(active_route);
1240            return Ok(NetworkProxyHandle::windows_shared(
1241                Arc::clone(&windows_runtime.active_route),
1242                Arc::clone(&self.environment_proxies),
1243            ));
1244        }
1245
1246        let reserved_listeners = self.reserved_listeners.as_ref();
1247        let http_listener = reserved_listeners.and_then(|listeners| listeners.take_http());
1248        let socks_listener = reserved_listeners.and_then(|listeners| listeners.take_socks());
1249
1250        let http_state = self.state.clone();
1251        let http_decider = self.policy_decider.clone();
1252        let http_addr = self.http_addr;
1253        let http_task = tokio::spawn(async move {
1254            match http_listener {
1255                Some(listener) => {
1256                    http_proxy::run_http_proxy_with_std_listener(
1257                        http_state,
1258                        listener,
1259                        http_decider,
1260                        /*environment_id*/ None,
1261                    )
1262                    .await
1263                }
1264                None => {
1265                    http_proxy::run_http_proxy(
1266                        http_state,
1267                        http_addr,
1268                        http_decider,
1269                        /*environment_id*/ None,
1270                    )
1271                    .await
1272                }
1273            }
1274        });
1275
1276        let socks_task = if current_cfg.enable_socks5 {
1277            let socks_state = self.state.clone();
1278            let socks_decider = self.policy_decider.clone();
1279            let socks_addr = self.socks_addr;
1280            let enable_socks5_udp = current_cfg.enable_socks5_udp;
1281            Some(tokio::spawn(async move {
1282                match socks_listener {
1283                    Some(listener) => {
1284                        socks5::run_socks5_with_std_listener(
1285                            socks_state,
1286                            listener,
1287                            socks_decider,
1288                            /*environment_id*/ None,
1289                            enable_socks5_udp,
1290                        )
1291                        .await
1292                    }
1293                    None => {
1294                        socks5::run_socks5(
1295                            socks_state,
1296                            socks_addr,
1297                            socks_decider,
1298                            /*environment_id*/ None,
1299                            enable_socks5_udp,
1300                        )
1301                        .await
1302                    }
1303                }
1304            }))
1305        } else {
1306            None
1307        };
1308
1309        Ok(NetworkProxyHandle {
1310            http_task: Some(http_task),
1311            socks_task,
1312            environment_proxies: self.environment_proxies.clone(),
1313            completed: false,
1314            #[cfg(target_os = "windows")]
1315            windows_active_route: None,
1316        })
1317    }
1318}
1319
1320pub struct NetworkProxyHandle {
1321    http_task: Option<JoinHandle<Result<()>>>,
1322    socks_task: Option<JoinHandle<Result<()>>>,
1323    environment_proxies: Arc<Mutex<HashMap<String, EnvironmentProxy>>>,
1324    completed: bool,
1325    #[cfg(target_os = "windows")]
1326    windows_active_route: Option<Arc<Mutex<Option<Arc<WindowsProxyRoute>>>>>,
1327}
1328
1329impl NetworkProxyHandle {
1330    fn noop() -> Self {
1331        Self {
1332            http_task: Some(tokio::spawn(async { Ok(()) })),
1333            socks_task: None,
1334            environment_proxies: Arc::new(Mutex::new(HashMap::new())),
1335            completed: true,
1336            #[cfg(target_os = "windows")]
1337            windows_active_route: None,
1338        }
1339    }
1340
1341    #[cfg(target_os = "windows")]
1342    fn windows_shared(
1343        active_route: Arc<Mutex<Option<Arc<WindowsProxyRoute>>>>,
1344        environment_proxies: Arc<Mutex<HashMap<String, EnvironmentProxy>>>,
1345    ) -> Self {
1346        Self {
1347            http_task: Some(tokio::spawn(async {
1348                std::future::pending::<()>().await;
1349                Ok(())
1350            })),
1351            socks_task: None,
1352            environment_proxies,
1353            completed: false,
1354            windows_active_route: Some(active_route),
1355        }
1356    }
1357
1358    #[cfg(target_os = "windows")]
1359    fn deactivate_windows_route(&mut self) {
1360        if let Some(active_route) = self.windows_active_route.take() {
1361            active_route
1362                .lock()
1363                .unwrap_or_else(std::sync::PoisonError::into_inner)
1364                .take();
1365        }
1366    }
1367
1368    pub async fn wait(mut self) -> Result<()> {
1369        let http_task = self.http_task.take().context("missing http proxy task")?;
1370        let socks_task = self.socks_task.take();
1371        let http_result = http_task.await;
1372        let socks_result = match socks_task {
1373            Some(task) => Some(task.await),
1374            None => None,
1375        };
1376        #[cfg(target_os = "windows")]
1377        self.deactivate_windows_route();
1378        self.completed = true;
1379        abort_environment_proxies(self.environment_proxies.clone()).await;
1380        http_result??;
1381        if let Some(socks_result) = socks_result {
1382            socks_result??;
1383        }
1384        Ok(())
1385    }
1386
1387    pub async fn shutdown(mut self) -> Result<()> {
1388        #[cfg(target_os = "windows")]
1389        self.deactivate_windows_route();
1390        abort_tasks(self.http_task.take(), self.socks_task.take()).await;
1391        abort_environment_proxies(self.environment_proxies.clone()).await;
1392        self.completed = true;
1393        Ok(())
1394    }
1395}
1396
1397async fn abort_task(task: Option<JoinHandle<Result<()>>>) {
1398    if let Some(task) = task {
1399        task.abort();
1400        let _ = task.await;
1401    }
1402}
1403
1404async fn abort_tasks(
1405    http_task: Option<JoinHandle<Result<()>>>,
1406    socks_task: Option<JoinHandle<Result<()>>>,
1407) {
1408    abort_task(http_task).await;
1409    abort_task(socks_task).await;
1410}
1411
1412async fn abort_environment_proxies(
1413    environment_proxies: Arc<Mutex<HashMap<String, EnvironmentProxy>>>,
1414) {
1415    let proxies = {
1416        let mut guard = environment_proxies
1417            .lock()
1418            .unwrap_or_else(std::sync::PoisonError::into_inner);
1419        guard.drain().map(|(_, proxy)| proxy).collect::<Vec<_>>()
1420    };
1421    for proxy in proxies {
1422        match proxy.runtime {
1423            EnvironmentProxyRuntime::ListenerTasks {
1424                http_task,
1425                socks_task,
1426            } => {
1427                abort_task(Some(http_task)).await;
1428                abort_task(socks_task).await;
1429            }
1430            #[cfg(target_os = "windows")]
1431            EnvironmentProxyRuntime::SharedIngress { .. } => {}
1432        }
1433    }
1434}
1435
1436#[cfg(target_os = "windows")]
1437fn unregister_windows_ingress_environment_routes(
1438    environment_proxies: &Arc<Mutex<HashMap<String, EnvironmentProxy>>>,
1439) {
1440    environment_proxies
1441        .lock()
1442        .unwrap_or_else(std::sync::PoisonError::into_inner)
1443        .retain(|_, proxy| {
1444            matches!(
1445                &proxy.runtime,
1446                EnvironmentProxyRuntime::ListenerTasks { .. }
1447            )
1448        });
1449}
1450
1451impl Drop for NetworkProxyHandle {
1452    fn drop(&mut self) {
1453        if self.completed {
1454            return;
1455        }
1456        let http_task = self.http_task.take();
1457        let socks_task = self.socks_task.take();
1458        let environment_proxies = self.environment_proxies.clone();
1459        #[cfg(target_os = "windows")]
1460        {
1461            self.deactivate_windows_route();
1462            unregister_windows_ingress_environment_routes(&environment_proxies);
1463        }
1464        tokio::spawn(async move {
1465            abort_tasks(http_task, socks_task).await;
1466            abort_environment_proxies(environment_proxies).await;
1467        });
1468    }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use super::*;
1474    use crate::config::NetworkProxyConfig;
1475    use crate::state::network_proxy_state_for_policy;
1476    use pretty_assertions::assert_eq;
1477    use std::net::IpAddr;
1478    use std::net::Ipv4Addr;
1479    use std::path::Path;
1480
1481    #[cfg(target_os = "windows")]
1482    static WINDOWS_INGRESS_TEST_LOCK: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1);
1483
1484    #[tokio::test]
1485    async fn managed_proxy_builder_uses_loopback_ports() {
1486        #[cfg(target_os = "windows")]
1487        let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap();
1488        let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
1489        let http_addr = http_listener.local_addr().unwrap();
1490        let socks_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
1491        let socks_addr = socks_listener.local_addr().unwrap();
1492        drop(http_listener);
1493        drop(socks_listener);
1494
1495        let state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig {
1496            enabled: true,
1497            proxy_url: format!("http://{http_addr}"),
1498            socks_url: format!("http://{socks_addr}"),
1499            ..NetworkProxyConfig::default()
1500        }));
1501        let proxy = match NetworkProxy::builder().state(state).build().await {
1502            Ok(proxy) => proxy,
1503            Err(err) => {
1504                if err
1505                    .chain()
1506                    .any(|cause| cause.to_string().contains("Operation not permitted"))
1507                {
1508                    return;
1509                }
1510                panic!("failed to build managed proxy: {err:#}");
1511            }
1512        };
1513
1514        assert!(proxy.http_addr.ip().is_loopback());
1515        assert!(proxy.socks_addr.ip().is_loopback());
1516        #[cfg(target_os = "windows")]
1517        {
1518            assert_eq!(proxy.http_addr, http_addr);
1519            assert_eq!(proxy.socks_addr, socks_addr);
1520            assert_eq!(proxy.network_proxy_restricting_sid(None), None);
1521            let handle = proxy.run().await.expect("start stable ingress route");
1522            let second_state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig {
1523                enabled: true,
1524                proxy_url: format!("http://{http_addr}"),
1525                socks_url: format!("http://{socks_addr}"),
1526                ..NetworkProxyConfig::default()
1527            }));
1528            let second = NetworkProxy::builder()
1529                .state(second_state)
1530                .build()
1531                .await
1532                .expect("second proxy should share the stable ingress");
1533            let second_handle = second
1534                .run()
1535                .await
1536                .expect("start second stable ingress route");
1537            assert_eq!(second.http_addr, proxy.http_addr);
1538            assert_eq!(second.socks_addr, proxy.socks_addr);
1539            assert_ne!(
1540                second.network_proxy_restricting_sid(None),
1541                proxy.network_proxy_restricting_sid(None)
1542            );
1543            let differently_configured =
1544                Arc::new(network_proxy_state_for_policy(NetworkProxyConfig {
1545                    enabled: true,
1546                    proxy_url: "http://127.0.0.1:1".to_string(),
1547                    socks_url: "http://127.0.0.1:2".to_string(),
1548                    allow_local_binding: true,
1549                    ..NetworkProxyConfig::default()
1550                }));
1551            let third = NetworkProxy::builder()
1552                .state(differently_configured)
1553                .build()
1554                .await
1555                .expect("different route config should share the stable ingress");
1556            let third_handle = third
1557                .run()
1558                .await
1559                .expect("start differently configured stable route");
1560            assert_eq!(third.http_addr, proxy.http_addr);
1561            assert_eq!(third.socks_addr, proxy.socks_addr);
1562            assert!(third.allow_local_binding());
1563            let replacement = crate::state::build_config_state(
1564                NetworkProxyConfig {
1565                    enabled: true,
1566                    proxy_url: format!("http://{http_addr}"),
1567                    socks_url: format!("http://{socks_addr}"),
1568                    allow_local_binding: true,
1569                    ..NetworkProxyConfig::default()
1570                },
1571                Default::default(),
1572            )
1573            .expect("replacement config state");
1574            proxy
1575                .replace_config_state(replacement)
1576                .await
1577                .expect("live route should accept a local-binding policy change");
1578            assert!(proxy.allow_local_binding());
1579            third_handle
1580                .shutdown()
1581                .await
1582                .expect("stop differently configured stable route");
1583            second_handle
1584                .shutdown()
1585                .await
1586                .expect("stop second stable ingress route");
1587            handle.shutdown().await.expect("stop stable ingress route");
1588            assert_eq!(proxy.network_proxy_restricting_sid(None), None);
1589        }
1590        #[cfg(not(target_os = "windows"))]
1591        {
1592            assert_ne!(proxy.http_addr.port(), 0);
1593            assert_ne!(proxy.socks_addr.port(), 0);
1594        }
1595    }
1596
1597    #[tokio::test]
1598    async fn non_codex_managed_proxy_builder_uses_configured_ports() {
1599        let settings = NetworkProxyConfig {
1600            proxy_url: "http://127.0.0.1:43128".to_string(),
1601            socks_url: "http://127.0.0.1:48081".to_string(),
1602            ..NetworkProxyConfig::default()
1603        };
1604        let state = Arc::new(network_proxy_state_for_policy(settings));
1605        let proxy = NetworkProxy::builder()
1606            .state(state)
1607            .managed_by_codex(/*managed_by_codex*/ false)
1608            .build()
1609            .await
1610            .unwrap();
1611
1612        assert_eq!(
1613            proxy.http_addr,
1614            "127.0.0.1:43128".parse::<SocketAddr>().unwrap()
1615        );
1616        assert_eq!(
1617            proxy.socks_addr,
1618            "127.0.0.1:48081".parse::<SocketAddr>().unwrap()
1619        );
1620    }
1621
1622    #[tokio::test]
1623    async fn prepare_for_environment_keeps_env_and_sandbox_ports_in_sync() -> Result<()> {
1624        #[cfg(target_os = "windows")]
1625        let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap();
1626        let state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig {
1627            enabled: true,
1628            ..NetworkProxyConfig::default()
1629        }));
1630        let proxy = NetworkProxy::builder().state(state).build().await?;
1631        let handle = proxy.run().await?;
1632
1633        let base_env = HashMap::from([("PRESERVED".to_string(), "value".to_string())]);
1634        let local = proxy.prepare_for_optional_environment(base_env.clone(), Some("local"))?;
1635        let remote = proxy.prepare_for_remote_environment(HashMap::new(), "remote")?;
1636
1637        assert_eq!(
1638            local.env.get("PRESERVED").map(String::as_str),
1639            Some("value")
1640        );
1641        #[cfg(target_os = "windows")]
1642        {
1643            assert_eq!(
1644                local.env.get("HTTP_PROXY"),
1645                Some(&format!("http://{}", proxy.http_addr()))
1646            );
1647            assert_ne!(local.env.get("HTTP_PROXY"), remote.env.get("HTTP_PROXY"));
1648            assert!(proxy.network_proxy_restricting_sid(Some("local")).is_some());
1649            assert_eq!(proxy.network_proxy_restricting_sid(Some("remote")), None);
1650        }
1651        #[cfg(not(target_os = "windows"))]
1652        {
1653            assert_ne!(local.env.get("HTTP_PROXY"), remote.env.get("HTTP_PROXY"));
1654            assert_ne!(
1655                local.env.get("HTTP_PROXY"),
1656                Some(&format!("http://{}", proxy.http_addr()))
1657            );
1658            assert_ne!(
1659                remote.env.get("HTTP_PROXY"),
1660                Some(&format!("http://{}", proxy.http_addr()))
1661            );
1662        }
1663        for prepared in [&local, &remote] {
1664            let http_port = prepared
1665                .env
1666                .get("HTTP_PROXY")
1667                .and_then(|value| value.strip_prefix("http://"))
1668                .and_then(|value| value.parse::<SocketAddr>().ok())
1669                .map(|addr| addr.port())
1670                .expect("managed HTTP proxy address");
1671            let socks_port = prepared
1672                .env
1673                .get("ALL_PROXY")
1674                .and_then(|value| value.strip_prefix("socks5h://"))
1675                .and_then(|value| value.parse::<SocketAddr>().ok())
1676                .map(|addr| addr.port())
1677                .expect("managed SOCKS proxy address");
1678            let mut expected_ports = vec![http_port, socks_port];
1679            expected_ports.sort_unstable();
1680            expected_ports.dedup();
1681            assert_eq!(
1682                prepared.sandbox_context,
1683                ManagedNetworkSandboxContext {
1684                    loopback_ports: expected_ports,
1685                    allow_local_binding: false,
1686                }
1687            );
1688        }
1689        let mut legacy_env = base_env;
1690        proxy.apply_to_env_for_environment(&mut legacy_env, "local")?;
1691        assert_eq!(legacy_env, local.env);
1692
1693        handle.shutdown().await?;
1694        Ok(())
1695    }
1696
1697    #[tokio::test]
1698    async fn remote_launch_config_carries_execution_scope() -> Result<()> {
1699        #[cfg(target_os = "windows")]
1700        let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap();
1701        let state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig::default()));
1702        let proxy = match NetworkProxy::builder().state(state).build().await {
1703            Ok(proxy) => proxy,
1704            Err(err) => {
1705                if err
1706                    .chain()
1707                    .any(|cause| cause.to_string().contains("Operation not permitted"))
1708                {
1709                    return Ok(());
1710                }
1711                return Err(err);
1712            }
1713        };
1714
1715        let scoped = proxy.for_execution("remote-env", "execution-1", "token-1".to_string())?;
1716        let launch = scoped.remote_launch_config().await?;
1717        let prepared = scoped.prepare_for_optional_environment(
1718            HashMap::from([(
1719                PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(),
1720                "foreign-token".to_string(),
1721            )]),
1722            /*environment_id*/ None,
1723        )?;
1724
1725        assert_eq!(launch.environment_id.as_deref(), Some("remote-env"));
1726        assert_eq!(launch.execution_id.as_deref(), Some("execution-1"));
1727        assert_eq!(
1728            prepared
1729                .env
1730                .get(PROXY_ATTRIBUTION_TOKEN_ENV_KEY)
1731                .map(String::as_str),
1732            Some("token-1")
1733        );
1734        Ok(())
1735    }
1736
1737    #[tokio::test]
1738    async fn managed_proxy_builder_lazily_upgrades_disabled_socks() {
1739        #[cfg(target_os = "windows")]
1740        let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap();
1741        let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
1742        let http_addr = http_listener.local_addr().unwrap();
1743        drop(http_listener);
1744        let occupied_socks = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
1745        let socks_addr = occupied_socks.local_addr().unwrap();
1746        let settings = NetworkProxyConfig {
1747            enabled: true,
1748            enable_socks5: false,
1749            proxy_url: format!("http://{http_addr}"),
1750            socks_url: format!("http://{socks_addr}"),
1751            ..NetworkProxyConfig::default()
1752        };
1753        let state = Arc::new(network_proxy_state_for_policy(settings));
1754        let proxy = match NetworkProxy::builder().state(state).build().await {
1755            Ok(proxy) => proxy,
1756            Err(err) => {
1757                if err
1758                    .chain()
1759                    .any(|cause| cause.to_string().contains("Operation not permitted"))
1760                {
1761                    return;
1762                }
1763                panic!("failed to build managed proxy: {err:#}");
1764            }
1765        };
1766
1767        assert!(proxy.http_addr.ip().is_loopback());
1768        assert_ne!(proxy.http_addr.port(), 0);
1769        assert_eq!(proxy.socks_addr, socks_addr);
1770        #[cfg(target_os = "windows")]
1771        {
1772            assert_eq!(proxy.http_addr, http_addr);
1773            assert!(proxy.reserved_listeners.is_none());
1774            assert!(proxy.windows_runtime.is_some());
1775            assert_eq!(proxy.network_proxy_restricting_sid(None), None);
1776            let handle = proxy.run().await.expect("start HTTP-only stable route");
1777            assert!(proxy.network_proxy_restricting_sid(None).is_some());
1778            let prepared_before_upgrade = proxy
1779                .prepare_for_optional_environment(
1780                    HashMap::from([(
1781                        WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY.to_string(),
1782                        "1,2".to_string(),
1783                    )]),
1784                    None,
1785                )
1786                .expect("prepare stable Windows proxy");
1787            assert_eq!(
1788                prepared_before_upgrade.sandbox_context.loopback_ports,
1789                vec![proxy.http_addr.port()]
1790            );
1791            assert_eq!(
1792                prepared_before_upgrade
1793                    .env
1794                    .get(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY),
1795                Some(&proxy.http_addr.port().to_string())
1796            );
1797            assert_eq!(
1798                prepared_before_upgrade.env.get("ALL_PROXY"),
1799                Some(&format!("http://{}", proxy.http_addr))
1800            );
1801            let environment_id = "cached-before-socks-upgrade";
1802            let environment_before_upgrade = proxy
1803                .prepare_for_optional_environment(HashMap::new(), Some(environment_id))
1804                .expect("prepare cached HTTP-only environment");
1805            assert_eq!(
1806                environment_before_upgrade.sandbox_context.loopback_ports,
1807                vec![proxy.http_addr.port()]
1808            );
1809
1810            let requested_socks =
1811                StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
1812            let requested_socks_addr = requested_socks.local_addr().unwrap();
1813            assert_ne!(requested_socks_addr, socks_addr);
1814            let socks_state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig {
1815                enabled: true,
1816                enable_socks5: true,
1817                proxy_url: format!("http://{http_addr}"),
1818                socks_url: format!("socks5://{requested_socks_addr}"),
1819                ..NetworkProxyConfig::default()
1820            }));
1821            let socks_proxy = NetworkProxy::builder()
1822                .state(socks_state)
1823                .build()
1824                .await
1825                .expect("upgrade stable ingress to SOCKS5");
1826            let actual_socks_addr = socks_proxy.socks_addr();
1827            assert_eq!(socks_proxy.http_addr(), proxy.http_addr());
1828            assert!(actual_socks_addr.ip().is_loopback());
1829            assert_ne!(actual_socks_addr, requested_socks_addr);
1830            assert_eq!(proxy.socks_addr(), actual_socks_addr);
1831            let socks_handle = socks_proxy
1832                .run()
1833                .await
1834                .expect("start SOCKS-enabled stable route");
1835
1836            let mut expected_ports = vec![proxy.http_addr.port(), actual_socks_addr.port()];
1837            expected_ports.sort_unstable();
1838            let prepared_after_upgrade = proxy
1839                .prepare_for_optional_environment(HashMap::new(), None)
1840                .expect("re-prepare HTTP-only route after SOCKS5 upgrade");
1841            let environment_after_upgrade = proxy
1842                .prepare_for_optional_environment(HashMap::new(), Some(environment_id))
1843                .expect("re-prepare cached environment after SOCKS5 upgrade");
1844            for prepared in [&prepared_after_upgrade, &environment_after_upgrade] {
1845                assert_eq!(prepared.sandbox_context.loopback_ports, expected_ports);
1846                assert_eq!(
1847                    prepared.env.get(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY),
1848                    Some(
1849                        &expected_ports
1850                            .iter()
1851                            .map(u16::to_string)
1852                            .collect::<Vec<_>>()
1853                            .join(",")
1854                    )
1855                );
1856                assert_eq!(
1857                    prepared.env.get("ALL_PROXY"),
1858                    Some(&format!("http://{}", proxy.http_addr))
1859                );
1860            }
1861            let socks_prepared = socks_proxy
1862                .prepare_for_optional_environment(HashMap::new(), None)
1863                .expect("prepare SOCKS-enabled route");
1864            assert_eq!(
1865                socks_prepared.sandbox_context.loopback_ports,
1866                expected_ports
1867            );
1868            assert_eq!(
1869                socks_prepared.env.get("ALL_PROXY"),
1870                Some(&format!("socks5h://{actual_socks_addr}"))
1871            );
1872            let remote = proxy
1873                .prepare_for_remote_environment(
1874                    HashMap::from([(
1875                        WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY.to_string(),
1876                        "1,2".to_string(),
1877                    )]),
1878                    "remote",
1879                )
1880                .expect("prepare HTTP-only trusted bridge proxy");
1881            assert_eq!(remote.sandbox_context.loopback_ports.len(), 1);
1882            assert_eq!(remote.env.get(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY), None);
1883            assert_eq!(proxy.network_proxy_restricting_sid(Some("remote")), None);
1884            socks_handle
1885                .shutdown()
1886                .await
1887                .expect("stop SOCKS-enabled stable route");
1888            handle
1889                .shutdown()
1890                .await
1891                .expect("stop HTTP-only stable route");
1892            assert_eq!(proxy.network_proxy_restricting_sid(None), None);
1893        }
1894        #[cfg(not(target_os = "windows"))]
1895        assert!(
1896            proxy
1897                .reserved_listeners
1898                .as_ref()
1899                .expect("managed builder should reserve listeners")
1900                .take_socks()
1901                .is_none()
1902        );
1903        drop(proxy);
1904        drop(occupied_socks);
1905    }
1906
1907    #[cfg(target_os = "windows")]
1908    #[test]
1909    fn windows_managed_loopback_addr_clamps_non_loopback_inputs() {
1910        assert_eq!(
1911            windows_managed_loopback_addr("0.0.0.0:3128".parse::<SocketAddr>().unwrap()),
1912            "127.0.0.1:3128".parse::<SocketAddr>().unwrap()
1913        );
1914        assert_eq!(
1915            windows_managed_loopback_addr("[::]:8081".parse::<SocketAddr>().unwrap()),
1916            "127.0.0.1:8081".parse::<SocketAddr>().unwrap()
1917        );
1918    }
1919
1920    #[cfg(target_os = "windows")]
1921    #[test]
1922    fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() {
1923        let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
1924        let busy_port = occupied.local_addr().unwrap().port();
1925
1926        let reserved = reserve_windows_managed_listeners(
1927            SocketAddr::from(([127, 0, 0, 1], busy_port)),
1928            SocketAddr::from(([127, 0, 0, 1], 48081)),
1929            /*reserve_socks_listener*/ false,
1930        )
1931        .unwrap();
1932
1933        assert!(reserved.socks_listener.is_none());
1934        assert!(
1935            reserved
1936                .http_listener
1937                .local_addr()
1938                .unwrap()
1939                .ip()
1940                .is_loopback()
1941        );
1942        assert_ne!(
1943            reserved.http_listener.local_addr().unwrap().port(),
1944            busy_port
1945        );
1946    }
1947
1948    #[test]
1949    fn proxy_url_env_value_resolves_lowercase_aliases() {
1950        let mut env = HashMap::new();
1951        env.insert(
1952            "http_proxy".to_string(),
1953            "http://127.0.0.1:3128".to_string(),
1954        );
1955
1956        assert_eq!(
1957            proxy_url_env_value(&env, "HTTP_PROXY"),
1958            Some("http://127.0.0.1:3128")
1959        );
1960    }
1961
1962    #[test]
1963    fn has_proxy_url_env_vars_detects_lowercase_aliases() {
1964        let mut env = HashMap::new();
1965        env.insert(
1966            "all_proxy".to_string(),
1967            "socks5h://127.0.0.1:8081".to_string(),
1968        );
1969
1970        assert_eq!(has_proxy_url_env_vars(&env), true);
1971    }
1972
1973    #[test]
1974    fn has_proxy_url_env_vars_detects_websocket_proxy_keys() {
1975        let mut env = HashMap::new();
1976        env.insert("wss_proxy".to_string(), "http://127.0.0.1:3128".to_string());
1977
1978        assert_eq!(has_proxy_url_env_vars(&env), true);
1979    }
1980
1981    #[test]
1982    fn apply_proxy_env_overrides_sets_common_tool_vars() {
1983        let mut env = HashMap::new();
1984        apply_proxy_env_overrides(
1985            &mut env,
1986            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1987            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1988            /*socks_enabled*/ true,
1989            /*allow_local_binding*/ false,
1990            /*mitm_ca_trust_bundle*/ None,
1991        );
1992
1993        assert_eq!(
1994            env.get("HTTP_PROXY"),
1995            Some(&"http://127.0.0.1:3128".to_string())
1996        );
1997        assert_eq!(
1998            env.get("WS_PROXY"),
1999            Some(&"http://127.0.0.1:3128".to_string())
2000        );
2001        assert_eq!(
2002            env.get("WSS_PROXY"),
2003            Some(&"http://127.0.0.1:3128".to_string())
2004        );
2005        assert_eq!(
2006            env.get("npm_config_proxy"),
2007            Some(&"http://127.0.0.1:3128".to_string())
2008        );
2009        assert_eq!(
2010            env.get("ALL_PROXY"),
2011            Some(&"socks5h://127.0.0.1:8081".to_string())
2012        );
2013        assert_eq!(
2014            env.get("FTP_PROXY"),
2015            Some(&"socks5h://127.0.0.1:8081".to_string())
2016        );
2017        assert_eq!(env.get("NO_PROXY"), Some(&String::new()));
2018        assert_eq!(env.get(PROXY_ACTIVE_ENV_KEY), Some(&"1".to_string()));
2019        assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string()));
2020        assert_eq!(
2021            env.get(ELECTRON_GET_USE_PROXY_ENV_KEY),
2022            Some(&"true".to_string())
2023        );
2024        assert_eq!(env.get(NODE_USE_ENV_PROXY_ENV_KEY), Some(&"1".to_string()));
2025        #[cfg(target_os = "macos")]
2026        assert_eq!(
2027            env.get(GIT_SSH_COMMAND_ENV_KEY),
2028            Some(
2029                &"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'"
2030                    .to_string()
2031            )
2032        );
2033        #[cfg(not(target_os = "macos"))]
2034        assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
2035    }
2036
2037    #[test]
2038    fn apply_proxy_env_overrides_keeps_local_targets_direct_when_local_binding_enabled() {
2039        let mut env = HashMap::new();
2040        apply_proxy_env_overrides(
2041            &mut env,
2042            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
2043            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
2044            /*socks_enabled*/ true,
2045            /*allow_local_binding*/ true,
2046            /*mitm_ca_trust_bundle*/ None,
2047        );
2048
2049        assert_eq!(
2050            env.get("NO_PROXY"),
2051            Some(&DEFAULT_NO_PROXY_VALUE.to_string())
2052        );
2053    }
2054
2055    #[test]
2056    fn apply_proxy_env_overrides_sets_only_expected_env_keys() {
2057        let mut env = HashMap::new();
2058        apply_proxy_env_overrides(
2059            &mut env,
2060            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
2061            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
2062            /*socks_enabled*/ true,
2063            /*allow_local_binding*/ false,
2064            /*mitm_ca_trust_bundle*/ None,
2065        );
2066
2067        for key in env.keys() {
2068            let is_managed_git_ssh_key =
2069                cfg!(target_os = "macos") && key == GIT_SSH_COMMAND_ENV_KEY;
2070            assert!(
2071                PROXY_ENV_KEYS.contains(&key.as_str()) || is_managed_git_ssh_key,
2072                "proxy env writer set unexpected key: {key}"
2073            );
2074        }
2075    }
2076
2077    #[test]
2078    fn apply_proxy_env_overrides_sets_mitm_ca_trust_bundle_vars() {
2079        let mut env = HashMap::new();
2080        let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem");
2081        let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle {
2082            path: mitm_ca_trust_bundle_path.to_path_buf(),
2083            startup_env_values: HashMap::new(),
2084        };
2085        apply_proxy_env_overrides(
2086            &mut env,
2087            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
2088            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
2089            /*socks_enabled*/ true,
2090            /*allow_local_binding*/ false,
2091            Some(&mitm_ca_trust_bundle),
2092        );
2093
2094        for key in crate::certs::CUSTOM_CA_ENV_KEYS {
2095            assert_eq!(
2096                env.get(key),
2097                Some(&mitm_ca_trust_bundle_path.display().to_string())
2098            );
2099        }
2100    }
2101
2102    #[test]
2103    fn apply_proxy_env_overrides_preserves_command_scoped_mitm_ca_override() {
2104        let command_ca_bundle_path = "/tmp/command-ca.pem".to_string();
2105        let mut env = HashMap::from([(
2106            "REQUESTS_CA_BUNDLE".to_string(),
2107            command_ca_bundle_path.clone(),
2108        )]);
2109        let mitm_ca_trust_bundle_path = Path::new("/tmp/codex-proxy/ca-bundle.pem");
2110        let mitm_ca_trust_bundle = crate::certs::ManagedMitmCaTrustBundle {
2111            path: mitm_ca_trust_bundle_path.to_path_buf(),
2112            startup_env_values: HashMap::new(),
2113        };
2114
2115        apply_proxy_env_overrides(
2116            &mut env,
2117            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
2118            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
2119            /*socks_enabled*/ true,
2120            /*allow_local_binding*/ false,
2121            Some(&mitm_ca_trust_bundle),
2122        );
2123
2124        assert_eq!(env.get("REQUESTS_CA_BUNDLE"), Some(&command_ca_bundle_path));
2125        assert_eq!(
2126            env.get("SSL_CERT_FILE"),
2127            Some(&mitm_ca_trust_bundle_path.display().to_string())
2128        );
2129    }
2130
2131    #[test]
2132    fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() {
2133        let mut env = HashMap::new();
2134        apply_proxy_env_overrides(
2135            &mut env,
2136            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
2137            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
2138            /*socks_enabled*/ false,
2139            /*allow_local_binding*/ true,
2140            /*mitm_ca_trust_bundle*/ None,
2141        );
2142
2143        assert_eq!(
2144            env.get("ALL_PROXY"),
2145            Some(&"http://127.0.0.1:3128".to_string())
2146        );
2147        assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"1".to_string()));
2148    }
2149
2150    #[test]
2151    fn apply_proxy_env_overrides_uses_plain_http_proxy_url() {
2152        let mut env = HashMap::new();
2153        apply_proxy_env_overrides(
2154            &mut env,
2155            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
2156            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
2157            /*socks_enabled*/ true,
2158            /*allow_local_binding*/ false,
2159            /*mitm_ca_trust_bundle*/ None,
2160        );
2161
2162        assert_eq!(
2163            env.get("HTTP_PROXY"),
2164            Some(&"http://127.0.0.1:3128".to_string())
2165        );
2166        assert_eq!(
2167            env.get("HTTPS_PROXY"),
2168            Some(&"http://127.0.0.1:3128".to_string())
2169        );
2170        assert_eq!(
2171            env.get("WS_PROXY"),
2172            Some(&"http://127.0.0.1:3128".to_string())
2173        );
2174        assert_eq!(
2175            env.get("WSS_PROXY"),
2176            Some(&"http://127.0.0.1:3128".to_string())
2177        );
2178        assert_eq!(
2179            env.get("ALL_PROXY"),
2180            Some(&"socks5h://127.0.0.1:8081".to_string())
2181        );
2182        #[cfg(target_os = "macos")]
2183        assert_eq!(
2184            env.get(GIT_SSH_COMMAND_ENV_KEY),
2185            Some(
2186                &"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'"
2187                    .to_string()
2188            )
2189        );
2190        #[cfg(not(target_os = "macos"))]
2191        assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
2192    }
2193
2194    #[cfg(target_os = "macos")]
2195    #[test]
2196    fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() {
2197        let mut env = HashMap::new();
2198        env.insert(
2199            GIT_SSH_COMMAND_ENV_KEY.to_string(),
2200            "ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(),
2201        );
2202        apply_proxy_env_overrides(
2203            &mut env,
2204            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
2205            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
2206            /*socks_enabled*/ true,
2207            /*allow_local_binding*/ false,
2208            /*mitm_ca_trust_bundle*/ None,
2209        );
2210
2211        assert_eq!(
2212            env.get(GIT_SSH_COMMAND_ENV_KEY),
2213            Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string())
2214        );
2215    }
2216
2217    #[cfg(target_os = "macos")]
2218    #[test]
2219    fn apply_proxy_env_overrides_preserves_unmarked_git_ssh_command_with_proxy_shape() {
2220        let mut env = HashMap::new();
2221        env.insert(
2222            GIT_SSH_COMMAND_ENV_KEY.to_string(),
2223            "ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string(),
2224        );
2225        apply_proxy_env_overrides(
2226            &mut env,
2227            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
2228            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
2229            /*socks_enabled*/ true,
2230            /*allow_local_binding*/ false,
2231            /*mitm_ca_trust_bundle*/ None,
2232        );
2233
2234        assert_eq!(
2235            env.get(GIT_SSH_COMMAND_ENV_KEY),
2236            Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
2237        );
2238    }
2239
2240    #[cfg(target_os = "macos")]
2241    #[test]
2242    fn apply_proxy_env_overrides_refreshes_previous_codex_proxy_git_ssh_command() {
2243        let mut env = HashMap::new();
2244        env.insert(
2245            GIT_SSH_COMMAND_ENV_KEY.to_string(),
2246            codex_proxy_git_ssh_command(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081)),
2247        );
2248
2249        apply_proxy_env_overrides(
2250            &mut env,
2251            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 43128),
2252            SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
2253            /*socks_enabled*/ true,
2254            /*allow_local_binding*/ false,
2255            /*mitm_ca_trust_bundle*/ None,
2256        );
2257
2258        assert_eq!(
2259            env.get(GIT_SSH_COMMAND_ENV_KEY),
2260            Some(&codex_proxy_git_ssh_command(SocketAddr::new(
2261                IpAddr::V4(Ipv4Addr::LOCALHOST),
2262                48081,
2263            )))
2264        );
2265    }
2266}