diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index dfe7555d58..fe9ba114eb 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -270,6 +270,7 @@ impl CommandExecRequestProcessor { }; let started_network_proxy = match network_proxy_spec.as_ref() { Some(spec) => match spec + .with_base_environment_id(Some(codex_exec_server::LOCAL_ENVIRONMENT_ID)) .start_proxy( &network_proxy_permission_profile, /*policy_decider*/ None, diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 4f77a4b822..fd989f4af6 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -340,9 +340,13 @@ async fn run_command_under_sandbox( let _ = log_denials; let managed_network_requirements_enabled = config.managed_network_requirements_enabled(); + let network_proxy_spec = + config.permissions.network.as_ref().map(|spec| { + spec.with_base_environment_id(Some(codex_exec_server::LOCAL_ENVIRONMENT_ID)) + }); // This proxy should only live for the lifetime of the child process. - let network_proxy = match config.permissions.network.as_ref() { + let network_proxy = match network_proxy_spec.as_ref() { Some(spec) => Some( spec.start_proxy( &permission_profile, diff --git a/codex-rs/cli/src/sandbox_setup.rs b/codex-rs/cli/src/sandbox_setup.rs index 958411c428..8b1e7f18b4 100644 --- a/codex-rs/cli/src/sandbox_setup.rs +++ b/codex-rs/cli/src/sandbox_setup.rs @@ -1,8 +1,10 @@ use std::path::PathBuf; +use anyhow::Context; use clap::ArgAction; use clap::ArgGroup; use clap::Parser; +use codex_core::config::ConfigBuilder; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; @@ -77,10 +79,17 @@ pub(crate) fn parse_setup_command( async fn run_elevated(cmd: SandboxSetupCommand) -> anyhow::Result<()> { let identity = resolve_sandbox_setup_identity(&cmd)?; + let config = ConfigBuilder::default() + .codex_home(identity.codex_home.clone()) + .fallback_cwd(Some(identity.codex_home.clone())) + .build() + .await + .context("failed to load target user's Codex config for sandbox provisioning")?; codex_core::windows_sandbox::run_elevated_provisioning_setup( identity.codex_home.as_path(), identity.real_user.as_str(), + config.permissions.network.as_ref(), )?; ConfigEditsBuilder::new(identity.codex_home.as_path()) .set_windows_sandbox_mode("elevated") diff --git a/codex-rs/core/src/config/network_proxy_spec.rs b/codex-rs/core/src/config/network_proxy_spec.rs index 653e6987f3..01d0f193e6 100644 --- a/codex-rs/core/src/config/network_proxy_spec.rs +++ b/codex-rs/core/src/config/network_proxy_spec.rs @@ -14,6 +14,8 @@ use codex_network_proxy::NetworkProxyHandle; use codex_network_proxy::NetworkProxyState; use codex_network_proxy::build_config_state; use codex_network_proxy::host_and_port_from_network_addr; +#[cfg(any(target_os = "windows", test))] +use codex_network_proxy::managed_proxy_ports; use codex_network_proxy::normalize_host; use codex_network_proxy::validate_policy_against_constraints; use codex_protocol::models::PermissionProfile; @@ -27,6 +29,7 @@ pub struct NetworkProxySpec { config: NetworkProxyConfig, constraints: NetworkProxyConstraints, hard_deny_allowlist_misses: bool, + base_environment_id: Option, } pub struct StartedNetworkProxy { @@ -77,6 +80,13 @@ impl NetworkProxySpec { self.config.network.enabled } + /// Returns a copy whose primary listeners are attributed to one execution environment. + pub fn with_base_environment_id(&self, environment_id: Option<&str>) -> Self { + let mut spec = self.clone(); + spec.base_environment_id = environment_id.map(str::to_string); + spec + } + pub fn proxy_host_and_port(&self) -> String { host_and_port_from_network_addr(&self.config.network.proxy_url, /*default_port*/ 3128) } @@ -85,6 +95,16 @@ impl NetworkProxySpec { self.config.network.enable_socks5 } + #[cfg(any(target_os = "windows", test))] + pub(crate) fn configured_proxy_ports(&self) -> std::io::Result> { + managed_proxy_ports(&self.config).map_err(std::io::Error::other) + } + + #[cfg(any(target_os = "windows", test))] + pub(crate) fn allow_local_binding(&self) -> bool { + self.config.network.allow_local_binding + } + pub(crate) fn from_config_and_constraints( config: NetworkProxyConfig, requirements: Option, @@ -116,6 +136,7 @@ impl NetworkProxySpec { config, constraints, hard_deny_allowlist_misses, + base_environment_id: None, }) } @@ -129,6 +150,10 @@ impl NetworkProxySpec { ) -> std::io::Result { let state = self.build_state_with_audit_metadata(audit_metadata)?; let mut builder = NetworkProxy::builder().state(Arc::new(state)); + #[cfg(target_os = "windows")] + if let Some(base_environment_id) = self.base_environment_id.as_deref() { + builder = builder.base_environment_id(base_environment_id); + } if enable_network_approval_flow && !self.hard_deny_allowlist_misses { if let Some(policy_decider) = policy_decider { builder = builder.policy_decider_arc(policy_decider); @@ -154,11 +179,13 @@ impl NetworkProxySpec { &self, permission_profile: &PermissionProfile, ) -> std::io::Result { - Self::from_config_and_constraints( + let mut spec = Self::from_config_and_constraints( self.base_config.clone(), self.requirements.clone(), permission_profile, - ) + )?; + spec.base_environment_id = self.base_environment_id.clone(); + Ok(spec) } pub(crate) fn with_exec_policy_network_rules( diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 6dfb1e3a25..f1b4ce9bc1 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -26,6 +26,7 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() { config: NetworkProxyConfig::default(), constraints: NetworkProxyConstraints::default(), hard_deny_allowlist_misses: false, + base_environment_id: None, }; let metadata = NetworkProxyAuditMetadata { conversation_id: Some("conversation-1".to_string()), @@ -40,6 +41,41 @@ fn build_state_with_audit_metadata_threads_metadata_to_state() { assert_eq!(state.audit_metadata(), &metadata); } +#[cfg(target_os = "windows")] +#[tokio::test] +async fn base_environment_uses_the_configured_windows_port_after_recompute() -> std::io::Result<()> +{ + let occupied = std::net::TcpListener::bind("127.0.0.1:0").expect("bind occupied port"); + let busy_port = occupied.local_addr().expect("read occupied port").port(); + let permission_profile = PermissionProfile::workspace_write(); + let mut config = NetworkProxyConfig::default(); + config.network.proxy_url = format!("http://127.0.0.1:{busy_port}"); + config.network.enable_socks5 = false; + let spec = NetworkProxySpec::from_config_and_constraints( + config, + /*requirements*/ None, + &permission_profile, + )? + .with_base_environment_id(Some(codex_exec_server::LOCAL_ENVIRONMENT_ID)) + .recompute_for_permission_profile(&permission_profile)?; + + let result = spec + .start_proxy( + &permission_profile, + /*policy_decider*/ None, + /*blocked_request_observer*/ None, + /*enable_network_approval_flow*/ false, + NetworkProxyAuditMetadata::default(), + ) + .await; + + assert!( + result.is_err(), + "stable Windows proxy ingress must not use an ephemeral fallback" + ); + Ok(()) +} + #[test] fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { let mut config = NetworkProxyConfig::default(); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 560dcf5985..39201013e5 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1025,6 +1025,13 @@ impl Session { } } + fn base_proxy_environment_id(environment_manager: &EnvironmentManager) -> Option<&'static str> { + environment_manager + .try_local_environment() + .is_some() + .then_some(codex_exec_server::LOCAL_ENVIRONMENT_ID) + } + async fn start_managed_network_proxy( spec: &crate::config::NetworkProxySpec, exec_policy: &codex_execpolicy::Policy, @@ -1109,6 +1116,12 @@ impl Session { return; } + let spec = spec.with_base_environment_id(Self::base_proxy_environment_id( + self.services + .turn_environments + .environment_manager() + .as_ref(), + )); match Self::start_managed_network_proxy( &spec, current_exec_policy.as_ref(), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 48a9ee447e..d4f559f904 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -891,6 +891,7 @@ impl Session { } else { ShellSnapshot::disabled() }; + let base_proxy_environment_id = Self::base_proxy_environment_id(&environment_manager); let turn_environments = Arc::new(ThreadEnvironments::new( environment_manager, default_shell.clone(), @@ -974,8 +975,9 @@ impl Session { let (network_proxy, session_network_proxy) = if let Some(spec) = config.permissions.network.as_ref() { let current_exec_policy = exec_policy.current(); + let spec = spec.with_base_environment_id(base_proxy_environment_id); let (network_proxy, session_network_proxy) = Self::start_managed_network_proxy( - spec, + &spec, current_exec_policy.as_ref(), config.permissions.permission_profile(), network_policy_decider.as_ref().map(Arc::clone), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9ae29a0917..c53aeacce6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -755,6 +755,18 @@ fn validated_network_policy_amendment_host_rejects_mismatch() { assert!(message.contains("does not match approved host")); } +#[tokio::test] +async fn base_proxy_environment_requires_a_local_environment() { + assert_eq!( + Session::base_proxy_environment_id(&EnvironmentManager::default_for_tests()), + Some(codex_exec_server::LOCAL_ENVIRONMENT_ID) + ); + assert_eq!( + Session::base_proxy_environment_id(&EnvironmentManager::without_environments()), + None + ); +} + #[tokio::test] async fn start_managed_network_proxy_applies_execpolicy_network_rules() -> anyhow::Result<()> { let permission_profile = PermissionProfile::workspace_write(); diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index a1ff5dda09..8c2ed7283d 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -169,9 +169,30 @@ pub fn run_elevated_setup( ) } +#[cfg(any(target_os = "windows", test))] +fn provisioning_settings( + network: Option<&crate::config::NetworkProxySpec>, +) -> std::io::Result { + let Some(network) = network.filter(|network| network.enabled()) else { + return Ok(codex_windows_sandbox::WindowsSandboxProvisioningSettings::default()); + }; + Ok(codex_windows_sandbox::WindowsSandboxProvisioningSettings { + proxy_ports: network.configured_proxy_ports()?, + allow_local_binding: network.allow_local_binding(), + }) +} + #[cfg(target_os = "windows")] -pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> anyhow::Result<()> { - codex_windows_sandbox::run_elevated_provisioning_setup(codex_home, real_user) +pub fn run_elevated_provisioning_setup( + codex_home: &Path, + real_user: &str, + network: Option<&crate::config::NetworkProxySpec>, +) -> anyhow::Result<()> { + codex_windows_sandbox::run_elevated_provisioning_setup( + codex_home, + real_user, + provisioning_settings(network)?, + ) } #[cfg(not(target_os = "windows"))] @@ -186,7 +207,11 @@ pub fn run_elevated_setup( } #[cfg(not(target_os = "windows"))] -pub fn run_elevated_provisioning_setup(_codex_home: &Path, _real_user: &str) -> anyhow::Result<()> { +pub fn run_elevated_provisioning_setup( + _codex_home: &Path, + _real_user: &str, + _network: Option<&crate::config::NetworkProxySpec>, +) -> anyhow::Result<()> { anyhow::bail!("elevated Windows sandbox setup is only supported on Windows") } diff --git a/codex-rs/core/src/windows_sandbox_tests.rs b/codex-rs/core/src/windows_sandbox_tests.rs index 5a66c8c96a..c24eb3af2f 100644 --- a/codex-rs/core/src/windows_sandbox_tests.rs +++ b/codex-rs/core/src/windows_sandbox_tests.rs @@ -2,6 +2,8 @@ use super::*; use codex_config::types::WindowsToml; use codex_features::Features; use codex_features::FeaturesToml; +use codex_network_proxy::NetworkProxyConfig; +use codex_protocol::models::PermissionProfile; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -115,3 +117,64 @@ fn resolve_windows_sandbox_private_desktop_respects_explicit_cfg_value() { assert!(!resolve_windows_sandbox_private_desktop(&cfg)); } + +#[test] +fn provisioning_settings_match_the_configured_managed_proxy() { + let mut config = NetworkProxyConfig::default(); + config.network.enabled = true; + config.network.proxy_url = "http://127.0.0.1:43128".to_string(); + config.network.socks_url = "socks5h://127.0.0.1:48081".to_string(); + config.network.allow_local_binding = true; + let spec = crate::config::NetworkProxySpec::from_config_and_constraints( + config, + /*requirements*/ None, + &PermissionProfile::workspace_write(), + ) + .expect("managed proxy config should resolve"); + + assert_eq!( + provisioning_settings(Some(&spec)).expect("provisioning settings should resolve"), + codex_windows_sandbox::WindowsSandboxProvisioningSettings { + proxy_ports: vec![43128, 48081], + allow_local_binding: true, + } + ); +} + +#[test] +fn provisioning_settings_are_empty_when_managed_network_is_disabled() { + let spec = crate::config::NetworkProxySpec::from_config_and_constraints( + NetworkProxyConfig::default(), + /*requirements*/ None, + &PermissionProfile::workspace_write(), + ) + .expect("managed proxy config should resolve"); + + assert_eq!( + provisioning_settings(Some(&spec)).expect("provisioning settings should resolve"), + codex_windows_sandbox::WindowsSandboxProvisioningSettings::default() + ); +} + +#[test] +fn provisioning_settings_exclude_the_disabled_socks_listener() { + let mut config = NetworkProxyConfig::default(); + config.network.enabled = true; + config.network.enable_socks5 = false; + config.network.proxy_url = "http://127.0.0.1:43128".to_string(); + config.network.socks_url = "socks5h://127.0.0.1:48081".to_string(); + let spec = crate::config::NetworkProxySpec::from_config_and_constraints( + config, + /*requirements*/ None, + &PermissionProfile::workspace_write(), + ) + .expect("managed proxy config should resolve"); + + assert_eq!( + provisioning_settings(Some(&spec)).expect("provisioning settings should resolve"), + codex_windows_sandbox::WindowsSandboxProvisioningSettings { + proxy_ports: vec![43128], + allow_local_binding: false, + } + ); +} diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 30cdfa7b2d..b882f833c0 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -438,6 +438,26 @@ pub fn resolve_runtime(cfg: &NetworkProxyConfig) -> Result { }) } +/// Returns the sorted loopback ports used by the configured managed proxy listeners. +pub fn managed_proxy_ports(cfg: &NetworkProxyConfig) -> Result> { + let runtime = resolve_runtime(cfg)?; + if runtime.http_addr.port() == 0 { + bail!("network.proxy_url must use a fixed non-zero port for managed proxy provisioning"); + } + let mut ports = vec![runtime.http_addr.port()]; + if cfg.network.enable_socks5 { + if runtime.socks_addr.port() == 0 { + bail!( + "network.socks_url must use a fixed non-zero port for managed proxy provisioning" + ); + } + ports.push(runtime.socks_addr.port()); + } + ports.sort_unstable(); + ports.dedup(); + Ok(ports) +} + fn resolve_addr(url: &str, default_port: u16) -> Result { let addr_parts = parse_host_port(url, default_port)?; let host = if addr_parts.host.eq_ignore_ascii_case("localhost") { @@ -613,6 +633,24 @@ mod tests { ); } + #[test] + fn managed_proxy_ports_reject_ephemeral_ports() { + let mut config = NetworkProxyConfig::default(); + config.network.proxy_url = "http://127.0.0.1:0".to_string(); + + assert_eq!( + managed_proxy_ports(&config).unwrap_err().to_string(), + "network.proxy_url must use a fixed non-zero port for managed proxy provisioning" + ); + + config.network.proxy_url = "http://127.0.0.1:3128".to_string(); + config.network.socks_url = "socks5h://127.0.0.1:0".to_string(); + assert_eq!( + managed_proxy_ports(&config).unwrap_err().to_string(), + "network.socks_url must use a fixed non-zero port for managed proxy provisioning" + ); + } + #[test] fn partial_network_config_uses_struct_defaults_for_missing_fields() { let config: NetworkProxyConfig = serde_json::from_str( diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 82ab48ba89..bfbb92f8af 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -28,6 +28,7 @@ pub use config::NetworkProxyConfig; pub use config::NetworkUnixSocketPermission; pub use config::NetworkUnixSocketPermissions; pub use config::host_and_port_from_network_addr; +pub use config::managed_proxy_ports; pub use credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; pub use credential_broker::brokered_credential_dummy_env_keys; pub use credential_broker::brokered_credential_env_keys; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index a338744b68..c3bfa348b6 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -10,6 +10,8 @@ use crate::socks5; use crate::state::NetworkProxyState; use anyhow::Context; use anyhow::Result; +#[cfg(target_os = "windows")] +use anyhow::bail; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; @@ -100,6 +102,7 @@ pub struct NetworkProxyBuilder { state: Option>, http_addr: Option, socks_addr: Option, + base_environment_id: Option, managed_by_codex: bool, policy_decider: Option>, blocked_request_observer: Option>, @@ -111,6 +114,7 @@ impl Default for NetworkProxyBuilder { state: None, http_addr: None, socks_addr: None, + base_environment_id: None, managed_by_codex: true, policy_decider: None, blocked_request_observer: None, @@ -134,6 +138,14 @@ impl NetworkProxyBuilder { self } + /// Associates the primary proxy listeners with one execution environment. + /// + /// Commands in other environments continue to use isolated listener pairs. + pub fn base_environment_id(mut self, environment_id: impl Into) -> Self { + self.base_environment_id = Some(environment_id.into()); + self + } + pub fn managed_by_codex(mut self, managed_by_codex: bool) -> Self { self.managed_by_codex = managed_by_codex; self @@ -193,6 +205,11 @@ impl NetworkProxyBuilder { managed_http_addr, managed_socks_addr, current_cfg.network.enable_socks5, + if self.base_environment_id.is_some() { + WindowsManagedPortSelection::ConfiguredOnly + } else { + WindowsManagedPortSelection::AllowEphemeralFallback + }, ) .context("reserve managed loopback proxy listeners")?; #[cfg(not(target_os = "windows"))] @@ -227,6 +244,7 @@ impl NetworkProxyBuilder { socks_addr, socks_enabled: current_cfg.network.enable_socks5, socks5_udp_enabled: current_cfg.network.enable_socks5_udp, + base_environment_id: self.base_environment_id, runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config( ¤t_cfg, )?)), @@ -250,23 +268,46 @@ fn reserve_loopback_ephemeral_listeners( Ok(ReservedListenerSet::new(http_listener, socks_listener)) } +#[cfg(target_os = "windows")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WindowsManagedPortSelection { + ConfiguredOnly, + AllowEphemeralFallback, +} + #[cfg(target_os = "windows")] fn reserve_windows_managed_listeners( http_addr: SocketAddr, socks_addr: SocketAddr, reserve_socks_listener: bool, + port_selection: WindowsManagedPortSelection, ) -> Result { let http_addr = windows_managed_loopback_addr(http_addr); let socks_addr = windows_managed_loopback_addr(socks_addr); + if port_selection == WindowsManagedPortSelection::ConfiguredOnly { + if http_addr.port() == 0 { + bail!("configured Windows HTTP proxy address must use a fixed non-zero port"); + } + if reserve_socks_listener && socks_addr.port() == 0 { + bail!("configured Windows SOCKS proxy address must use a fixed non-zero port"); + } + } match try_reserve_windows_managed_listeners(http_addr, socks_addr, reserve_socks_listener) { Ok(listeners) => Ok(listeners), - Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => { + Err(err) + if err.kind() == std::io::ErrorKind::AddrInUse + && port_selection == WindowsManagedPortSelection::AllowEphemeralFallback => + { warn!("managed Windows proxy ports are busy; falling back to ephemeral loopback ports"); reserve_loopback_ephemeral_listeners(reserve_socks_listener) .context("reserve fallback loopback proxy listeners") } - Err(err) => Err(err).context("reserve Windows managed proxy listeners"), + Err(err) => Err(err).with_context(|| { + format!( + "reserve configured Windows managed proxy listeners at {http_addr} and {socks_addr}" + ) + }), } } @@ -366,6 +407,7 @@ pub struct NetworkProxy { socks_addr: SocketAddr, socks_enabled: bool, socks5_udp_enabled: bool, + base_environment_id: Option, runtime_settings: Arc>, reserved_listeners: Option>, policy_decider: Option>, @@ -387,6 +429,7 @@ impl PartialEq for NetworkProxy { fn eq(&self, other: &Self) -> bool { self.http_addr == other.http_addr && self.socks_addr == other.socks_addr + && self.base_environment_id == other.base_environment_id && self.runtime_settings() == other.runtime_settings() } } @@ -778,6 +821,13 @@ impl NetworkProxy { } fn environment_proxy_addrs(&self, environment_id: &str) -> Result { + if self.base_environment_id.as_deref() == Some(environment_id) { + return Ok(EnvironmentProxyAddrs { + http_addr: self.http_addr, + socks_addr: self.socks_addr, + }); + } + let mut proxies = self .environment_proxies .lock() @@ -913,6 +963,7 @@ impl NetworkProxy { let http_state = self.state.clone(); let http_decider = self.policy_decider.clone(); + let http_environment_id = self.base_environment_id.clone(); let http_addr = self.http_addr; let http_task = tokio::spawn(async move { match http_listener { @@ -921,7 +972,7 @@ impl NetworkProxy { http_state, listener, http_decider, - /*environment_id*/ None, + http_environment_id, ) .await } @@ -930,7 +981,7 @@ impl NetworkProxy { http_state, http_addr, http_decider, - /*environment_id*/ None, + http_environment_id, ) .await } @@ -940,6 +991,7 @@ impl NetworkProxy { let socks_task = if current_cfg.network.enable_socks5 { let socks_state = self.state.clone(); let socks_decider = self.policy_decider.clone(); + let socks_environment_id = self.base_environment_id.clone(); let socks_addr = self.socks_addr; let enable_socks5_udp = current_cfg.network.enable_socks5_udp; Some(tokio::spawn(async move { @@ -949,7 +1001,7 @@ impl NetworkProxy { socks_state, listener, socks_decider, - /*environment_id*/ None, + socks_environment_id, enable_socks5_udp, ) .await @@ -959,7 +1011,7 @@ impl NetworkProxy { socks_state, socks_addr, socks_decider, - /*environment_id*/ None, + socks_environment_id, enable_socks5_udp, ) .await @@ -1070,11 +1122,61 @@ impl Drop for NetworkProxyHandle { mod tests { use super::*; use crate::config::NetworkProxySettings; + use crate::network_policy::NetworkDecision; + use crate::network_policy::NetworkPolicyRequest; + use crate::network_policy::NetworkProtocol; use crate::state::network_proxy_state_for_policy; use pretty_assertions::assert_eq; use std::net::IpAddr; use std::net::Ipv4Addr; use std::path::Path; + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + use tokio::time::Duration; + use tokio::time::timeout; + + fn proxy_addr_from_env(env: &HashMap, key: &str, scheme: &str) -> SocketAddr { + env.get(key) + .and_then(|value| value.strip_prefix(scheme)) + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("expected {key} to contain a {scheme} proxy address")) + } + + async fn issue_denied_http_request(proxy_addr: SocketAddr) -> Result<()> { + let mut stream = tokio::net::TcpStream::connect(proxy_addr).await?; + stream + .write_all( + b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n", + ) + .await?; + let mut response = Vec::new(); + timeout(Duration::from_secs(2), stream.read_to_end(&mut response)).await??; + assert!( + response.starts_with(b"HTTP/1.1 403 Forbidden"), + "unexpected proxy response: {}", + String::from_utf8_lossy(&response) + ); + Ok(()) + } + + async fn issue_denied_socks_request(proxy_addr: SocketAddr) -> Result<()> { + let mut stream = tokio::net::TcpStream::connect(proxy_addr).await?; + stream.write_all(&[0x05, 0x01, 0x00]).await?; + let mut greeting = [0_u8; 2]; + timeout(Duration::from_secs(2), stream.read_exact(&mut greeting)).await??; + assert_eq!(greeting, [0x05, 0x00]); + + let host = b"example.com"; + let mut request = vec![0x05, 0x01, 0x00, 0x03, host.len() as u8]; + request.extend_from_slice(host); + request.extend_from_slice(&80_u16.to_be_bytes()); + stream.write_all(&request).await?; + let mut response = [0_u8; 2]; + timeout(Duration::from_secs(2), stream.read_exact(&mut response)).await??; + assert_eq!(response[0], 0x05); + assert_ne!(response[1], 0x00); + Ok(()) + } #[tokio::test] async fn managed_proxy_builder_uses_loopback_ports() { @@ -1143,11 +1245,37 @@ mod tests { } #[tokio::test] - async fn prepare_for_environment_keeps_env_and_sandbox_ports_in_sync() -> Result<()> { - let state = Arc::new(network_proxy_state_for_policy( - NetworkProxySettings::default(), - )); - let proxy = NetworkProxy::builder().state(state).build().await?; + async fn base_environment_reuses_primary_listener_and_keeps_other_environments_isolated() + -> Result<()> { + let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let http_addr = http_listener.local_addr()?; + let socks_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0)))?; + let socks_addr = socks_listener.local_addr()?; + drop(http_listener); + drop(socks_listener); + let state = Arc::new(network_proxy_state_for_policy(NetworkProxySettings { + enabled: true, + proxy_url: format!("http://{http_addr}"), + socks_url: format!("socks5h://{socks_addr}"), + ..NetworkProxySettings::default() + })); + let seen_requests = Arc::new(Mutex::new(Vec::new())); + let decider: Arc = Arc::new({ + let seen_requests = Arc::clone(&seen_requests); + move |request: NetworkPolicyRequest| { + seen_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push((request.protocol, request.environment_id)); + async { NetworkDecision::deny("test denial") } + } + }); + let proxy = NetworkProxy::builder() + .state(state) + .base_environment_id("local") + .policy_decider_arc(decider) + .build() + .await?; let handle = proxy.run().await?; let base_env = HashMap::from([("PRESERVED".to_string(), "value".to_string())]); @@ -1159,7 +1287,7 @@ mod tests { Some("value") ); assert_ne!(local.env.get("HTTP_PROXY"), remote.env.get("HTTP_PROXY")); - assert_ne!( + assert_eq!( local.env.get("HTTP_PROXY"), Some(&format!("http://{}", proxy.http_addr())) ); @@ -1197,6 +1325,25 @@ mod tests { proxy.apply_to_env_for_environment(&mut legacy_env, "local")?; assert_eq!(legacy_env, local.env); + issue_denied_http_request(proxy_addr_from_env(&local.env, "HTTP_PROXY", "http://")).await?; + issue_denied_http_request(proxy_addr_from_env(&remote.env, "HTTP_PROXY", "http://")) + .await?; + issue_denied_socks_request(proxy_addr_from_env(&local.env, "ALL_PROXY", "socks5h://")) + .await?; + issue_denied_socks_request(proxy_addr_from_env(&remote.env, "ALL_PROXY", "socks5h://")) + .await?; + assert_eq!( + *seen_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + vec![ + (NetworkProtocol::Http, Some("local".to_string())), + (NetworkProtocol::Http, Some("remote".to_string())), + (NetworkProtocol::Socks5Tcp, Some("local".to_string())), + (NetworkProtocol::Socks5Tcp, Some("remote".to_string())), + ] + ); + handle.shutdown().await?; Ok(()) } @@ -1262,6 +1409,7 @@ mod tests { SocketAddr::from(([127, 0, 0, 1], busy_port)), SocketAddr::from(([127, 0, 0, 1], 48081)), /*reserve_socks_listener*/ false, + WindowsManagedPortSelection::AllowEphemeralFallback, ) .unwrap(); diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 2a394f11ff..1ef7e95ea6 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -44,6 +44,15 @@ pub enum WindowsSandboxProxySettingsMode { Preserve, } +/// Network settings installed by an administrator during managed Windows sandbox setup. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WindowsSandboxProvisioningSettings { + /// Loopback proxy ports permitted for the offline sandbox identity. + pub proxy_ports: Vec, + /// Whether the offline sandbox identity may exchange arbitrary loopback traffic. + pub allow_local_binding: bool, +} + #[cfg(target_os = "windows")] mod acl; #[cfg(target_os = "windows")] diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 1995c33365..7d18444e5a 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -903,7 +903,35 @@ fn run_elevated_setup_inner( run_setup_exe(&payload, needs_elevation, request.codex_home) } -pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> Result<()> { +fn build_provisioning_payload( + codex_home: &Path, + real_user: &str, + settings: crate::WindowsSandboxProvisioningSettings, +) -> ElevationPayload { + ElevationPayload { + version: SETUP_VERSION, + offline_username: OFFLINE_USERNAME.to_string(), + online_username: ONLINE_USERNAME.to_string(), + codex_home: codex_home.to_path_buf(), + command_cwd: codex_home.to_path_buf(), + read_roots: Vec::new(), + write_roots: Vec::new(), + deny_read_paths: Vec::new(), + deny_write_paths: Vec::new(), + proxy_ports: settings.proxy_ports, + allow_local_binding: settings.allow_local_binding, + otel: codex_otel::global_statsig_metrics_settings(), + real_user: real_user.to_string(), + mode: SetupMode::ProvisionOnly, + refresh_only: false, + } +} + +pub fn run_elevated_provisioning_setup( + codex_home: &Path, + real_user: &str, + settings: crate::WindowsSandboxProvisioningSettings, +) -> Result<()> { let sbx_dir = sandbox_dir(codex_home); std::fs::create_dir_all(&sbx_dir).map_err(|err| { failure( @@ -922,23 +950,7 @@ pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> Re "sandbox provisioning setup must be run from an elevated process", )); } - let payload = ElevationPayload { - version: SETUP_VERSION, - offline_username: OFFLINE_USERNAME.to_string(), - online_username: ONLINE_USERNAME.to_string(), - codex_home: codex_home.to_path_buf(), - command_cwd: codex_home.to_path_buf(), - read_roots: Vec::new(), - write_roots: Vec::new(), - deny_read_paths: Vec::new(), - deny_write_paths: Vec::new(), - proxy_ports: Vec::new(), - allow_local_binding: false, - otel: codex_otel::global_statsig_metrics_settings(), - real_user: real_user.to_string(), - mode: SetupMode::ProvisionOnly, - refresh_only: false, - }; + let payload = build_provisioning_payload(codex_home, real_user, settings); run_setup_exe(&payload, /*needs_elevation*/ false, codex_home) } @@ -1239,6 +1251,23 @@ mod tests { ); } + #[test] + fn provisioning_payload_forwards_managed_proxy_settings() { + let codex_home = Path::new(r"C:\Users\test\.codex"); + let payload = super::build_provisioning_payload( + codex_home, + "test-user", + crate::WindowsSandboxProvisioningSettings { + proxy_ports: vec![3128, 8081], + allow_local_binding: true, + }, + ); + + assert_eq!(payload.proxy_ports, vec![3128, 8081]); + assert!(payload.allow_local_binding); + assert!(matches!(payload.mode, super::SetupMode::ProvisionOnly)); + } + #[test] fn report_helper_failure_uses_setup_error_report_when_clear_succeeded() { let tmp = TempDir::new().expect("tempdir");