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(¤t_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 ¤t_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(¤t_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 let (http_addr, socks_addr) = config::clamp_bind_addrs(
214 requested_http_addr,
215 requested_socks_addr,
216 ¤t_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 ¤t_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 f.debug_struct("NetworkProxy")
330 .field("http_addr", &self.http_addr)
331 .field("socks_addr", &self.socks_addr)
332 .finish_non_exhaustive()
333 }
334}
335
336impl PartialEq for NetworkProxy {
337 fn eq(&self, other: &Self) -> bool {
338 self.http_addr == other.http_addr
339 && self.socks_addr == other.socks_addr
340 && self.runtime_settings() == other.runtime_settings()
341 }
342}
343
344impl Eq for NetworkProxy {}
345
346pub const PROXY_URL_ENV_KEYS: &[&str] = &[
347 "HTTP_PROXY",
348 "HTTPS_PROXY",
349 "WS_PROXY",
350 "WSS_PROXY",
351 "ALL_PROXY",
352 "FTP_PROXY",
353 "YARN_HTTP_PROXY",
354 "YARN_HTTPS_PROXY",
355 "NPM_CONFIG_HTTP_PROXY",
356 "NPM_CONFIG_HTTPS_PROXY",
357 "NPM_CONFIG_PROXY",
358 "BUNDLE_HTTP_PROXY",
359 "BUNDLE_HTTPS_PROXY",
360 "PIP_PROXY",
361 "DOCKER_HTTP_PROXY",
362 "DOCKER_HTTPS_PROXY",
363];
364
365pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"];
366pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE";
367pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING";
368const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY";
369#[cfg(any(target_os = "macos", test))]
370const GIT_SSH_COMMAND_ENV_KEY: &str = "GIT_SSH_COMMAND";
371pub const PROXY_ENV_KEYS: &[&str] = &[
372 PROXY_ACTIVE_ENV_KEY,
373 ALLOW_LOCAL_BINDING_ENV_KEY,
374 ELECTRON_GET_USE_PROXY_ENV_KEY,
375 "NODE_USE_ENV_PROXY",
376 "HTTP_PROXY",
377 "HTTPS_PROXY",
378 "http_proxy",
379 "https_proxy",
380 "YARN_HTTP_PROXY",
381 "YARN_HTTPS_PROXY",
382 "npm_config_http_proxy",
383 "npm_config_https_proxy",
384 "npm_config_proxy",
385 "NPM_CONFIG_HTTP_PROXY",
386 "NPM_CONFIG_HTTPS_PROXY",
387 "NPM_CONFIG_PROXY",
388 "BUNDLE_HTTP_PROXY",
389 "BUNDLE_HTTPS_PROXY",
390 "PIP_PROXY",
391 "DOCKER_HTTP_PROXY",
392 "DOCKER_HTTPS_PROXY",
393 "WS_PROXY",
394 "WSS_PROXY",
395 "ws_proxy",
396 "wss_proxy",
397 "NO_PROXY",
398 "no_proxy",
399 "npm_config_noproxy",
400 "NPM_CONFIG_NOPROXY",
401 "YARN_NO_PROXY",
402 "BUNDLE_NO_PROXY",
403 "ALL_PROXY",
404 "all_proxy",
405 "FTP_PROXY",
406 "ftp_proxy",
407];
408
409#[cfg(target_os = "macos")]
410pub const PROXY_GIT_SSH_COMMAND_ENV_KEY: &str = GIT_SSH_COMMAND_ENV_KEY;
411
412const FTP_PROXY_ENV_KEYS: &[&str] = &["FTP_PROXY", "ftp_proxy"];
413const WEBSOCKET_PROXY_ENV_KEYS: &[&str] = &["WS_PROXY", "WSS_PROXY", "ws_proxy", "wss_proxy"];
414
415pub const NO_PROXY_ENV_KEYS: &[&str] = &[
416 "NO_PROXY",
417 "no_proxy",
418 "npm_config_noproxy",
419 "NPM_CONFIG_NOPROXY",
420 "YARN_NO_PROXY",
421 "BUNDLE_NO_PROXY",
422];
423
424pub const DEFAULT_NO_PROXY_VALUE: &str = concat!(
425 "localhost,127.0.0.1,::1,",
426 "10.0.0.0/8,",
427 "172.16.0.0/12,",
428 "192.168.0.0/16"
429);
430
431#[cfg(target_os = "macos")]
432pub const CODEX_PROXY_GIT_SSH_COMMAND_MARKER: &str = "CODEX_PROXY_GIT_SSH_COMMAND=1 ";
433#[cfg(target_os = "macos")]
434const CODEX_PROXY_GIT_SSH_COMMAND_PREFIX: &str =
435 "CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x ";
436#[cfg(target_os = "macos")]
437const CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX: &str = " %h %p'";
438
439pub fn proxy_url_env_value<'a>(
440 env: &'a HashMap<String, String>,
441 canonical_key: &str,
442) -> Option<&'a str> {
443 if let Some(value) = env.get(canonical_key) {
444 return Some(value.as_str());
445 }
446 let lower_key = canonical_key.to_ascii_lowercase();
447 env.get(lower_key.as_str()).map(String::as_str)
448}
449
450pub fn has_proxy_url_env_vars(env: &HashMap<String, String>) -> bool {
451 PROXY_URL_ENV_KEYS
452 .iter()
453 .any(|key| proxy_url_env_value(env, key).is_some_and(|value| !value.trim().is_empty()))
454}
455
456fn set_env_keys(env: &mut HashMap<String, String>, keys: &[&str], value: &str) {
457 for key in keys {
458 env.insert((*key).to_string(), value.to_string());
459 }
460}
461
462#[cfg(target_os = "macos")]
463fn codex_proxy_git_ssh_command(socks_addr: SocketAddr) -> String {
464 format!("{CODEX_PROXY_GIT_SSH_COMMAND_PREFIX}{socks_addr}{CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX}")
465}
466
467#[cfg(target_os = "macos")]
468fn is_codex_proxy_git_ssh_command(command: &str) -> bool {
469 command.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_PREFIX)
470 && command.ends_with(CODEX_PROXY_GIT_SSH_COMMAND_SUFFIX)
471}
472
473fn apply_proxy_env_overrides(
474 env: &mut HashMap<String, String>,
475 http_addr: SocketAddr,
476 socks_addr: SocketAddr,
477 socks_enabled: bool,
478 allow_local_binding: bool,
479) {
480 let http_proxy_url = format!("http://{http_addr}");
481 let socks_proxy_url = format!("socks5h://{socks_addr}");
482 env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string());
483 env.insert(
484 ALLOW_LOCAL_BINDING_ENV_KEY.to_string(),
485 if allow_local_binding {
486 "1".to_string()
487 } else {
488 "0".to_string()
489 },
490 );
491
492 set_env_keys(
494 env,
495 &[
496 "HTTP_PROXY",
497 "HTTPS_PROXY",
498 "http_proxy",
499 "https_proxy",
500 "YARN_HTTP_PROXY",
501 "YARN_HTTPS_PROXY",
502 "npm_config_http_proxy",
503 "npm_config_https_proxy",
504 "npm_config_proxy",
505 "NPM_CONFIG_HTTP_PROXY",
506 "NPM_CONFIG_HTTPS_PROXY",
507 "NPM_CONFIG_PROXY",
508 "BUNDLE_HTTP_PROXY",
509 "BUNDLE_HTTPS_PROXY",
510 "PIP_PROXY",
511 "DOCKER_HTTP_PROXY",
512 "DOCKER_HTTPS_PROXY",
513 ],
514 &http_proxy_url,
515 );
516 set_env_keys(env, WEBSOCKET_PROXY_ENV_KEYS, &http_proxy_url);
519
520 set_env_keys(env, NO_PROXY_ENV_KEYS, DEFAULT_NO_PROXY_VALUE);
524
525 env.insert(
526 ELECTRON_GET_USE_PROXY_ENV_KEY.to_string(),
527 "true".to_string(),
528 );
529
530 env.insert("NODE_USE_ENV_PROXY".to_string(), "1".to_string());
532
533 if socks_enabled {
537 set_env_keys(env, ALL_PROXY_ENV_KEYS, &socks_proxy_url);
538 set_env_keys(env, FTP_PROXY_ENV_KEYS, &socks_proxy_url);
539 } else {
540 set_env_keys(env, ALL_PROXY_ENV_KEYS, &http_proxy_url);
541 set_env_keys(env, FTP_PROXY_ENV_KEYS, &http_proxy_url);
542 }
543
544 #[cfg(target_os = "macos")]
545 if socks_enabled {
546 match env.get(GIT_SSH_COMMAND_ENV_KEY) {
550 Some(command) if !is_codex_proxy_git_ssh_command(command) => {}
551 _ => {
552 env.insert(
553 GIT_SSH_COMMAND_ENV_KEY.to_string(),
554 codex_proxy_git_ssh_command(socks_addr),
555 );
556 }
557 }
558 }
559}
560
561impl NetworkProxy {
562 pub fn builder() -> NetworkProxyBuilder {
563 NetworkProxyBuilder::default()
564 }
565
566 pub fn http_addr(&self) -> SocketAddr {
567 self.http_addr
568 }
569
570 pub fn socks_addr(&self) -> SocketAddr {
571 self.socks_addr
572 }
573
574 pub async fn current_cfg(&self) -> Result<config::NetworkProxyConfig> {
575 self.state.current_cfg().await
576 }
577
578 pub async fn add_allowed_domain(&self, host: &str) -> Result<()> {
579 self.state.add_allowed_domain(host).await
580 }
581
582 pub async fn add_denied_domain(&self, host: &str) -> Result<()> {
583 self.state.add_denied_domain(host).await
584 }
585
586 pub fn allow_local_binding(&self) -> bool {
587 self.runtime_settings().allow_local_binding
588 }
589
590 pub fn allow_unix_sockets(&self) -> Arc<[String]> {
591 self.runtime_settings().allow_unix_sockets
592 }
593
594 pub fn dangerously_allow_all_unix_sockets(&self) -> bool {
595 self.runtime_settings().dangerously_allow_all_unix_sockets
596 }
597
598 pub fn apply_to_env(&self, env: &mut HashMap<String, String>) {
599 let allow_local_binding = self.allow_local_binding();
600 apply_proxy_env_overrides(
603 env,
604 self.http_addr,
605 self.socks_addr,
606 self.socks_enabled,
607 allow_local_binding,
608 );
609 }
610
611 pub async fn replace_config_state(&self, new_state: ConfigState) -> Result<()> {
612 let current_cfg = self.state.current_cfg().await?;
613 anyhow::ensure!(
614 new_state.config.network.enabled == current_cfg.network.enabled,
615 "cannot update network.enabled on a running proxy"
616 );
617 anyhow::ensure!(
618 new_state.config.network.proxy_url == current_cfg.network.proxy_url,
619 "cannot update network.proxy_url on a running proxy"
620 );
621 anyhow::ensure!(
622 new_state.config.network.socks_url == current_cfg.network.socks_url,
623 "cannot update network.socks_url on a running proxy"
624 );
625 anyhow::ensure!(
626 new_state.config.network.enable_socks5 == current_cfg.network.enable_socks5,
627 "cannot update network.enable_socks5 on a running proxy"
628 );
629 anyhow::ensure!(
630 new_state.config.network.enable_socks5_udp == current_cfg.network.enable_socks5_udp,
631 "cannot update network.enable_socks5_udp on a running proxy"
632 );
633
634 let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config);
635 self.state.replace_config_state(new_state).await?;
636 let mut guard = self
637 .runtime_settings
638 .write()
639 .unwrap_or_else(std::sync::PoisonError::into_inner);
640 *guard = settings;
641 Ok(())
642 }
643
644 fn runtime_settings(&self) -> NetworkProxyRuntimeSettings {
645 self.runtime_settings
646 .read()
647 .unwrap_or_else(std::sync::PoisonError::into_inner)
648 .clone()
649 }
650
651 pub async fn run(&self) -> Result<NetworkProxyHandle> {
652 let current_cfg = self.state.current_cfg().await?;
653 if !current_cfg.network.enabled {
654 warn!("network.enabled is false; skipping proxy listeners");
655 return Ok(NetworkProxyHandle::noop());
656 }
657
658 if !unix_socket_permissions_supported() {
659 warn!(
660 "allowUnixSockets and dangerouslyAllowAllUnixSockets are macOS-only; requests will be rejected on this platform"
661 );
662 }
663
664 let reserved_listeners = self.reserved_listeners.as_ref();
665 let http_listener = reserved_listeners.and_then(|listeners| listeners.take_http());
666 let socks_listener = reserved_listeners.and_then(|listeners| listeners.take_socks());
667
668 let http_state = self.state.clone();
669 let http_decider = self.policy_decider.clone();
670 let http_addr = self.http_addr;
671 let http_task = tokio::spawn(async move {
672 match http_listener {
673 Some(listener) => {
674 http_proxy::run_http_proxy_with_std_listener(http_state, listener, http_decider)
675 .await
676 }
677 None => http_proxy::run_http_proxy(http_state, http_addr, http_decider).await,
678 }
679 });
680
681 let socks_task = if current_cfg.network.enable_socks5 {
682 let socks_state = self.state.clone();
683 let socks_decider = self.policy_decider.clone();
684 let socks_addr = self.socks_addr;
685 let enable_socks5_udp = current_cfg.network.enable_socks5_udp;
686 Some(tokio::spawn(async move {
687 match socks_listener {
688 Some(listener) => {
689 socks5::run_socks5_with_std_listener(
690 socks_state,
691 listener,
692 socks_decider,
693 enable_socks5_udp,
694 )
695 .await
696 }
697 None => {
698 socks5::run_socks5(
699 socks_state,
700 socks_addr,
701 socks_decider,
702 enable_socks5_udp,
703 )
704 .await
705 }
706 }
707 }))
708 } else {
709 None
710 };
711
712 Ok(NetworkProxyHandle {
713 http_task: Some(http_task),
714 socks_task,
715 completed: false,
716 })
717 }
718}
719
720pub struct NetworkProxyHandle {
721 http_task: Option<JoinHandle<Result<()>>>,
722 socks_task: Option<JoinHandle<Result<()>>>,
723 completed: bool,
724}
725
726impl NetworkProxyHandle {
727 fn noop() -> Self {
728 Self {
729 http_task: Some(tokio::spawn(async { Ok(()) })),
730 socks_task: None,
731 completed: true,
732 }
733 }
734
735 pub async fn wait(mut self) -> Result<()> {
736 let http_task = self.http_task.take().context("missing http proxy task")?;
737 let socks_task = self.socks_task.take();
738 let http_result = http_task.await;
739 let socks_result = match socks_task {
740 Some(task) => Some(task.await),
741 None => None,
742 };
743 self.completed = true;
744 http_result??;
745 if let Some(socks_result) = socks_result {
746 socks_result??;
747 }
748 Ok(())
749 }
750
751 pub async fn shutdown(mut self) -> Result<()> {
752 abort_tasks(self.http_task.take(), self.socks_task.take()).await;
753 self.completed = true;
754 Ok(())
755 }
756}
757
758async fn abort_task(task: Option<JoinHandle<Result<()>>>) {
759 if let Some(task) = task {
760 task.abort();
761 let _ = task.await;
762 }
763}
764
765async fn abort_tasks(
766 http_task: Option<JoinHandle<Result<()>>>,
767 socks_task: Option<JoinHandle<Result<()>>>,
768) {
769 abort_task(http_task).await;
770 abort_task(socks_task).await;
771}
772
773impl Drop for NetworkProxyHandle {
774 fn drop(&mut self) {
775 if self.completed {
776 return;
777 }
778 let http_task = self.http_task.take();
779 let socks_task = self.socks_task.take();
780 tokio::spawn(async move {
781 abort_tasks(http_task, socks_task).await;
782 });
783 }
784}
785
786#[cfg(test)]
787mod tests {
788 use super::*;
789 use crate::config::NetworkProxySettings;
790 use crate::state::network_proxy_state_for_policy;
791 use pretty_assertions::assert_eq;
792 use std::net::IpAddr;
793 use std::net::Ipv4Addr;
794
795 #[tokio::test]
796 async fn managed_proxy_builder_uses_loopback_ports() {
797 let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
798 let http_addr = http_listener.local_addr().unwrap();
799 let socks_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
800 let socks_addr = socks_listener.local_addr().unwrap();
801 drop(http_listener);
802 drop(socks_listener);
803
804 let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings {
805 proxy_url: format!("http://{http_addr}"),
806 socks_url: format!("http://{socks_addr}"),
807 ..NetworkProxySettings::default()
808 }));
809 let proxy = match NetworkProxy::builder().state(state).build().await {
810 Ok(proxy) => proxy,
811 Err(err) => {
812 if err
813 .chain()
814 .any(|cause| cause.to_string().contains("Operation not permitted"))
815 {
816 return;
817 }
818 panic!("failed to build managed proxy: {err:#}");
819 }
820 };
821
822 assert!(proxy.http_addr.ip().is_loopback());
823 assert!(proxy.socks_addr.ip().is_loopback());
824 #[cfg(target_os = "windows")]
825 {
826 assert_eq!(proxy.http_addr, http_addr);
827 assert_eq!(proxy.socks_addr, socks_addr);
828 }
829 #[cfg(not(target_os = "windows"))]
830 {
831 assert_ne!(proxy.http_addr.port(), 0);
832 assert_ne!(proxy.socks_addr.port(), 0);
833 }
834 }
835
836 #[tokio::test]
837 async fn non_codex_managed_proxy_builder_uses_configured_ports() {
838 let settings = NetworkProxySettings {
839 proxy_url: "http://127.0.0.1:43128".to_string(),
840 socks_url: "http://127.0.0.1:48081".to_string(),
841 ..NetworkProxySettings::default()
842 };
843 let state = Arc::new(network_proxy_state_for_policy(settings));
844 let proxy = NetworkProxy::builder()
845 .state(state)
846 .managed_by_codex(false)
847 .build()
848 .await
849 .unwrap();
850
851 assert_eq!(
852 proxy.http_addr,
853 "127.0.0.1:43128".parse::<SocketAddr>().unwrap()
854 );
855 assert_eq!(
856 proxy.socks_addr,
857 "127.0.0.1:48081".parse::<SocketAddr>().unwrap()
858 );
859 }
860
861 #[tokio::test]
862 async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() {
863 let settings = NetworkProxySettings {
864 enable_socks5: false,
865 proxy_url: "http://127.0.0.1:43128".to_string(),
866 socks_url: "http://127.0.0.1:43129".to_string(),
867 ..NetworkProxySettings::default()
868 };
869 let state = Arc::new(network_proxy_state_for_policy(settings));
870 let proxy = match NetworkProxy::builder().state(state).build().await {
871 Ok(proxy) => proxy,
872 Err(err) => {
873 if err
874 .chain()
875 .any(|cause| cause.to_string().contains("Operation not permitted"))
876 {
877 return;
878 }
879 panic!("failed to build managed proxy: {err:#}");
880 }
881 };
882
883 assert!(proxy.http_addr.ip().is_loopback());
884 assert_ne!(proxy.http_addr.port(), 0);
885 assert_eq!(
886 proxy.socks_addr,
887 "127.0.0.1:43129".parse::<SocketAddr>().unwrap()
888 );
889 assert!(
890 proxy
891 .reserved_listeners
892 .as_ref()
893 .expect("managed builder should reserve listeners")
894 .take_socks()
895 .is_none()
896 );
897 }
898
899 #[cfg(target_os = "windows")]
900 #[test]
901 fn windows_managed_loopback_addr_clamps_non_loopback_inputs() {
902 assert_eq!(
903 windows_managed_loopback_addr("0.0.0.0:3128".parse::<SocketAddr>().unwrap()),
904 "127.0.0.1:3128".parse::<SocketAddr>().unwrap()
905 );
906 assert_eq!(
907 windows_managed_loopback_addr("[::]:8081".parse::<SocketAddr>().unwrap()),
908 "127.0.0.1:8081".parse::<SocketAddr>().unwrap()
909 );
910 }
911
912 #[cfg(target_os = "windows")]
913 #[test]
914 fn reserve_windows_managed_listeners_falls_back_when_http_port_is_busy() {
915 let occupied = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap();
916 let busy_port = occupied.local_addr().unwrap().port();
917
918 let reserved = reserve_windows_managed_listeners(
919 SocketAddr::from(([127, 0, 0, 1], busy_port)),
920 SocketAddr::from(([127, 0, 0, 1], 48081)),
921 false,
922 )
923 .unwrap();
924
925 assert!(reserved.socks_listener.is_none());
926 assert!(
927 reserved
928 .http_listener
929 .local_addr()
930 .unwrap()
931 .ip()
932 .is_loopback()
933 );
934 assert_ne!(
935 reserved.http_listener.local_addr().unwrap().port(),
936 busy_port
937 );
938 }
939
940 #[test]
941 fn proxy_url_env_value_resolves_lowercase_aliases() {
942 let mut env = HashMap::new();
943 env.insert(
944 "http_proxy".to_string(),
945 "http://127.0.0.1:3128".to_string(),
946 );
947
948 assert_eq!(
949 proxy_url_env_value(&env, "HTTP_PROXY"),
950 Some("http://127.0.0.1:3128")
951 );
952 }
953
954 #[test]
955 fn has_proxy_url_env_vars_detects_lowercase_aliases() {
956 let mut env = HashMap::new();
957 env.insert(
958 "all_proxy".to_string(),
959 "socks5h://127.0.0.1:8081".to_string(),
960 );
961
962 assert_eq!(has_proxy_url_env_vars(&env), true);
963 }
964
965 #[test]
966 fn has_proxy_url_env_vars_detects_websocket_proxy_keys() {
967 let mut env = HashMap::new();
968 env.insert("wss_proxy".to_string(), "http://127.0.0.1:3128".to_string());
969
970 assert_eq!(has_proxy_url_env_vars(&env), true);
971 }
972
973 #[test]
974 fn apply_proxy_env_overrides_sets_common_tool_vars() {
975 let mut env = HashMap::new();
976 apply_proxy_env_overrides(
977 &mut env,
978 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
979 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
980 true,
981 false,
982 );
983
984 assert_eq!(
985 env.get("HTTP_PROXY"),
986 Some(&"http://127.0.0.1:3128".to_string())
987 );
988 assert_eq!(
989 env.get("WS_PROXY"),
990 Some(&"http://127.0.0.1:3128".to_string())
991 );
992 assert_eq!(
993 env.get("WSS_PROXY"),
994 Some(&"http://127.0.0.1:3128".to_string())
995 );
996 assert_eq!(
997 env.get("npm_config_proxy"),
998 Some(&"http://127.0.0.1:3128".to_string())
999 );
1000 assert_eq!(
1001 env.get("ALL_PROXY"),
1002 Some(&"socks5h://127.0.0.1:8081".to_string())
1003 );
1004 assert_eq!(
1005 env.get("FTP_PROXY"),
1006 Some(&"socks5h://127.0.0.1:8081".to_string())
1007 );
1008 assert_eq!(
1009 env.get("NO_PROXY"),
1010 Some(&DEFAULT_NO_PROXY_VALUE.to_string())
1011 );
1012 let no_proxy = env.get("NO_PROXY").expect("NO_PROXY should be set");
1013 assert!(no_proxy.contains("10.0.0.0/8"));
1014 assert!(no_proxy.contains("172.16.0.0/12"));
1015 assert!(no_proxy.contains("192.168.0.0/16"));
1016 assert!(!no_proxy.contains("169.254.0.0/16"));
1017 assert_eq!(env.get(PROXY_ACTIVE_ENV_KEY), Some(&"1".to_string()));
1018 assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"0".to_string()));
1019 assert_eq!(
1020 env.get(ELECTRON_GET_USE_PROXY_ENV_KEY),
1021 Some(&"true".to_string())
1022 );
1023 #[cfg(target_os = "macos")]
1024 assert_eq!(
1025 env.get(GIT_SSH_COMMAND_ENV_KEY),
1026 Some(
1027 &"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'"
1028 .to_string()
1029 )
1030 );
1031 #[cfg(not(target_os = "macos"))]
1032 assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
1033 }
1034
1035 #[test]
1036 fn apply_proxy_env_overrides_sets_only_expected_env_keys() {
1037 let mut env = HashMap::new();
1038 apply_proxy_env_overrides(
1039 &mut env,
1040 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1041 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1042 true,
1043 false,
1044 );
1045
1046 for key in env.keys() {
1047 let is_managed_git_ssh_key =
1048 cfg!(target_os = "macos") && key == GIT_SSH_COMMAND_ENV_KEY;
1049 assert!(
1050 PROXY_ENV_KEYS.contains(&key.as_str()) || is_managed_git_ssh_key,
1051 "proxy env writer set unexpected key: {key}"
1052 );
1053 }
1054 }
1055
1056 #[test]
1057 fn apply_proxy_env_overrides_uses_http_for_all_proxy_without_socks() {
1058 let mut env = HashMap::new();
1059 apply_proxy_env_overrides(
1060 &mut env,
1061 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1062 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1063 false,
1064 true,
1065 );
1066
1067 assert_eq!(
1068 env.get("ALL_PROXY"),
1069 Some(&"http://127.0.0.1:3128".to_string())
1070 );
1071 assert_eq!(env.get(ALLOW_LOCAL_BINDING_ENV_KEY), Some(&"1".to_string()));
1072 }
1073
1074 #[test]
1075 fn apply_proxy_env_overrides_uses_plain_http_proxy_url() {
1076 let mut env = HashMap::new();
1077 apply_proxy_env_overrides(
1078 &mut env,
1079 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1080 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1081 true,
1082 false,
1083 );
1084
1085 assert_eq!(
1086 env.get("HTTP_PROXY"),
1087 Some(&"http://127.0.0.1:3128".to_string())
1088 );
1089 assert_eq!(
1090 env.get("HTTPS_PROXY"),
1091 Some(&"http://127.0.0.1:3128".to_string())
1092 );
1093 assert_eq!(
1094 env.get("WS_PROXY"),
1095 Some(&"http://127.0.0.1:3128".to_string())
1096 );
1097 assert_eq!(
1098 env.get("WSS_PROXY"),
1099 Some(&"http://127.0.0.1:3128".to_string())
1100 );
1101 assert_eq!(
1102 env.get("ALL_PROXY"),
1103 Some(&"socks5h://127.0.0.1:8081".to_string())
1104 );
1105 #[cfg(target_os = "macos")]
1106 assert_eq!(
1107 env.get(GIT_SSH_COMMAND_ENV_KEY),
1108 Some(
1109 &"CODEX_PROXY_GIT_SSH_COMMAND=1 ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'"
1110 .to_string()
1111 )
1112 );
1113 #[cfg(not(target_os = "macos"))]
1114 assert_eq!(env.get(GIT_SSH_COMMAND_ENV_KEY), None);
1115 }
1116
1117 #[cfg(target_os = "macos")]
1118 #[test]
1119 fn apply_proxy_env_overrides_preserves_existing_git_ssh_command() {
1120 let mut env = HashMap::new();
1121 env.insert(
1122 GIT_SSH_COMMAND_ENV_KEY.to_string(),
1123 "ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string(),
1124 );
1125 apply_proxy_env_overrides(
1126 &mut env,
1127 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1128 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081),
1129 true,
1130 false,
1131 );
1132
1133 assert_eq!(
1134 env.get(GIT_SSH_COMMAND_ENV_KEY),
1135 Some(&"ssh -o ProxyCommand='tsh proxy ssh --cluster=dev %r@%h:%p'".to_string())
1136 );
1137 }
1138
1139 #[cfg(target_os = "macos")]
1140 #[test]
1141 fn apply_proxy_env_overrides_preserves_unmarked_git_ssh_command_with_proxy_shape() {
1142 let mut env = HashMap::new();
1143 env.insert(
1144 GIT_SSH_COMMAND_ENV_KEY.to_string(),
1145 "ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string(),
1146 );
1147 apply_proxy_env_overrides(
1148 &mut env,
1149 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3128),
1150 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
1151 true,
1152 false,
1153 );
1154
1155 assert_eq!(
1156 env.get(GIT_SSH_COMMAND_ENV_KEY),
1157 Some(&"ssh -o ProxyCommand='nc -X 5 -x 127.0.0.1:8081 %h %p'".to_string())
1158 );
1159 }
1160
1161 #[cfg(target_os = "macos")]
1162 #[test]
1163 fn apply_proxy_env_overrides_refreshes_previous_codex_proxy_git_ssh_command() {
1164 let mut env = HashMap::new();
1165 env.insert(
1166 GIT_SSH_COMMAND_ENV_KEY.to_string(),
1167 codex_proxy_git_ssh_command(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081)),
1168 );
1169
1170 apply_proxy_env_overrides(
1171 &mut env,
1172 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 43128),
1173 SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 48081),
1174 true,
1175 false,
1176 );
1177
1178 assert_eq!(
1179 env.get(GIT_SSH_COMMAND_ENV_KEY),
1180 Some(&codex_proxy_git_ssh_command(SocketAddr::new(
1181 IpAddr::V4(Ipv4Addr::LOCALHOST),
1182 48081,
1183 )))
1184 );
1185 }
1186}