diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dc616bd9c98d..55c7fe9156fc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3606,6 +3606,7 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-home-dir", "codex-utils-rustls-provider", + "codex-windows-sandbox", "globset", "pretty_assertions", "rama-core", @@ -3629,6 +3630,7 @@ dependencies = [ "tokio", "tracing", "url", + "windows-sys 0.52.0", ] [[package]] diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 4f77a4b82208..9122d403ce22 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -488,6 +488,7 @@ async fn run_command_under_windows_session( windows_sandbox_level: WindowsSandboxLevel::from_config(config), proxy_settings_mode: WindowsSandboxProxySettingsMode::Reconcile, proxy_enforced: false, + network_proxy_restricting_sid: None, timeout_ms: None, read_roots_override: None, read_roots_include_platform_defaults: false, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 61b484d54b70..8d52a6282b2c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1445,6 +1445,14 @@ async fn cli_main( .await?; } Some(Subcommand::Sandbox(mut sandbox_cli)) => { + let config_profile = sandbox_cli + .config_profile + .as_ref() + .or(interactive.config_profile_v2.as_ref()); + prepend_config_flags( + &mut sandbox_cli.config_overrides, + root_config_overrides.clone(), + ); #[cfg(target_os = "windows")] if let Some(setup_cli) = sandbox_setup::parse_setup_command(&sandbox_cli.command)? { reject_remote_mode_for_subcommand( @@ -1452,7 +1460,11 @@ async fn cli_main( root_remote_auth_token_env.as_deref(), "sandbox setup", )?; - sandbox_setup::run(setup_cli).await?; + let cli_overrides = sandbox_cli + .config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + sandbox_setup::run(setup_cli, config_profile.cloned(), cli_overrides).await?; return Ok(()); } reject_remote_mode_for_subcommand( @@ -1460,15 +1472,7 @@ async fn cli_main( root_remote_auth_token_env.as_deref(), "sandbox", )?; - let config_profile = sandbox_cli - .config_profile - .as_ref() - .or(interactive.config_profile_v2.as_ref()); let loader_overrides = loader_overrides_for_profile(config_profile)?; - prepend_config_flags( - &mut sandbox_cli.config_overrides, - root_config_overrides.clone(), - ); #[cfg(target_os = "macos")] codex_cli::run_command_under_seatbelt( sandbox_cli, @@ -1879,16 +1883,29 @@ fn loader_overrides_for_profile( match profile_v2 { Some(profile_v2) => { let codex_home = find_codex_home()?; - Ok(LoaderOverrides { - user_config_path: Some(resolve_profile_v2_config_path(&codex_home, profile_v2)), - user_config_profile: Some(profile_v2.clone()), - ..Default::default() - }) + Ok(loader_overrides_for_profile_at_codex_home( + Some(profile_v2), + &codex_home, + )) } None => Ok(LoaderOverrides::default()), } } +fn loader_overrides_for_profile_at_codex_home( + profile_v2: Option<&ProfileV2Name>, + codex_home: &std::path::Path, +) -> LoaderOverrides { + match profile_v2 { + Some(profile_v2) => LoaderOverrides { + user_config_path: Some(resolve_profile_v2_config_path(codex_home, profile_v2)), + user_config_profile: Some(profile_v2.clone()), + ..Default::default() + }, + None => LoaderOverrides::default(), + } +} + fn maybe_print_under_development_feature_warning(codex_home: &std::path::Path, feature: &str) { let Some(spec) = FEATURES.iter().find(|spec| spec.key == feature) else { return; @@ -2706,6 +2723,22 @@ mod tests { Ok(profile_v2_for_subcommand(&cli.interactive, subcommand)?.map(ToString::to_string)) } + #[test] + fn profile_loader_overrides_use_explicit_codex_home() -> anyhow::Result<()> { + let codex_home = tempfile::tempdir()?; + let profile: ProfileV2Name = "work".parse()?; + + let overrides = + loader_overrides_for_profile_at_codex_home(Some(&profile), codex_home.path()); + + assert_eq!( + overrides.user_config_path, + Some(resolve_profile_v2_config_path(codex_home.path(), &profile)) + ); + assert_eq!(overrides.user_config_profile, Some(profile)); + Ok(()) + } + #[test] fn profile_v2_is_rejected_for_config_management_subcommands() { assert!(profile_v2_for_args(&["codex", "--profile", "work", "features", "list"]).is_err()); diff --git a/codex-rs/cli/src/sandbox_setup.rs b/codex-rs/cli/src/sandbox_setup.rs index 958411c4285d..ba89d4c38d8a 100644 --- a/codex-rs/cli/src/sandbox_setup.rs +++ b/codex-rs/cli/src/sandbox_setup.rs @@ -1,10 +1,14 @@ 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; +use codex_utils_cli::ProfileV2Name; +use toml::Value as TomlValue; #[derive(Debug, Parser)] #[command(group( @@ -54,9 +58,13 @@ impl SandboxSetupCommand { } } -pub(crate) async fn run(cmd: SandboxSetupCommand) -> anyhow::Result<()> { +pub(crate) async fn run( + cmd: SandboxSetupCommand, + config_profile: Option, + cli_overrides: Vec<(String, TomlValue)>, +) -> anyhow::Result<()> { match cmd.setup_level()? { - SandboxSetupLevel::Elevated => run_elevated(cmd).await, + SandboxSetupLevel::Elevated => run_elevated(cmd, config_profile, cli_overrides).await, } } @@ -75,12 +83,28 @@ pub(crate) fn parse_setup_command( .map_err(anyhow::Error::from) } -async fn run_elevated(cmd: SandboxSetupCommand) -> anyhow::Result<()> { +async fn run_elevated( + cmd: SandboxSetupCommand, + config_profile: Option, + cli_overrides: Vec<(String, TomlValue)>, +) -> 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())) + .loader_overrides(super::loader_overrides_for_profile_at_codex_home( + config_profile.as_ref(), + &identity.codex_home, + )) + .cli_overrides(cli_overrides) + .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 a5cef5ca72d8..8141b1efa62c 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; @@ -85,6 +87,16 @@ impl NetworkProxySpec { self.config.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.allow_local_binding + } + pub(crate) fn from_config_and_constraints( config: NetworkProxyConfig, requirements: Option, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 47dd317de1d2..0a0364da3783 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -632,6 +632,18 @@ async fn exec_windows_sandbox( network_proxy_environment_error(network_environment_id.as_deref(), err) })?; } + let network_proxy_restricting_sid = network + .as_ref() + .map(|network| { + network + .network_proxy_restricting_sid(network_environment_id.as_deref()) + .ok_or_else(|| { + CodexErr::Io(io::Error::other( + "managed Windows proxy route is missing its restricting SID", + )) + }) + }) + .transpose()?; // Windows sandbox capture still receives timeout and cancellation separately. let (cancellation, timeout_ms) = if capture_policy.uses_expiration() { @@ -686,6 +698,7 @@ async fn exec_windows_sandbox( cancellation, use_private_desktop: windows_sandbox_private_desktop, proxy_enforced, + network_proxy_restricting_sid, read_roots_override: elevated_read_roots_override.as_deref(), read_roots_include_platform_defaults: elevated_read_roots_include_platform_defaults, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f32a5e674797..55babc759f51 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -903,7 +903,14 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R use tokio::io::AsyncReadExt as _; use tokio::io::AsyncWriteExt as _; - let mut stream = tokio::net::TcpStream::connect(started_proxy.proxy().http_addr()).await?; + let prepared = started_proxy + .proxy() + .prepare_for_remote_environment(std::collections::HashMap::new(), "test-bridge")?; + let proxy_addr = prepared.env["HTTP_PROXY"] + .strip_prefix("http://") + .expect("HTTP proxy URL") + .parse::()?; + 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", diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 1a83ef56579b..9eb4f4b00979 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -19,7 +19,8 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; -use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path; +pub(crate) use codex_network_proxy::is_managed_proxy_env_var; +pub(crate) use codex_network_proxy::strip_managed_proxy_env; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_sandboxing::SandboxCommand; @@ -69,28 +70,6 @@ pub(crate) fn exec_env_for_sandbox_permissions( env } -pub(crate) fn is_managed_proxy_env_var(key: &str, value: &str) -> bool { - if PROXY_ENV_KEYS.contains(&key) { - return true; - } - if CUSTOM_CA_ENV_KEYS.contains(&key) { - return is_managed_mitm_ca_trust_bundle_path(value); - } - #[cfg(target_os = "macos")] - { - key == PROXY_GIT_SSH_COMMAND_ENV_KEY - && value.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER) - } - #[cfg(not(target_os = "macos"))] - { - false - } -} - -pub(crate) fn strip_managed_proxy_env(env: &mut HashMap) { - env.retain(|key, value| !is_managed_proxy_env_var(key, value)); -} - /// Prepends `path_entry` to `PATH`, removing duplicate and empty existing /// entries. /// diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 74744be52f18..27f889da7acc 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1031,6 +1031,35 @@ impl UnifiedExecProcessManager { if request.command.is_empty() { return Err(UnifiedExecError::MissingCommandLine); } + let network_proxy_restricting_sid = { + #[cfg(target_os = "windows")] + { + if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { + request + .network + .as_ref() + .map(|network| { + network + .network_proxy_restricting_sid( + request.network_environment_id.as_deref(), + ) + .ok_or_else(|| { + UnifiedExecError::create_process( + "managed Windows proxy route is missing its restricting SID" + .to_string(), + ) + }) + }) + .transpose()? + } else { + None + } + } + #[cfg(not(target_os = "windows"))] + { + None:: + } + }; let windows_sandbox = if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { @@ -1039,6 +1068,7 @@ impl UnifiedExecProcessManager { workspace_roots: &request.windows_sandbox_workspace_roots, windows_sandbox_level: request.windows_sandbox_level, proxy_enforced: request.network.is_some(), + network_proxy_restricting_sid: network_proxy_restricting_sid.as_deref(), proxy_settings_mode: codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, filesystem_overrides: request.windows_sandbox_filesystem_overrides.as_ref(), use_private_desktop: request.windows_sandbox_private_desktop, diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 68d5ce29fdee..be380a8b2c9f 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -123,9 +123,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"))] @@ -140,7 +161,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 5a66c8c96a29..a5c69db5598d 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,44 @@ fn resolve_windows_sandbox_private_desktop_respects_explicit_cfg_value() { assert!(!resolve_windows_sandbox_private_desktop(&cfg)); } + +#[test] +fn provisioning_settings_omit_the_disabled_socks_proxy() { + let config = NetworkProxyConfig { + enabled: true, + proxy_url: "http://127.0.0.1:43128".to_string(), + enable_socks5: false, + socks_url: "socks5h://127.0.0.1:48081".to_string(), + allow_local_binding: true, + ..Default::default() + }; + 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: 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() + ); +} diff --git a/codex-rs/core/tests/suite/unified_exec_process_events.rs b/codex-rs/core/tests/suite/unified_exec_process_events.rs index 7fade19bce84..3ce0b2728cdb 100644 --- a/codex-rs/core/tests/suite/unified_exec_process_events.rs +++ b/codex-rs/core/tests/suite/unified_exec_process_events.rs @@ -8,6 +8,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use core_test_support::managed_network_requirements_loader; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -22,6 +23,7 @@ use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::fs; use std::time::Duration; use test_case::test_case; use tokio::net::TcpListener; @@ -46,6 +48,12 @@ enum PushedExecScenario { ReplayGap, } +#[derive(Debug)] +struct PushedExecServerResult { + process_read_requests: usize, + process_start: Value, +} + async fn read_exec_server_json(websocket: &mut WebSocketStream) -> Value { loop { match timeout(Duration::from_secs(5), websocket.next()) @@ -96,11 +104,18 @@ async fn accept_initialized_exec_server(listener: TcpListener) -> WebSocketStrea async fn send_environment_info(websocket: &mut WebSocketStream) { let info = read_exec_server_json(websocket).await; assert_eq!(info["method"], "environment/info"); + respond_environment_info(websocket, &info["id"]).await; +} + +async fn respond_environment_info(websocket: &mut WebSocketStream, id: &Value) { send_exec_server_json( websocket, json!({ - "id": info["id"], - "result": { "shell": { "name": "zsh", "path": "/bin/zsh" } } + "id": id, + "result": { + "shell": { "name": "zsh", "path": "/bin/zsh" }, + "capabilities": { "networkProxyLaunch": true } + } }), ) .await; @@ -109,7 +124,7 @@ async fn send_environment_info(websocket: &mut WebSocketStream) { async fn serve_exec_with_pushed_events( listener: TcpListener, scenario: PushedExecScenario, -) -> usize { +) -> PushedExecServerResult { let mut websocket = accept_initialized_exec_server(listener).await; send_environment_info(&mut websocket).await; @@ -117,6 +132,9 @@ async fn serve_exec_with_pushed_events( let request = read_exec_server_json(&mut websocket).await; match request["method"].as_str() { Some("process/start") => break request, + Some("environment/info") => { + respond_environment_info(&mut websocket, &request["id"]).await; + } Some("fs/getMetadata") => { send_exec_server_json( &mut websocket, @@ -360,20 +378,25 @@ async fn serve_exec_with_pushed_events( }), ) .await; - return process_read_requests; + return PushedExecServerResult { + process_read_requests, + process_start, + }; } method => panic!("unexpected exec-server request: {method:?}"), } } } -#[test_case(PushedExecScenario::Complete ; "complete_event_stream")] -#[test_case(PushedExecScenario::DirectDenied ; "direct_sandbox_denial")] -#[test_case(PushedExecScenario::LegacyExit ; "legacy_exit_metadata")] -#[test_case(PushedExecScenario::ReplayGap ; "truncated_event_replay")] +#[test_case(PushedExecScenario::Complete, false ; "complete_event_stream")] +#[test_case(PushedExecScenario::DirectDenied, false ; "direct_sandbox_denial")] +#[test_case(PushedExecScenario::LegacyExit, false ; "legacy_exit_metadata")] +#[test_case(PushedExecScenario::ReplayGap, false ; "truncated_event_replay")] +#[test_case(PushedExecScenario::Complete, true ; "managed_network_uses_executor_proxy_launch")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn exec_command_consumes_pushed_remote_process_events( scenario: PushedExecScenario, + managed_network: bool, ) -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0").await?; let server = start_mock_server().await; @@ -386,7 +409,7 @@ async fn exec_command_consumes_pushed_remote_process_events( CALL_ID, "exec_command", &json!({ - "cmd": "ignored by fake exec-server", + "cmd": "pwd", "yield_time_ms": 1_000, }) .to_string(), @@ -403,22 +426,46 @@ async fn exec_command_consumes_pushed_remote_process_events( .await; let exec_server_url = format!("ws://{}", listener.local_addr()?); let exec_server = tokio::spawn(serve_exec_with_pushed_events(listener, scenario)); - let mut builder = test_codex() - .with_exec_server_url(exec_server_url) - .with_config(|config| { - config.project_doc_max_bytes = 0; - config.use_experimental_unified_exec_tool = true; - config - .features - .enable(Feature::UnifiedExec) - .expect("test config should allow feature update"); - }); + let mut builder = test_codex().with_exec_server_url(exec_server_url); + if managed_network { + builder = builder + .with_cloud_config_bundle(managed_network_requirements_loader()) + .with_pre_build_hook(|home| { + fs::write( + home.join("config.toml"), + r#"default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.network] +enabled = true +mode = "full" +allow_local_binding = true +"#, + ) + .expect("write managed-network test config"); + }); + } + let mut builder = builder.with_config(|config| { + config.project_doc_max_bytes = 0; + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + }); let test = timeout(Duration::from_secs(5), builder.build(&server)) .await .context("thread startup should connect to the fake exec-server")??; + let turn_permission_profile = if managed_network { + test.session_configured.permission_profile.clone() + } else { + PermissionProfile::Disabled + }; let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path()); + turn_permission_fields(turn_permission_profile, test.config.cwd.as_path()); test.codex .submit(Op::UserInput { items: vec![UserInput::Text { @@ -445,22 +492,47 @@ async fn exec_command_consumes_pushed_remote_process_events( }) .await?; let mut saw_exec_command_begin = false; - loop { - let event = timeout(Duration::from_secs(5), test.codex.next_event()) - .await - .context("turn should complete")?? - .msg; - match event { - EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => { - saw_exec_command_begin = true; + if !managed_network { + loop { + let event = timeout(Duration::from_secs(5), test.codex.next_event()) + .await + .context("turn should complete")?? + .msg; + match event { + EventMsg::ExecCommandBegin(event) if event.call_id == CALL_ID => { + saw_exec_command_begin = true; + } + EventMsg::TurnComplete(_) => break, + _ => {} } - EventMsg::TurnComplete(_) => break, - _ => {} } } - let process_read_requests = timeout(Duration::from_secs(5), exec_server) + let cleanup_timeout = if managed_network { + Duration::from_secs(15) + } else { + Duration::from_secs(5) + }; + let exec_server_result = timeout(cleanup_timeout, exec_server) .await .context("fake exec-server should observe process cleanup")??; + if managed_network { + let params = &exec_server_result.process_start["params"]; + assert_eq!(params["enforceManagedNetwork"], true); + assert_eq!(params["managedNetwork"], Value::Null); + assert_eq!(params["env"]["HTTP_PROXY"], Value::Null); + assert_eq!(params["networkProxy"]["proxy"]["enabled"], true); + assert_eq!(params["networkProxy"]["proxy"]["mode"], "full"); + assert_eq!(params["networkProxy"]["environmentId"], "remote"); + assert!(params["networkProxy"]["executionId"].as_str().is_some()); + timeout(Duration::from_secs(5), async { + while response_mock.requests().len() < 2 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .context("model should receive the remote exec output")?; + return Ok(()); + } let request = response_mock .last_request() .context("model should receive the exec_command output")?; @@ -468,6 +540,7 @@ async fn exec_command_consumes_pushed_remote_process_events( .function_call_output_content_and_success(CALL_ID) .context("exec_command output should be model visible")?; let output = output.context("exec_command output should contain text")?; + let process_read_requests = exec_server_result.process_read_requests; match scenario { PushedExecScenario::Complete => { assert_ne!(success, Some(false)); diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 467a4b0676c8..c4687ec1c058 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -1372,6 +1372,8 @@ mod tests { tokio::net::TcpStream::connect(proxy_addr) .await .expect("proxy should remain available to a child holding inherited output streams"); + #[cfg(target_os = "windows")] + assert!(proxy.network_proxy_restricting_sid(None).is_some()); drop(process.stdout_tx); drop(process.stderr_tx); @@ -1382,6 +1384,9 @@ mod tests { .await .expect("process should close"); assert!(closed_response.closed); + #[cfg(target_os = "windows")] + assert_eq!(proxy.network_proxy_restricting_sid(None), None); + #[cfg(not(target_os = "windows"))] assert!(tokio::net::TcpStream::connect(proxy_addr).await.is_err()); backend.shutdown().await; } diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index ca0f39415103..61e77277c840 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -9,6 +9,8 @@ use codex_network_proxy::NetworkProxyHandle; use codex_network_proxy::NetworkProxyState; use codex_network_proxy::RemoteNetworkProxyLaunchConfig; use codex_network_proxy::is_managed_mitm_ca_trust_bundle_path; +#[cfg(target_os = "windows")] +use codex_network_proxy::strip_managed_proxy_env; use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxDirectSpawnTransformRequest; @@ -48,6 +50,7 @@ struct PreparedWindowsSandboxRequest { workspace_roots: Vec, windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, proxy_enforced: bool, + network_proxy_restricting_sid: Option, filesystem_overrides: Option, use_private_desktop: bool, } @@ -61,6 +64,7 @@ impl PreparedExecRequest { workspace_roots: &request.workspace_roots, windows_sandbox_level: request.windows_sandbox_level, proxy_enforced: request.proxy_enforced, + network_proxy_restricting_sid: request.network_proxy_restricting_sid.as_deref(), proxy_settings_mode: WindowsSandboxProxySettingsMode::Reconcile, filesystem_overrides: request.filesystem_overrides.as_ref(), use_private_desktop: request.use_private_desktop, @@ -73,12 +77,24 @@ pub(crate) async fn prepare_exec_request( env: HashMap, runtime_paths: Option<&ExecServerRuntimePaths>, ) -> Result { - let (env, managed_network, network_proxy_handle) = prepare_managed_network( - params.managed_network.as_ref(), - params.network_proxy.as_ref(), - env, - ) - .await?; + #[cfg(target_os = "windows")] + let mut env = env; + #[cfg(target_os = "windows")] + let network_proxy = if params.sandbox.is_none() { + // Shared Windows ingress selects a route from the sandbox token's SID. Native launches + // have no route SID, so leave them direct. + if params.network_proxy.is_some() { + strip_managed_proxy_env(&mut env); + } + None + } else { + params.network_proxy.as_ref() + }; + #[cfg(not(target_os = "windows"))] + let network_proxy = params.network_proxy.as_ref(); + + let (env, managed_network, network_proxy_handle, network_proxy_restricting_sid) = + prepare_managed_network(params.managed_network.as_ref(), network_proxy, env).await?; let Some(sandbox_context) = params.sandbox.as_ref() else { return Ok(PreparedExecRequest { command: params.argv.clone(), @@ -246,6 +262,7 @@ pub(crate) async fn prepare_exec_request( workspace_roots: native_workspace_roots, windows_sandbox_level: sandbox_context.windows_sandbox_level, proxy_enforced, + network_proxy_restricting_sid, filesystem_overrides, use_private_desktop: sandbox_context.windows_sandbox_private_desktop, }) @@ -272,11 +289,12 @@ async fn prepare_managed_network( HashMap, Option, Option, + Option, ), JSONRPCErrorError, > { let Some(network_proxy) = network_proxy.cloned() else { - return Ok((env, managed_network.cloned(), None)); + return Ok((env, managed_network.cloned(), None, None)); }; let state = NetworkProxyState::from_remote_launch_config(network_proxy) .map_err(|err| invalid_params(format!("invalid network proxy config: {err}")))?; @@ -289,12 +307,29 @@ async fn prepare_managed_network( .run() .await .map_err(|err| internal_error(format!("failed to start executor network proxy: {err}")))?; + #[cfg(target_os = "windows")] + let network_proxy_restricting_sid = Some( + proxy + .network_proxy_restricting_sid(/*environment_id*/ None) + .ok_or_else(|| { + internal_error( + "managed Windows proxy route is missing its restricting SID".to_string(), + ) + })?, + ); + #[cfg(not(target_os = "windows"))] + let network_proxy_restricting_sid = None; let prepared = proxy .prepare_for_optional_environment(env, /*environment_id*/ None) .map_err(|err| { internal_error(format!("failed to prepare executor network proxy: {err}")) })?; - Ok((prepared.env, Some(prepared.sandbox_context), Some(handle))) + Ok(( + prepared.env, + Some(prepared.sandbox_context), + Some(handle), + network_proxy_restricting_sid, + )) } fn native_path(path: &PathUri, label: &str) -> Result { diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index 2fd2a182b777..0472625c389a 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -231,7 +231,7 @@ async fn native_request_preserves_native_launch_fields() { } #[tokio::test] -async fn remote_proxy_config_starts_executor_local_proxy() { +async fn native_request_handles_remote_proxy_config_for_platform() { let cwd: AbsolutePathBuf = std::env::current_dir() .expect("current directory") .try_into() @@ -261,12 +261,22 @@ async fn remote_proxy_config_starts_executor_local_proxy() { ), }; let stale_proxy = "http://127.0.0.1:9".to_string(); - let env = HashMap::from([("HTTP_PROXY".to_string(), stale_proxy.clone())]); + let env = HashMap::from([ + ("HTTP_PROXY".to_string(), stale_proxy.clone()), + ("TEST_ENV".to_string(), "value".to_string()), + ]); let prepared = prepare_exec_request(¶ms, env, /*runtime_paths*/ None) .await .expect("prepare request with executor-local proxy"); + if cfg!(target_os = "windows") { + assert_eq!(prepared.env.get("HTTP_PROXY"), None); + assert_eq!(prepared.env.get("TEST_ENV"), Some(&"value".to_string())); + assert!(prepared.network_proxy_handle.is_none()); + return; + } + let http_proxy = prepared.env.get("HTTP_PROXY").expect("HTTP proxy env"); assert_ne!(http_proxy, &stale_proxy); assert!(http_proxy.starts_with("http://127.0.0.1:")); @@ -297,6 +307,7 @@ async fn remote_proxy_config_starts_executor_local_proxy() { .expect("shut down executor proxy"); } +#[cfg(not(target_os = "windows"))] #[tokio::test] async fn disabled_remote_proxy_config_is_rejected_before_exporting_ports() { let cwd: AbsolutePathBuf = std::env::current_dir() @@ -350,6 +361,12 @@ async fn managed_network_selects_elevated_windows_spawn() { cwd_uri.clone(), ); sandbox.windows_sandbox_level = WindowsSandboxLevel::RestrictedToken; + let proxy_config = RemoteNetworkProxyConfig::from_effective_config(&NetworkProxyConfig { + enabled: true, + enable_socks5: false, + ..NetworkProxyConfig::default() + }) + .expect("supported remote proxy config"); let params = ExecParams { process_id: ProcessId::from("process-managed-network"), argv: vec!["cmd.exe".to_string(), "/c".to_string(), "exit".to_string()], @@ -362,21 +379,31 @@ async fn managed_network_selects_elevated_windows_spawn() { sandbox: Some(sandbox), enforce_managed_network: true, managed_network: None, - network_proxy: None, + network_proxy: Some(RemoteNetworkProxyLaunchConfig::new(proxy_config)), }; - let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + let mut prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) .await .expect("prepare sandboxed request"); - let spawn = prepared - .windows_sandbox_spawn_request() - .expect("Windows sandbox spawn request"); + { + let spawn = prepared + .windows_sandbox_spawn_request() + .expect("Windows sandbox spawn request"); - assert_eq!( - spawn.windows_sandbox_level, - WindowsSandboxLevel::RestrictedToken - ); - assert!(spawn.proxy_enforced); - assert_eq!(spawn.permission_profile, &permissions); - assert_eq!(spawn.workspace_roots, std::slice::from_ref(&cwd)); + assert_eq!( + spawn.windows_sandbox_level, + WindowsSandboxLevel::RestrictedToken + ); + assert!(spawn.proxy_enforced); + assert!(spawn.network_proxy_restricting_sid.is_some()); + assert_eq!(spawn.permission_profile, &permissions); + assert_eq!(spawn.workspace_roots, std::slice::from_ref(&cwd)); + } + prepared + .network_proxy_handle + .take() + .expect("running executor proxy") + .shutdown() + .await + .expect("shut down executor proxy"); } diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index d1d2a72ce323..82407b00d104 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -51,3 +51,14 @@ security-framework = "3" [target.'cfg(windows)'.dependencies] schannel = "0.1" +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_NetworkManagement_IpHelper", + "Win32_Networking_WinSock", + "Win32_Security", + "Win32_Security_Authorization", + "Win32_System_Threading", +] } + +[target.'cfg(windows)'.dev-dependencies] +codex-windows-sandbox = { path = "../windows-sandbox-rs" } diff --git a/codex-rs/network-proxy/src/config.rs b/codex-rs/network-proxy/src/config.rs index 3461c696acb5..8fe5c520e7e9 100644 --- a/codex-rs/network-proxy/src/config.rs +++ b/codex-rs/network-proxy/src/config.rs @@ -430,6 +430,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.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") { @@ -605,6 +625,32 @@ mod tests { ); } + #[test] + fn managed_proxy_ports_reject_ephemeral_ports() { + let mut config = NetworkProxyConfig { + proxy_url: "http://127.0.0.1:0".to_string(), + ..Default::default() + }; + + 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.proxy_url = "http://127.0.0.1:3128".to_string(); + config.socks_url = "socks5h://127.0.0.1:48081".to_string(); + assert_eq!(managed_proxy_ports(&config).unwrap(), vec![3128, 48081]); + + config.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" + ); + + config.enable_socks5 = false; + assert_eq!(managed_proxy_ports(&config).unwrap(), vec![3128]); + } + #[test] fn network_proxy_config_uses_struct_defaults_for_missing_fields() { let config: NetworkProxyConfig = serde_json::from_str(r#"{ "enabled": true }"#).unwrap(); diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index da91925539d9..e8a77d85678a 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -40,6 +40,7 @@ use rama_core::error::ErrorExt as _; use rama_core::error::OpaqueError; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; +use rama_core::service::BoxService; use rama_core::service::service_fn; use rama_core::stream::Stream; use rama_http::Body; @@ -66,6 +67,7 @@ use rama_net::proxy::ProxyRequest; use rama_net::proxy::ProxyTarget; use rama_net::proxy::StreamForwardService; use rama_net::stream::SocketInfo; +use rama_tcp::TcpStream; use rama_tcp::client::Request as TcpRequest; use rama_tcp::server::TcpListener; use rama_tls_rustls::client::TlsConnectorDataBuilder; @@ -124,12 +126,25 @@ async fn run_http_proxy_with_listener( policy_decider: Option>, environment_id: Option, ) -> Result<()> { - ensure_rustls_crypto_provider(); - let addr = listener .local_addr() .context("read HTTP proxy listener local addr")?; + info!("HTTP proxy listening on {addr}"); + + listener + .serve(http_proxy_service(state, policy_decider, environment_id)) + .await; + Ok(()) +} + +pub(crate) fn http_proxy_service( + state: Arc, + policy_decider: Option>, + environment_id: Option, +) -> BoxService { + ensure_rustls_crypto_provider(); + // This proxy listener only needs HTTP/1 proxy semantics. Using Rama's auto builder // forces every accepted socket through the HTTP version sniffing pre-read path before proxy // request parsing, which can stall some local clients on macOS before CONNECT/absolute-form @@ -156,16 +171,7 @@ async fn run_http_proxy_with_listener( })), ); - info!("HTTP proxy listening on {addr}"); - - listener - .serve(BindConnectionAttribution::new( - http_service, - state, - environment_id, - )) - .await; - Ok(()) + BindConnectionAttribution::new(http_service, state, environment_id).boxed() } async fn http_connect_accept( diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 8eb39c0d04b6..2ad9a21c8cee 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -19,6 +19,10 @@ mod runtime; mod socks5; mod state; mod upstream; +#[cfg(target_os = "windows")] +mod windows_proxy_ingress; +#[cfg(target_os = "windows")] +mod windows_tcp_attribution; pub use attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; pub use attribution::write_attribution_frame; @@ -32,6 +36,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; @@ -67,7 +72,9 @@ pub use proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; pub use proxy::PROXY_URL_ENV_KEYS; pub use proxy::PreparedManagedNetwork; pub use proxy::has_proxy_url_env_vars; +pub use proxy::is_managed_proxy_env_var; pub use proxy::proxy_url_env_value; +pub use proxy::strip_managed_proxy_env; pub use remote_config::RemoteNetworkProxyConfig; pub use remote_config::RemoteNetworkProxyLaunchConfig; pub use runtime::BlockedRequest; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index 85fc045fcbc5..a100311d17ff 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -11,6 +11,12 @@ use crate::runtime::ConfigState; use crate::runtime::unix_socket_permissions_supported; use crate::socks5; use crate::state::NetworkProxyState; +#[cfg(target_os = "windows")] +use crate::windows_proxy_ingress::WindowsProxyIngress; +#[cfg(target_os = "windows")] +use crate::windows_proxy_ingress::WindowsProxyRoute; +#[cfg(target_os = "windows")] +use crate::windows_proxy_ingress::WindowsRouteService; use anyhow::Context; use anyhow::Result; use clap::Parser; @@ -39,6 +45,7 @@ struct ReservedListeners { } impl ReservedListeners { + #[cfg(not(target_os = "windows"))] fn new(http: StdTcpListener, socks: Option) -> Self { Self { http: Mutex::new(Some(http)), @@ -63,7 +70,7 @@ impl ReservedListeners { } } -struct ReservedListenerSet { +pub(super) struct ReservedListenerSet { http_listener: StdTcpListener, socks_listener: Option, } @@ -76,13 +83,13 @@ impl ReservedListenerSet { } } - fn http_addr(&self) -> Result { + pub(super) fn http_addr(&self) -> Result { self.http_listener .local_addr() .context("failed to read reserved HTTP proxy address") } - fn socks_addr(&self, default_addr: SocketAddr) -> Result { + pub(super) fn socks_addr(&self, default_addr: SocketAddr) -> Result { self.socks_listener .as_ref() .map_or(Ok(default_addr), |listener| { @@ -92,12 +99,18 @@ impl ReservedListenerSet { }) } + #[cfg(not(target_os = "windows"))] fn into_reserved_listeners(self) -> Arc { Arc::new(ReservedListeners::new( self.http_listener, self.socks_listener, )) } + + #[cfg(target_os = "windows")] + pub(super) fn into_listeners(self) -> (StdTcpListener, Option) { + (self.http_listener, self.socks_listener) + } } #[derive(Clone)] @@ -183,20 +196,30 @@ impl NetworkProxyBuilder { .set_blocked_request_observer(self.blocked_request_observer.clone()) .await; let current_cfg = state.current_cfg().await?; - let (requested_http_addr, requested_socks_addr, reserved_listeners) = - if self.managed_by_codex { - let runtime = config::resolve_runtime(¤t_cfg)?; - #[cfg(target_os = "windows")] + #[cfg(target_os = "windows")] + let runtime_settings = NetworkProxyRuntimeSettings::from_config(¤t_cfg)?; + #[cfg(target_os = "windows")] + let mut windows_ingress = None; + let (requested_http_addr, requested_socks_addr, reserved_listeners) = if self + .managed_by_codex + { + let runtime = config::resolve_runtime(¤t_cfg)?; + #[cfg(target_os = "windows")] + { let (managed_http_addr, managed_socks_addr) = config::clamp_bind_addrs(runtime.http_addr, runtime.socks_addr, ¤t_cfg); - #[cfg(target_os = "windows")] - let reserved = reserve_windows_managed_listeners( + let ingress = WindowsProxyIngress::shared( managed_http_addr, managed_socks_addr, current_cfg.enable_socks5, - ) - .context("reserve managed loopback proxy listeners")?; - #[cfg(not(target_os = "windows"))] + )?; + let http_addr = ingress.http_addr(); + let socks_addr = ingress.socks_addr(); + windows_ingress = Some(ingress); + (http_addr, socks_addr, None) + } + #[cfg(not(target_os = "windows"))] + { let reserved = reserve_loopback_ephemeral_listeners(current_cfg.enable_socks5) .context("reserve managed loopback proxy listeners")?; let http_addr = reserved.http_addr()?; @@ -206,32 +229,59 @@ impl NetworkProxyBuilder { socks_addr, Some(reserved.into_reserved_listeners()), ) - } else { - let runtime = config::resolve_runtime(¤t_cfg)?; - ( - self.http_addr.unwrap_or(runtime.http_addr), - self.socks_addr.unwrap_or(runtime.socks_addr), - None, - ) - }; + } + } else { + let runtime = config::resolve_runtime(¤t_cfg)?; + ( + self.http_addr.unwrap_or(runtime.http_addr), + self.socks_addr.unwrap_or(runtime.socks_addr), + None, + ) + }; // Reapply bind clamping for caller overrides so unix-socket proxying stays loopback-only. let (http_addr, socks_addr) = config::clamp_bind_addrs(requested_http_addr, requested_socks_addr, ¤t_cfg); + #[cfg(target_os = "windows")] + let windows_runtime = windows_ingress.map(|ingress| { + let http = http_proxy::http_proxy_service( + Arc::clone(&state), + self.policy_decider.clone(), + /*environment_id*/ None, + ); + let socks = current_cfg.enable_socks5.then(|| { + socks5::socks5_proxy_service( + Arc::clone(&state), + self.policy_decider.clone(), + /*environment_id*/ None, + current_cfg.enable_socks5_udp, + ) + }); + Arc::new(WindowsSharedProxyRuntime { + ingress, + http_service: http, + socks_service: socks, + active_route: Arc::new(Mutex::new(None)), + }) + }); + + #[cfg(not(target_os = "windows"))] + let runtime_settings = NetworkProxyRuntimeSettings::from_config(¤t_cfg)?; + Ok(NetworkProxy { state, http_addr, socks_addr, socks_enabled: current_cfg.enable_socks5, socks5_udp_enabled: current_cfg.enable_socks5_udp, - runtime_settings: Arc::new(RwLock::new(NetworkProxyRuntimeSettings::from_config( - ¤t_cfg, - )?)), + runtime_settings: Arc::new(RwLock::new(runtime_settings)), reserved_listeners, policy_decider: self.policy_decider, environment_proxies: Arc::new(Mutex::new(HashMap::new())), execution_scope: None, + #[cfg(target_os = "windows")] + windows_runtime, }) } } @@ -250,7 +300,7 @@ fn reserve_loopback_ephemeral_listeners( } #[cfg(target_os = "windows")] -fn reserve_windows_managed_listeners( +pub(super) fn reserve_windows_managed_listeners( http_addr: SocketAddr, socks_addr: SocketAddr, reserve_socks_listener: bool, @@ -269,6 +319,24 @@ fn reserve_windows_managed_listeners( } } +#[cfg(target_os = "windows")] +pub(super) fn reserve_windows_managed_socks_listener( + socks_addr: SocketAddr, +) -> Result { + let socks_addr = windows_managed_loopback_addr(socks_addr); + match StdTcpListener::bind(socks_addr) { + Ok(listener) => Ok(listener), + Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => { + warn!( + "managed Windows SOCKS5 proxy port is busy; falling back to an ephemeral loopback port" + ); + reserve_loopback_ephemeral_listener() + .context("reserve fallback loopback SOCKS5 proxy listener") + } + Err(err) => Err(err).context("reserve Windows managed SOCKS5 proxy listener"), + } +} + #[cfg(target_os = "windows")] fn try_reserve_windows_managed_listeners( http_addr: SocketAddr, @@ -285,7 +353,7 @@ fn try_reserve_windows_managed_listeners( } #[cfg(target_os = "windows")] -fn windows_managed_loopback_addr(addr: SocketAddr) -> SocketAddr { +pub(super) fn windows_managed_loopback_addr(addr: SocketAddr) -> SocketAddr { if !addr.ip().is_loopback() { warn!( "managed Windows proxies must bind to loopback; clamping {addr} to 127.0.0.1:{}", @@ -354,8 +422,40 @@ pub struct PreparedManagedNetwork { struct EnvironmentProxy { addrs: EnvironmentProxyAddrs, - http_task: JoinHandle>, - socks_task: Option>>, + runtime: EnvironmentProxyRuntime, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum EnvironmentProxyClient { + SandboxedProcess, + TrustedBridge, +} + +enum EnvironmentProxyRuntime { + ListenerTasks { + http_task: JoinHandle>, + socks_task: Option>>, + }, + #[cfg(target_os = "windows")] + SharedIngress { _route: Arc }, +} + +impl EnvironmentProxyRuntime { + #[cfg(target_os = "windows")] + fn network_proxy_restricting_sid(&self) -> Option { + match self { + Self::ListenerTasks { .. } => None, + Self::SharedIngress { _route: route } => Some(route.sid().to_string()), + } + } +} + +#[cfg(target_os = "windows")] +struct WindowsSharedProxyRuntime { + ingress: Arc, + http_service: WindowsRouteService, + socks_service: Option, + active_route: Arc>>>, } #[derive(Clone)] @@ -370,6 +470,8 @@ pub struct NetworkProxy { policy_decider: Option>, environment_proxies: Arc>>, execution_scope: Option>, + #[cfg(target_os = "windows")] + windows_runtime: Option>, } impl std::fmt::Debug for NetworkProxy { @@ -378,7 +480,7 @@ impl std::fmt::Debug for NetworkProxy { // and may contain sensitive paths. f.debug_struct("NetworkProxy") .field("http_addr", &self.http_addr) - .field("socks_addr", &self.socks_addr) + .field("socks_addr", &self.socks_addr()) .finish_non_exhaustive() } } @@ -386,7 +488,7 @@ impl std::fmt::Debug for NetworkProxy { impl PartialEq for NetworkProxy { fn eq(&self, other: &Self) -> bool { self.http_addr == other.http_addr - && self.socks_addr == other.socks_addr + && self.socks_addr() == other.socks_addr() && self.runtime_settings() == other.runtime_settings() } } @@ -415,6 +517,11 @@ pub const PROXY_URL_ENV_KEYS: &[&str] = &[ pub const ALL_PROXY_ENV_KEYS: &[&str] = &["ALL_PROXY", "all_proxy"]; pub const PROXY_ACTIVE_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ACTIVE"; pub const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; +// Internal wire format shared with windows-sandbox-rs/src/setup.rs. The value is a +// comma-separated, sorted list of non-zero loopback proxy ports used only when computing the +// Windows offline sandbox setup marker. +#[cfg(target_os = "windows")] +const WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY: &str = "CODEX_WINDOWS_SANDBOX_PROXY_PORTS"; const ELECTRON_GET_USE_PROXY_ENV_KEY: &str = "ELECTRON_GET_USE_PROXY"; const NODE_USE_ENV_PROXY_ENV_KEY: &str = "NODE_USE_ENV_PROXY"; #[cfg(any(target_os = "macos", test))] @@ -424,6 +531,8 @@ pub const PROXY_ENV_KEYS: &[&str] = &[ CREDENTIAL_BROKER_ACTIVE_ENV_KEY, BROKERED_CREDENTIALS_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, + #[cfg(target_os = "windows")] + WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY, PROXY_ATTRIBUTION_TOKEN_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, @@ -460,6 +569,28 @@ pub const PROXY_ENV_KEYS: &[&str] = &[ "ftp_proxy", ]; +pub fn is_managed_proxy_env_var(key: &str, value: &str) -> bool { + if PROXY_ENV_KEYS.contains(&key) { + return true; + } + if crate::certs::CUSTOM_CA_ENV_KEYS.contains(&key) { + return crate::certs::is_managed_mitm_ca_trust_bundle_path(value); + } + #[cfg(target_os = "macos")] + { + key == PROXY_GIT_SSH_COMMAND_ENV_KEY + && value.starts_with(CODEX_PROXY_GIT_SSH_COMMAND_MARKER) + } + #[cfg(not(target_os = "macos"))] + { + false + } +} + +pub fn strip_managed_proxy_env(env: &mut HashMap) { + env.retain(|key, value| !is_managed_proxy_env_var(key, value)); +} + #[cfg(target_os = "macos")] pub const PROXY_GIT_SSH_COMMAND_ENV_KEY: &str = GIT_SSH_COMMAND_ENV_KEY; @@ -647,9 +778,35 @@ impl NetworkProxy { } pub fn socks_addr(&self) -> SocketAddr { + #[cfg(target_os = "windows")] + if let Some(runtime) = self.windows_runtime.as_ref() { + return runtime.ingress.socks_addr(); + } self.socks_addr } + /// Returns the restricting SID that identifies this logical proxy route to the shared + /// Windows ingress. Environment routes are available after their proxy settings are prepared. + #[cfg(target_os = "windows")] + pub fn network_proxy_restricting_sid(&self, environment_id: Option<&str>) -> Option { + match environment_id { + Some(environment_id) => self + .environment_proxies + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(environment_id) + .and_then(|proxy| proxy.runtime.network_proxy_restricting_sid()), + None => self.windows_runtime.as_ref().and_then(|runtime| { + runtime + .active_route + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .as_ref() + .map(|route| route.sid().to_string()) + }), + } + } + pub async fn current_cfg(&self) -> Result { self.state.current_cfg().await } @@ -712,7 +869,22 @@ impl NetworkProxy { &self, mut env: HashMap, addrs: EnvironmentProxyAddrs, + #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] + client: EnvironmentProxyClient, ) -> PreparedManagedNetwork { + #[cfg(target_os = "windows")] + let shared_socks_addr = (client == EnvironmentProxyClient::SandboxedProcess) + .then(|| { + self.windows_runtime + .as_ref() + .and_then(|runtime| runtime.ingress.active_socks_addr()) + }) + .flatten(); + #[cfg(target_os = "windows")] + let addrs = EnvironmentProxyAddrs { + socks_addr: shared_socks_addr.unwrap_or(addrs.socks_addr), + ..addrs + }; let runtime_settings = self.runtime_settings(); // Enforce proxying for child processes. Proxy endpoint values are always rewritten; // managed MITM CA vars preserve child-scoped overrides after proxy startup. @@ -731,9 +903,12 @@ impl NetworkProxy { execution_scope.attribution_token.clone(), ); } + let expose_socks_port = self.socks_enabled; + #[cfg(target_os = "windows")] + let expose_socks_port = expose_socks_port || shared_socks_addr.is_some(); let mut loopback_ports = [ Some(addrs.http_addr), - self.socks_enabled.then_some(addrs.socks_addr), + expose_socks_port.then_some(addrs.socks_addr), ] .into_iter() .flatten() @@ -742,6 +917,19 @@ impl NetworkProxy { .collect::>(); loopback_ports.sort_unstable(); loopback_ports.dedup(); + #[cfg(target_os = "windows")] + if client == EnvironmentProxyClient::SandboxedProcess && self.windows_runtime.is_some() { + env.insert( + WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY.to_string(), + loopback_ports + .iter() + .map(u16::to_string) + .collect::>() + .join(","), + ); + } else { + env.remove(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY); + } PreparedManagedNetwork { env, sandbox_context: ManagedNetworkSandboxContext { @@ -756,7 +944,11 @@ impl NetworkProxy { env: &mut HashMap, addrs: EnvironmentProxyAddrs, ) { - let prepared = self.prepare_for_addrs(std::mem::take(env), addrs); + let prepared = self.prepare_for_addrs( + std::mem::take(env), + addrs, + EnvironmentProxyClient::SandboxedProcess, + ); *env = prepared.env; } @@ -775,7 +967,8 @@ impl NetworkProxy { env: &mut HashMap, environment_id: &str, ) -> Result<()> { - let addrs = self.environment_proxy_addrs(environment_id)?; + let addrs = + self.environment_proxy_addrs(environment_id, EnvironmentProxyClient::SandboxedProcess)?; self.apply_to_env_for_addrs(env, addrs); Ok(()) } @@ -802,16 +995,36 @@ impl NetworkProxy { environment_id: Option<&str>, ) -> Result { let addrs = match environment_id { - Some(environment_id) => self.environment_proxy_addrs(environment_id)?, + Some(environment_id) => self.environment_proxy_addrs( + environment_id, + EnvironmentProxyClient::SandboxedProcess, + )?, None => EnvironmentProxyAddrs { http_addr: self.http_addr, socks_addr: self.socks_addr, }, }; - Ok(self.prepare_for_addrs(env, addrs)) + Ok(self.prepare_for_addrs(env, addrs, EnvironmentProxyClient::SandboxedProcess)) + } + + /// Prepares proxy settings for a remote executor whose connection reaches this process through + /// the trusted proxy bridge rather than directly from a locally spawned sandbox process. + pub fn prepare_for_remote_environment( + &self, + env: HashMap, + environment_id: &str, + ) -> Result { + let addrs = + self.environment_proxy_addrs(environment_id, EnvironmentProxyClient::TrustedBridge)?; + Ok(self.prepare_for_addrs(env, addrs, EnvironmentProxyClient::TrustedBridge)) } - fn environment_proxy_addrs(&self, environment_id: &str) -> Result { + fn environment_proxy_addrs( + &self, + environment_id: &str, + #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] + client: EnvironmentProxyClient, + ) -> Result { if let Some(execution_scope) = self.execution_scope.as_ref() { anyhow::ensure!( execution_scope.environment_id == environment_id, @@ -825,9 +1038,62 @@ impl NetworkProxy { .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if let Some(proxy) = proxies.get(environment_id) { + #[cfg(target_os = "windows")] + let uses_shared_ingress = client == EnvironmentProxyClient::SandboxedProcess + && self.windows_runtime.is_some(); + #[cfg(target_os = "windows")] + anyhow::ensure!( + matches!( + (&proxy.runtime, uses_shared_ingress), + (EnvironmentProxyRuntime::SharedIngress { .. }, true) + | (EnvironmentProxyRuntime::ListenerTasks { .. }, false) + ), + "network proxy for environment `{environment_id}` was prepared for a different client type" + ); return Ok(proxy.addrs); } + #[cfg(target_os = "windows")] + if client == EnvironmentProxyClient::SandboxedProcess + && let Some(windows_runtime) = self.windows_runtime.as_ref() + { + let active_route = windows_runtime + .active_route + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + anyhow::ensure!( + active_route.is_some(), + "shared managed Windows proxy route is not running" + ); + let environment_id = environment_id.to_string(); + let http = http_proxy::http_proxy_service( + Arc::clone(&self.state), + self.policy_decider.clone(), + Some(environment_id.clone()), + ); + let socks = self.socks_enabled.then(|| { + socks5::socks5_proxy_service( + Arc::clone(&self.state), + self.policy_decider.clone(), + Some(environment_id.clone()), + self.socks5_udp_enabled, + ) + }); + let route = Arc::new(windows_runtime.ingress.register_route(http, socks)); + let addrs = EnvironmentProxyAddrs { + http_addr: self.http_addr, + socks_addr: self.socks_addr, + }; + proxies.insert( + environment_id, + EnvironmentProxy { + addrs, + runtime: EnvironmentProxyRuntime::SharedIngress { _route: route }, + }, + ); + return Ok(addrs); + } + let runtime = tokio::runtime::Handle::try_current().with_context(|| { format!("failed to create network proxy for environment `{environment_id}`") })?; @@ -889,8 +1155,10 @@ impl NetworkProxy { environment_id, EnvironmentProxy { addrs, - http_task, - socks_task, + runtime: EnvironmentProxyRuntime::ListenerTasks { + http_task, + socks_task, + }, }, ); Ok(addrs) @@ -918,7 +1186,6 @@ impl NetworkProxy { new_state.config.enable_socks5_udp == current_cfg.enable_socks5_udp, "cannot update network.enable_socks5_udp on a running proxy" ); - let settings = NetworkProxyRuntimeSettings::from_config(&new_state.config)?; self.state.replace_config_state(new_state).await?; let mut guard = self @@ -953,6 +1220,27 @@ impl NetworkProxy { ); } + #[cfg(target_os = "windows")] + if let Some(windows_runtime) = self.windows_runtime.as_ref() { + let mut active_route = windows_runtime + .active_route + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + anyhow::ensure!( + active_route.is_none(), + "shared managed Windows proxy route is already running" + ); + *active_route = Some(Arc::new(windows_runtime.ingress.register_route( + windows_runtime.http_service.clone(), + windows_runtime.socks_service.clone(), + ))); + drop(active_route); + return Ok(NetworkProxyHandle::windows_shared( + Arc::clone(&windows_runtime.active_route), + Arc::clone(&self.environment_proxies), + )); + } + let reserved_listeners = self.reserved_listeners.as_ref(); let http_listener = reserved_listeners.and_then(|listeners| listeners.take_http()); let socks_listener = reserved_listeners.and_then(|listeners| listeners.take_socks()); @@ -1021,6 +1309,8 @@ impl NetworkProxy { socks_task, environment_proxies: self.environment_proxies.clone(), completed: false, + #[cfg(target_os = "windows")] + windows_active_route: None, }) } } @@ -1030,6 +1320,8 @@ pub struct NetworkProxyHandle { socks_task: Option>>, environment_proxies: Arc>>, completed: bool, + #[cfg(target_os = "windows")] + windows_active_route: Option>>>>, } impl NetworkProxyHandle { @@ -1039,6 +1331,35 @@ impl NetworkProxyHandle { socks_task: None, environment_proxies: Arc::new(Mutex::new(HashMap::new())), completed: true, + #[cfg(target_os = "windows")] + windows_active_route: None, + } + } + + #[cfg(target_os = "windows")] + fn windows_shared( + active_route: Arc>>>, + environment_proxies: Arc>>, + ) -> Self { + Self { + http_task: Some(tokio::spawn(async { + std::future::pending::<()>().await; + Ok(()) + })), + socks_task: None, + environment_proxies, + completed: false, + windows_active_route: Some(active_route), + } + } + + #[cfg(target_os = "windows")] + fn deactivate_windows_route(&mut self) { + if let Some(active_route) = self.windows_active_route.take() { + active_route + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take(); } } @@ -1050,6 +1371,8 @@ impl NetworkProxyHandle { Some(task) => Some(task.await), None => None, }; + #[cfg(target_os = "windows")] + self.deactivate_windows_route(); self.completed = true; abort_environment_proxies(self.environment_proxies.clone()).await; http_result??; @@ -1060,6 +1383,8 @@ impl NetworkProxyHandle { } pub async fn shutdown(mut self) -> Result<()> { + #[cfg(target_os = "windows")] + self.deactivate_windows_route(); abort_tasks(self.http_task.take(), self.socks_task.take()).await; abort_environment_proxies(self.environment_proxies.clone()).await; self.completed = true; @@ -1092,11 +1417,35 @@ async fn abort_environment_proxies( guard.drain().map(|(_, proxy)| proxy).collect::>() }; for proxy in proxies { - abort_task(Some(proxy.http_task)).await; - abort_task(proxy.socks_task).await; + match proxy.runtime { + EnvironmentProxyRuntime::ListenerTasks { + http_task, + socks_task, + } => { + abort_task(Some(http_task)).await; + abort_task(socks_task).await; + } + #[cfg(target_os = "windows")] + EnvironmentProxyRuntime::SharedIngress { .. } => {} + } } } +#[cfg(target_os = "windows")] +fn unregister_windows_ingress_environment_routes( + environment_proxies: &Arc>>, +) { + environment_proxies + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .retain(|_, proxy| { + matches!( + &proxy.runtime, + EnvironmentProxyRuntime::ListenerTasks { .. } + ) + }); +} + impl Drop for NetworkProxyHandle { fn drop(&mut self) { if self.completed { @@ -1105,6 +1454,11 @@ impl Drop for NetworkProxyHandle { let http_task = self.http_task.take(); let socks_task = self.socks_task.take(); let environment_proxies = self.environment_proxies.clone(); + #[cfg(target_os = "windows")] + { + self.deactivate_windows_route(); + unregister_windows_ingress_environment_routes(&environment_proxies); + } tokio::spawn(async move { abort_tasks(http_task, socks_task).await; abort_environment_proxies(environment_proxies).await; @@ -1122,8 +1476,13 @@ mod tests { use std::net::Ipv4Addr; use std::path::Path; + #[cfg(target_os = "windows")] + static WINDOWS_INGRESS_TEST_LOCK: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); + #[tokio::test] async fn managed_proxy_builder_uses_loopback_ports() { + #[cfg(target_os = "windows")] + let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap(); let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); let http_addr = http_listener.local_addr().unwrap(); let socks_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); @@ -1132,6 +1491,7 @@ mod tests { drop(socks_listener); let state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig { + enabled: true, proxy_url: format!("http://{http_addr}"), socks_url: format!("http://{socks_addr}"), ..NetworkProxyConfig::default() @@ -1155,6 +1515,75 @@ mod tests { { assert_eq!(proxy.http_addr, http_addr); assert_eq!(proxy.socks_addr, socks_addr); + assert_eq!(proxy.network_proxy_restricting_sid(None), None); + let handle = proxy.run().await.expect("start stable ingress route"); + let second_state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig { + enabled: true, + proxy_url: format!("http://{http_addr}"), + socks_url: format!("http://{socks_addr}"), + ..NetworkProxyConfig::default() + })); + let second = NetworkProxy::builder() + .state(second_state) + .build() + .await + .expect("second proxy should share the stable ingress"); + let second_handle = second + .run() + .await + .expect("start second stable ingress route"); + assert_eq!(second.http_addr, proxy.http_addr); + assert_eq!(second.socks_addr, proxy.socks_addr); + assert_ne!( + second.network_proxy_restricting_sid(None), + proxy.network_proxy_restricting_sid(None) + ); + let differently_configured = + Arc::new(network_proxy_state_for_policy(NetworkProxyConfig { + enabled: true, + proxy_url: "http://127.0.0.1:1".to_string(), + socks_url: "http://127.0.0.1:2".to_string(), + allow_local_binding: true, + ..NetworkProxyConfig::default() + })); + let third = NetworkProxy::builder() + .state(differently_configured) + .build() + .await + .expect("different route config should share the stable ingress"); + let third_handle = third + .run() + .await + .expect("start differently configured stable route"); + assert_eq!(third.http_addr, proxy.http_addr); + assert_eq!(third.socks_addr, proxy.socks_addr); + assert!(third.allow_local_binding()); + let replacement = crate::state::build_config_state( + NetworkProxyConfig { + enabled: true, + proxy_url: format!("http://{http_addr}"), + socks_url: format!("http://{socks_addr}"), + allow_local_binding: true, + ..NetworkProxyConfig::default() + }, + Default::default(), + ) + .expect("replacement config state"); + proxy + .replace_config_state(replacement) + .await + .expect("live route should accept a local-binding policy change"); + assert!(proxy.allow_local_binding()); + third_handle + .shutdown() + .await + .expect("stop differently configured stable route"); + second_handle + .shutdown() + .await + .expect("stop second stable ingress route"); + handle.shutdown().await.expect("stop stable ingress route"); + assert_eq!(proxy.network_proxy_restricting_sid(None), None); } #[cfg(not(target_os = "windows"))] { @@ -1190,27 +1619,45 @@ 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(NetworkProxyConfig::default())); + #[cfg(target_os = "windows")] + let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap(); + let state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig { + enabled: true, + ..NetworkProxyConfig::default() + })); let proxy = NetworkProxy::builder().state(state).build().await?; let handle = proxy.run().await?; let base_env = HashMap::from([("PRESERVED".to_string(), "value".to_string())]); let local = proxy.prepare_for_optional_environment(base_env.clone(), Some("local"))?; - let remote = proxy.prepare_for_optional_environment(HashMap::new(), Some("remote"))?; + let remote = proxy.prepare_for_remote_environment(HashMap::new(), "remote")?; assert_eq!( local.env.get("PRESERVED").map(String::as_str), Some("value") ); - assert_ne!(local.env.get("HTTP_PROXY"), remote.env.get("HTTP_PROXY")); - assert_ne!( - local.env.get("HTTP_PROXY"), - Some(&format!("http://{}", proxy.http_addr())) - ); - assert_ne!( - remote.env.get("HTTP_PROXY"), - Some(&format!("http://{}", proxy.http_addr())) - ); + #[cfg(target_os = "windows")] + { + assert_eq!( + local.env.get("HTTP_PROXY"), + Some(&format!("http://{}", proxy.http_addr())) + ); + assert_ne!(local.env.get("HTTP_PROXY"), remote.env.get("HTTP_PROXY")); + assert!(proxy.network_proxy_restricting_sid(Some("local")).is_some()); + assert_eq!(proxy.network_proxy_restricting_sid(Some("remote")), None); + } + #[cfg(not(target_os = "windows"))] + { + assert_ne!(local.env.get("HTTP_PROXY"), remote.env.get("HTTP_PROXY")); + assert_ne!( + local.env.get("HTTP_PROXY"), + Some(&format!("http://{}", proxy.http_addr())) + ); + assert_ne!( + remote.env.get("HTTP_PROXY"), + Some(&format!("http://{}", proxy.http_addr())) + ); + } for prepared in [&local, &remote] { let http_port = prepared .env @@ -1247,6 +1694,8 @@ mod tests { #[tokio::test] async fn remote_launch_config_carries_execution_scope() -> Result<()> { + #[cfg(target_os = "windows")] + let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap(); let state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig::default())); let proxy = match NetworkProxy::builder().state(state).build().await { Ok(proxy) => proxy, @@ -1270,11 +1719,19 @@ mod tests { } #[tokio::test] - async fn managed_proxy_builder_does_not_reserve_socks_listener_when_disabled() { + async fn managed_proxy_builder_lazily_upgrades_disabled_socks() { + #[cfg(target_os = "windows")] + let _permit = WINDOWS_INGRESS_TEST_LOCK.acquire().await.unwrap(); + let http_listener = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let http_addr = http_listener.local_addr().unwrap(); + drop(http_listener); + let occupied_socks = StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let socks_addr = occupied_socks.local_addr().unwrap(); let settings = NetworkProxyConfig { + enabled: true, enable_socks5: false, - proxy_url: "http://127.0.0.1:43128".to_string(), - socks_url: "http://127.0.0.1:43129".to_string(), + proxy_url: format!("http://{http_addr}"), + socks_url: format!("http://{socks_addr}"), ..NetworkProxyConfig::default() }; let state = Arc::new(network_proxy_state_for_policy(settings)); @@ -1293,10 +1750,132 @@ mod tests { assert!(proxy.http_addr.ip().is_loopback()); assert_ne!(proxy.http_addr.port(), 0); - assert_eq!( - proxy.socks_addr, - "127.0.0.1:43129".parse::().unwrap() - ); + assert_eq!(proxy.socks_addr, socks_addr); + #[cfg(target_os = "windows")] + { + assert_eq!(proxy.http_addr, http_addr); + assert!(proxy.reserved_listeners.is_none()); + assert!(proxy.windows_runtime.is_some()); + assert_eq!(proxy.network_proxy_restricting_sid(None), None); + let handle = proxy.run().await.expect("start HTTP-only stable route"); + assert!(proxy.network_proxy_restricting_sid(None).is_some()); + let prepared_before_upgrade = proxy + .prepare_for_optional_environment( + HashMap::from([( + WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY.to_string(), + "1,2".to_string(), + )]), + None, + ) + .expect("prepare stable Windows proxy"); + assert_eq!( + prepared_before_upgrade.sandbox_context.loopback_ports, + vec![proxy.http_addr.port()] + ); + assert_eq!( + prepared_before_upgrade + .env + .get(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY), + Some(&proxy.http_addr.port().to_string()) + ); + assert_eq!( + prepared_before_upgrade.env.get("ALL_PROXY"), + Some(&format!("http://{}", proxy.http_addr)) + ); + let environment_id = "cached-before-socks-upgrade"; + let environment_before_upgrade = proxy + .prepare_for_optional_environment(HashMap::new(), Some(environment_id)) + .expect("prepare cached HTTP-only environment"); + assert_eq!( + environment_before_upgrade.sandbox_context.loopback_ports, + vec![proxy.http_addr.port()] + ); + + let requested_socks = + StdTcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))).unwrap(); + let requested_socks_addr = requested_socks.local_addr().unwrap(); + assert_ne!(requested_socks_addr, socks_addr); + let socks_state = Arc::new(network_proxy_state_for_policy(NetworkProxyConfig { + enabled: true, + enable_socks5: true, + proxy_url: format!("http://{http_addr}"), + socks_url: format!("socks5://{requested_socks_addr}"), + ..NetworkProxyConfig::default() + })); + let socks_proxy = NetworkProxy::builder() + .state(socks_state) + .build() + .await + .expect("upgrade stable ingress to SOCKS5"); + let actual_socks_addr = socks_proxy.socks_addr(); + assert_eq!(socks_proxy.http_addr(), proxy.http_addr()); + assert!(actual_socks_addr.ip().is_loopback()); + assert_ne!(actual_socks_addr, requested_socks_addr); + assert_eq!(proxy.socks_addr(), actual_socks_addr); + let socks_handle = socks_proxy + .run() + .await + .expect("start SOCKS-enabled stable route"); + + let mut expected_ports = vec![proxy.http_addr.port(), actual_socks_addr.port()]; + expected_ports.sort_unstable(); + let prepared_after_upgrade = proxy + .prepare_for_optional_environment(HashMap::new(), None) + .expect("re-prepare HTTP-only route after SOCKS5 upgrade"); + let environment_after_upgrade = proxy + .prepare_for_optional_environment(HashMap::new(), Some(environment_id)) + .expect("re-prepare cached environment after SOCKS5 upgrade"); + for prepared in [&prepared_after_upgrade, &environment_after_upgrade] { + assert_eq!(prepared.sandbox_context.loopback_ports, expected_ports); + assert_eq!( + prepared.env.get(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY), + Some( + &expected_ports + .iter() + .map(u16::to_string) + .collect::>() + .join(",") + ) + ); + assert_eq!( + prepared.env.get("ALL_PROXY"), + Some(&format!("http://{}", proxy.http_addr)) + ); + } + let socks_prepared = socks_proxy + .prepare_for_optional_environment(HashMap::new(), None) + .expect("prepare SOCKS-enabled route"); + assert_eq!( + socks_prepared.sandbox_context.loopback_ports, + expected_ports + ); + assert_eq!( + socks_prepared.env.get("ALL_PROXY"), + Some(&format!("socks5h://{actual_socks_addr}")) + ); + let remote = proxy + .prepare_for_remote_environment( + HashMap::from([( + WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY.to_string(), + "1,2".to_string(), + )]), + "remote", + ) + .expect("prepare HTTP-only trusted bridge proxy"); + assert_eq!(remote.sandbox_context.loopback_ports.len(), 1); + assert_eq!(remote.env.get(WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY), None); + assert_eq!(proxy.network_proxy_restricting_sid(Some("remote")), None); + socks_handle + .shutdown() + .await + .expect("stop SOCKS-enabled stable route"); + handle + .shutdown() + .await + .expect("stop HTTP-only stable route"); + assert_eq!(proxy.network_proxy_restricting_sid(None), None); + } + #[cfg(not(target_os = "windows"))] assert!( proxy .reserved_listeners @@ -1305,6 +1884,8 @@ mod tests { .take_socks() .is_none() ); + drop(proxy); + drop(occupied_socks); } #[cfg(target_os = "windows")] diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index f2fa36b8ef9e..9817e880d9d5 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -29,6 +29,7 @@ use rama_core::error::BoxError; use rama_core::extensions::Extensions; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; +use rama_core::service::BoxService; use rama_core::service::service_fn; use rama_net::address::HostWithPort; use rama_net::client::EstablishedClientConnection; @@ -129,6 +130,23 @@ async fn run_socks5_with_listener( } } + listener + .serve(socks5_proxy_service( + state, + policy_decider, + environment_id, + enable_socks5_udp, + )) + .await; + Ok(()) +} + +pub(crate) fn socks5_proxy_service( + state: Arc, + policy_decider: Option>, + environment_id: Option, + enable_socks5_udp: bool, +) -> BoxService { let tcp_connector = TargetCheckedTcpConnector::new(state.clone()); let policy_tcp_connector = service_fn({ let policy_decider = policy_decider.clone(); @@ -163,19 +181,10 @@ async fn run_socks5_with_listener( } })); let socks_acceptor = base.with_udp_associator(udp_relay); - listener - .serve(BindConnectionAttribution::new( - socks_acceptor, - state, - environment_id, - )) - .await; + BindConnectionAttribution::new(socks_acceptor, state, environment_id).boxed() } else { - listener - .serve(BindConnectionAttribution::new(base, state, environment_id)) - .await; + BindConnectionAttribution::new(base, state, environment_id).boxed() } - Ok(()) } async fn handle_socks5_tcp( diff --git a/codex-rs/network-proxy/src/windows_proxy_ingress.rs b/codex-rs/network-proxy/src/windows_proxy_ingress.rs new file mode 100644 index 000000000000..a1b7891c6b68 --- /dev/null +++ b/codex-rs/network-proxy/src/windows_proxy_ingress.rs @@ -0,0 +1,368 @@ +use crate::proxy::reserve_windows_managed_listeners; +use crate::proxy::reserve_windows_managed_socks_listener; +use crate::proxy::windows_managed_loopback_addr; +use crate::windows_tcp_attribution::restricting_sids_for_tcp_connection; +use anyhow::Context; +use anyhow::Result; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::service::BoxService; +use rama_net::stream::Socket; +use rama_tcp::TcpStream; +use rama_tcp::server::TcpListener; +use std::collections::HashMap; +use std::io; +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::Mutex; +#[cfg(test)] +use std::sync::Weak; +use tokio::runtime::Handle; +use tokio::task::JoinHandle; +use tracing::info; + +pub(crate) type WindowsRouteService = BoxService; + +// Production keeps the listeners alive for the process lifetime so their ports remain stable even +// when no routes are registered. Crate tests use independent Tokio runtimes and requested ports, so +// they retain only a weak reference and can tear each ingress down between tests. +#[cfg(not(test))] +static SHARED_INGRESS: LazyLock>>> = + LazyLock::new(|| Mutex::new(None)); +#[cfg(test)] +static SHARED_INGRESS: LazyLock>> = + LazyLock::new(|| Mutex::new(Weak::new())); + +#[derive(Clone)] +struct RouteServices { + http: WindowsRouteService, + socks: Option, +} + +type RouteRegistry = Arc>>>; + +#[derive(Clone, Copy)] +enum ProxyProtocol { + Http, + Socks, +} + +#[derive(Clone)] +struct IngressDispatcher { + routes: RouteRegistry, + protocol: ProxyProtocol, +} + +impl Service for IngressDispatcher { + type Output = (); + type Error = BoxError; + + async fn serve(&self, stream: TcpStream) -> Result<(), BoxError> { + let local_addr = stream.local_addr()?; + let peer_addr = stream.peer_addr()?; + let restricting_sids = restricting_sids_for_tcp_connection(local_addr, peer_addr)?; + let route = { + let routes = self + .routes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + registered_route_for_sids(&routes, &restricting_sids)? + }; + let service = match self.protocol { + ProxyProtocol::Http => route.http.clone(), + ProxyProtocol::Socks => route.socks.clone().ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "network proxy route does not enable SOCKS5", + ) + })?, + }; + service.serve(stream).await + } +} + +pub(crate) struct WindowsProxyIngress { + http_addr: SocketAddr, + routes: RouteRegistry, + runtime: Handle, + http_task: JoinHandle<()>, + socks: Mutex, +} + +struct SocksListenerState { + addr: SocketAddr, + task: Option>, +} + +impl WindowsProxyIngress { + pub(crate) fn shared( + requested_http_addr: SocketAddr, + requested_socks_addr: SocketAddr, + reserve_socks_listener: bool, + ) -> Result> { + let requested_http_addr = windows_managed_loopback_addr(requested_http_addr); + let requested_socks_addr = windows_managed_loopback_addr(requested_socks_addr); + let mut shared = SHARED_INGRESS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + #[cfg(not(test))] + if let Some(ingress) = shared.as_ref() + && ingress.is_running() + { + if reserve_socks_listener { + ingress.ensure_socks_listener(requested_socks_addr)?; + } + return Ok(Arc::clone(ingress)); + } + #[cfg(not(test))] + shared.take(); + #[cfg(test)] + if let Some(ingress) = shared.upgrade() + && ingress.is_running() + { + if reserve_socks_listener { + ingress.ensure_socks_listener(requested_socks_addr)?; + } + return Ok(ingress); + } + + let listeners = reserve_windows_managed_listeners( + requested_http_addr, + requested_socks_addr, + reserve_socks_listener, + ) + .context("reserve shared managed Windows proxy ingress")?; + let http_addr = listeners.http_addr()?; + let socks_addr = listeners.socks_addr(requested_socks_addr)?; + let (http_listener, socks_listener) = listeners.into_listeners(); + let http_listener = + TcpListener::try_from(http_listener).context("convert shared HTTP ingress listener")?; + let socks_listener = socks_listener + .map(TcpListener::try_from) + .transpose() + .context("convert shared SOCKS5 ingress listener")?; + let runtime = + Handle::try_current().context("start shared managed Windows proxy ingress")?; + let routes = Arc::new(Mutex::new(HashMap::new())); + let http_task = runtime.spawn(run_listener( + http_listener, + IngressDispatcher { + routes: Arc::clone(&routes), + protocol: ProxyProtocol::Http, + }, + "HTTP", + http_addr, + )); + let socks_task = socks_listener + .map(|listener| spawn_socks_listener(&runtime, &routes, listener, socks_addr)); + let ingress = Arc::new(Self { + http_addr, + routes, + runtime, + http_task, + socks: Mutex::new(SocksListenerState { + addr: socks_addr, + task: socks_task, + }), + }); + #[cfg(not(test))] + { + *shared = Some(Arc::clone(&ingress)); + } + #[cfg(test)] + { + *shared = Arc::downgrade(&ingress); + } + Ok(ingress) + } + + pub(crate) fn http_addr(&self) -> SocketAddr { + self.http_addr + } + + pub(crate) fn socks_addr(&self) -> SocketAddr { + self.socks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .addr + } + + pub(crate) fn active_socks_addr(&self) -> Option { + let socks = self + .socks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + socks + .task + .as_ref() + .filter(|task| !task.is_finished()) + .map(|_| socks.addr) + } + + pub(crate) fn register_route( + self: &Arc, + http: WindowsRouteService, + socks: Option, + ) -> WindowsProxyRoute { + let services = Arc::new(RouteServices { http, socks }); + let mut routes = self + .routes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let sid = loop { + let sid = random_restricting_sid(); + if !routes.contains_key(&sid) { + break sid; + } + }; + routes.insert(sid.clone(), Arc::clone(&services)); + WindowsProxyRoute { + sid, + services, + ingress: Arc::clone(self), + } + } + + fn is_running(&self) -> bool { + !self.http_task.is_finished() + && self + .socks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .task + .as_ref() + .is_none_or(|task| !task.is_finished()) + } + + fn ensure_socks_listener(&self, requested_addr: SocketAddr) -> Result<()> { + let mut socks = self + .socks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(task) = socks.task.as_ref() { + anyhow::ensure!( + !task.is_finished(), + "shared managed Windows SOCKS5 ingress stopped" + ); + return Ok(()); + } + + let listener = reserve_windows_managed_socks_listener(requested_addr) + .context("reserve shared managed Windows SOCKS5 ingress")?; + let addr = listener + .local_addr() + .context("read shared managed Windows SOCKS5 ingress address")?; + let listener = { + let _runtime = self.runtime.enter(); + TcpListener::try_from(listener) + } + .context("convert shared SOCKS5 ingress listener")?; + let task = spawn_socks_listener(&self.runtime, &self.routes, listener, addr); + socks.addr = addr; + socks.task = Some(task); + Ok(()) + } +} + +impl Drop for WindowsProxyIngress { + fn drop(&mut self) { + self.http_task.abort(); + let socks = self + .socks + .get_mut() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(socks_task) = socks.task.as_ref() { + socks_task.abort(); + } + } +} + +fn spawn_socks_listener( + runtime: &Handle, + routes: &RouteRegistry, + listener: TcpListener, + addr: SocketAddr, +) -> JoinHandle<()> { + runtime.spawn(run_listener( + listener, + IngressDispatcher { + routes: Arc::clone(routes), + protocol: ProxyProtocol::Socks, + }, + "SOCKS5", + addr, + )) +} + +pub(crate) struct WindowsProxyRoute { + sid: String, + services: Arc, + ingress: Arc, +} + +impl WindowsProxyRoute { + pub(crate) fn sid(&self) -> &str { + &self.sid + } +} + +impl Drop for WindowsProxyRoute { + fn drop(&mut self) { + let mut routes = self + .ingress + .routes + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if routes + .get(&self.sid) + .is_some_and(|services| Arc::ptr_eq(services, &self.services)) + { + routes.remove(&self.sid); + } + } +} + +async fn run_listener( + listener: TcpListener, + dispatcher: IngressDispatcher, + protocol: &'static str, + addr: SocketAddr, +) { + info!("shared managed Windows {protocol} proxy ingress listening on {addr}"); + listener.serve(dispatcher).await; +} + +fn registered_route_for_sids( + routes: &HashMap>, + restricting_sids: &[String], +) -> io::Result> { + let mut matching_routes = restricting_sids + .iter() + .filter_map(|sid| routes.get(sid).cloned()); + let route = matching_routes.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "proxy client token has no registered network proxy route SID", + ) + })?; + if matching_routes.next().is_some() { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "proxy client token has multiple registered network proxy route SIDs", + )); + } + Ok(route) +} + +fn random_restricting_sid() -> String { + let a = rand::random::(); + let b = rand::random::(); + let c = rand::random::(); + let d = rand::random::(); + format!("S-1-5-21-{a}-{b}-{c}-{d}") +} + +#[cfg(test)] +#[path = "windows_proxy_ingress_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/windows_proxy_ingress_tests.rs b/codex-rs/network-proxy/src/windows_proxy_ingress_tests.rs new file mode 100644 index 000000000000..6fb0cc8ee21e --- /dev/null +++ b/codex-rs/network-proxy/src/windows_proxy_ingress_tests.rs @@ -0,0 +1,43 @@ +use super::*; +use rama_core::service::service_fn; + +#[test] +fn selects_exactly_one_registered_route() { + let route = route_services(); + let routes = HashMap::from([("registered".to_string(), Arc::clone(&route))]); + + let selected = registered_route_for_sids( + &routes, + &["unrelated".to_string(), "registered".to_string()], + ) + .expect("one registered route should be selected"); + + assert!(Arc::ptr_eq(&selected, &route)); +} + +#[test] +fn rejects_missing_or_ambiguous_registered_routes() { + let first = route_services(); + let second = route_services(); + let routes = HashMap::from([("first".to_string(), first), ("second".to_string(), second)]); + + let Err(missing) = registered_route_for_sids(&routes, &["missing".to_string()]) else { + panic!("an unknown SID should fail closed"); + }; + let Err(ambiguous) = + registered_route_for_sids(&routes, &["first".to_string(), "second".to_string()]) + else { + panic!("multiple registered SIDs should fail closed"); + }; + + assert_eq!(missing.kind(), io::ErrorKind::PermissionDenied); + assert_eq!(ambiguous.kind(), io::ErrorKind::PermissionDenied); +} + +fn route_services() -> Arc { + let service = service_fn(|_stream: TcpStream| async { Ok::<(), BoxError>(()) }).boxed(); + Arc::new(RouteServices { + http: service, + socks: None, + }) +} diff --git a/codex-rs/network-proxy/src/windows_tcp_attribution.rs b/codex-rs/network-proxy/src/windows_tcp_attribution.rs new file mode 100644 index 000000000000..5146d5e6cec6 --- /dev/null +++ b/codex-rs/network-proxy/src/windows_tcp_attribution.rs @@ -0,0 +1,315 @@ +use std::ffi::c_void; +use std::io; +use std::mem::offset_of; +use std::mem::size_of; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::net::SocketAddrV4; +use std::os::windows::io::AsRawHandle; +use std::os::windows::io::FromRawHandle; +use std::os::windows::io::OwnedHandle; +use std::os::windows::io::RawHandle; + +use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Foundation::NO_ERROR; +use windows_sys::Win32::Foundation::PSID; +use windows_sys::Win32::NetworkManagement::IpHelper::GetExtendedTcpTable; +use windows_sys::Win32::NetworkManagement::IpHelper::MIB_TCPROW_OWNER_PID; +use windows_sys::Win32::NetworkManagement::IpHelper::MIB_TCPTABLE_OWNER_PID; +use windows_sys::Win32::NetworkManagement::IpHelper::TCP_TABLE_OWNER_PID_CONNECTIONS; +use windows_sys::Win32::Networking::WinSock::AF_INET; +use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; +use windows_sys::Win32::Security::GetTokenInformation; +use windows_sys::Win32::Security::SID_AND_ATTRIBUTES; +use windows_sys::Win32::Security::TOKEN_GROUPS; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::Security::TokenRestrictedSids; +use windows_sys::Win32::System::Threading::OpenProcess; +use windows_sys::Win32::System::Threading::OpenProcessToken; +use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; + +/// Returns the restricting SIDs on the process that opened an accepted loopback connection. +/// +/// `accepted_local_addr` and `accepted_peer_addr` must come from the accepted server socket. The +/// owning-PID table describes the client side in the opposite direction, so the lookup matches the +/// exact reversed four-tuple. +pub(crate) fn restricting_sids_for_tcp_connection( + accepted_local_addr: SocketAddr, + accepted_peer_addr: SocketAddr, +) -> io::Result> { + let (SocketAddr::V4(accepted_local_addr), SocketAddr::V4(accepted_peer_addr)) = + (accepted_local_addr, accepted_peer_addr) + else { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Windows proxy connection attribution currently supports IPv4 only", + )); + }; + + let process_id = owning_process_id(accepted_local_addr, accepted_peer_addr)?; + restricting_sids_for_process(process_id) +} + +fn owning_process_id( + accepted_local_addr: SocketAddrV4, + accepted_peer_addr: SocketAddrV4, +) -> io::Result { + let mut byte_len = 0_u32; + let result = unsafe { + GetExtendedTcpTable( + std::ptr::null_mut(), + &mut byte_len, + 0, + AF_INET as u32, + TCP_TABLE_OWNER_PID_CONNECTIONS, + 0, + ) + }; + if result != ERROR_INSUFFICIENT_BUFFER { + return Err(win32_error("query IPv4 TCP owner table size", result)); + } + + let buffer = loop { + let mut buffer = aligned_buffer(byte_len as usize)?; + let result = unsafe { + GetExtendedTcpTable( + buffer.as_mut_ptr().cast::(), + &mut byte_len, + 0, + AF_INET as u32, + TCP_TABLE_OWNER_PID_CONNECTIONS, + 0, + ) + }; + match result { + NO_ERROR => break buffer, + ERROR_INSUFFICIENT_BUFFER => continue, + _ => return Err(win32_error("read IPv4 TCP owner table", result)), + } + }; + + let rows = parse_tcp_owner_rows(&buffer, byte_len as usize)?; + unique_client_process_id(rows, accepted_local_addr, accepted_peer_addr) +} + +fn parse_tcp_owner_rows(buffer: &[usize], byte_len: usize) -> io::Result<&[MIB_TCPROW_OWNER_PID]> { + let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table); + if byte_len > size_of_val(buffer) || byte_len < rows_offset { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid IPv4 TCP owner table length", + )); + } + + let row_count = unsafe { std::ptr::read_unaligned(buffer.as_ptr().cast::()) } as usize; + let rows_byte_len = row_count + .checked_mul(size_of::()) + .and_then(|len| rows_offset.checked_add(len)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "IPv4 TCP owner table length overflow", + ) + })?; + if rows_byte_len > byte_len { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated IPv4 TCP owner table", + )); + } + + let rows = unsafe { + let rows_ptr = buffer + .as_ptr() + .cast::() + .add(rows_offset) + .cast::(); + std::slice::from_raw_parts(rows_ptr, row_count) + }; + Ok(rows) +} + +fn unique_client_process_id( + rows: &[MIB_TCPROW_OWNER_PID], + accepted_local_addr: SocketAddrV4, + accepted_peer_addr: SocketAddrV4, +) -> io::Result { + let mut matching_process_ids = rows + .iter() + .filter(|row| client_row_matches(row, accepted_local_addr, accepted_peer_addr)) + .map(|row| row.dwOwningPid); + let process_id = matching_process_ids.next().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "accepted connection is absent from the IPv4 TCP owner table", + ) + })?; + if matching_process_ids.next().is_some() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "accepted connection has multiple IPv4 TCP owner rows", + )); + } + Ok(process_id) +} + +fn client_row_matches( + row: &MIB_TCPROW_OWNER_PID, + accepted_local_addr: SocketAddrV4, + accepted_peer_addr: SocketAddrV4, +) -> bool { + ipv4_addr_matches(row.dwLocalAddr, *accepted_peer_addr.ip()) + && tcp_port(row.dwLocalPort) == accepted_peer_addr.port() + && ipv4_addr_matches(row.dwRemoteAddr, *accepted_local_addr.ip()) + && tcp_port(row.dwRemotePort) == accepted_local_addr.port() +} + +fn ipv4_addr_matches(table_addr: u32, socket_addr: Ipv4Addr) -> bool { + table_addr.to_ne_bytes() == socket_addr.octets() +} + +fn tcp_port(table_port: u32) -> u16 { + u16::from_be(table_port as u16) +} + +fn restricting_sids_for_process(process_id: u32) -> io::Result> { + let process_handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + let process = owned_handle(process_handle, "open proxy client process")?; + + let mut token_handle: HANDLE = 0; + let opened = unsafe { + OpenProcessToken( + process.as_raw_handle() as HANDLE, + TOKEN_QUERY, + &mut token_handle, + ) + }; + if opened == 0 { + return Err(last_error("open proxy client process token")); + } + let token = owned_handle(token_handle, "open proxy client process token")?; + + let mut byte_len = 0_u32; + let queried = unsafe { + GetTokenInformation( + token.as_raw_handle() as HANDLE, + TokenRestrictedSids, + std::ptr::null_mut(), + 0, + &mut byte_len, + ) + }; + if queried != 0 || unsafe { GetLastError() } != ERROR_INSUFFICIENT_BUFFER { + return Err(last_error("query proxy client restricting SID buffer size")); + } + + let mut buffer = aligned_buffer(byte_len as usize)?; + let queried = unsafe { + GetTokenInformation( + token.as_raw_handle() as HANDLE, + TokenRestrictedSids, + buffer.as_mut_ptr().cast::(), + byte_len, + &mut byte_len, + ) + }; + if queried == 0 { + return Err(last_error("read proxy client restricting SIDs")); + } + + parse_token_groups(&buffer, byte_len as usize)? + .iter() + .map(|entry| sid_to_string(entry.Sid)) + .collect() +} + +fn parse_token_groups(buffer: &[usize], byte_len: usize) -> io::Result<&[SID_AND_ATTRIBUTES]> { + let groups_offset = offset_of!(TOKEN_GROUPS, Groups); + if byte_len > size_of_val(buffer) || byte_len < groups_offset { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid restricting SID buffer length", + )); + } + + let group_count = unsafe { std::ptr::read_unaligned(buffer.as_ptr().cast::()) } as usize; + let groups_byte_len = group_count + .checked_mul(size_of::()) + .and_then(|len| groups_offset.checked_add(len)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "restricting SID buffer length overflow", + ) + })?; + if groups_byte_len > byte_len { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "truncated restricting SID buffer", + )); + } + + let groups = unsafe { + let groups_ptr = buffer + .as_ptr() + .cast::() + .add(groups_offset) + .cast::(); + std::slice::from_raw_parts(groups_ptr, group_count) + }; + Ok(groups) +} + +fn sid_to_string(sid: PSID) -> io::Result { + let mut string_sid = std::ptr::null_mut(); + if unsafe { ConvertSidToStringSidW(sid, &mut string_sid) } == 0 { + return Err(last_error("convert proxy client restricting SID to string")); + } + + let value = unsafe { + let mut len = 0; + while *string_sid.add(len) != 0 { + len += 1; + } + String::from_utf16_lossy(std::slice::from_raw_parts(string_sid, len)) + }; + unsafe { + LocalFree(string_sid as HLOCAL); + } + Ok(value) +} + +fn aligned_buffer(byte_len: usize) -> io::Result> { + if byte_len == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Windows API returned an empty buffer length", + )); + } + Ok(vec![0; byte_len.div_ceil(size_of::())]) +} + +fn owned_handle(handle: HANDLE, operation: &str) -> io::Result { + if handle == 0 { + return Err(last_error(operation)); + } + Ok(unsafe { OwnedHandle::from_raw_handle(handle as RawHandle) }) +} + +fn win32_error(operation: &str, error_code: u32) -> io::Error { + let error = io::Error::from_raw_os_error(error_code as i32); + io::Error::new(error.kind(), format!("{operation}: {error}")) +} + +fn last_error(operation: &str) -> io::Error { + let error = io::Error::last_os_error(); + io::Error::new(error.kind(), format!("{operation}: {error}")) +} + +#[cfg(test)] +#[path = "windows_tcp_attribution_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/windows_tcp_attribution_tests.rs b/codex-rs/network-proxy/src/windows_tcp_attribution_tests.rs new file mode 100644 index 000000000000..ebbc4b810970 --- /dev/null +++ b/codex-rs/network-proxy/src/windows_tcp_attribution_tests.rs @@ -0,0 +1,112 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::net::TcpListener; +use std::net::TcpStream; + +#[test] +fn parses_owner_table_and_matches_reversed_client_tuple() -> io::Result<()> { + let proxy_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 3128); + let client_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 49152); + let rows = [ + tcp_row(proxy_addr, client_addr, 100), + tcp_row(client_addr, proxy_addr, 200), + ]; + let (buffer, byte_len) = owner_table_buffer(&rows); + + let parsed = parse_tcp_owner_rows(&buffer, byte_len)?; + + assert_eq!( + unique_client_process_id(parsed, proxy_addr, client_addr)?, + 200 + ); + Ok(()) +} + +#[test] +fn rejects_multiple_matching_owner_rows() { + let proxy_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 3128); + let client_addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, 49152); + let rows = [ + tcp_row(client_addr, proxy_addr, 200), + tcp_row(client_addr, proxy_addr, 201), + ]; + + let error = unique_client_process_id(&rows, proxy_addr, client_addr) + .expect_err("duplicate connection rows should fail closed"); + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); +} + +#[test] +fn rejects_truncated_owner_table() { + let byte_len = offset_of!(MIB_TCPTABLE_OWNER_PID, table); + let mut buffer = aligned_buffer(byte_len).expect("aligned table buffer"); + unsafe { + std::ptr::write_unaligned(buffer.as_mut_ptr().cast::(), 1); + } + + let Err(error) = parse_tcp_owner_rows(&buffer, byte_len) else { + panic!("truncated connection row should fail closed"); + }; + + assert_eq!(error.kind(), io::ErrorKind::InvalidData); +} + +#[test] +fn resolves_loopback_connection_to_current_process() -> io::Result<()> { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let client = TcpStream::connect(listener.local_addr()?)?; + let (accepted, _) = listener.accept()?; + let local_addr = accepted.local_addr()?; + let peer_addr = accepted.peer_addr()?; + + let process_id = owning_process_id(socket_addr_v4(local_addr)?, socket_addr_v4(peer_addr)?)?; + let restricting_sids = restricting_sids_for_tcp_connection(local_addr, peer_addr)?; + + assert_eq!(process_id, std::process::id()); + assert!(restricting_sids.iter().all(|sid| sid.starts_with("S-"))); + drop(client); + Ok(()) +} + +fn socket_addr_v4(addr: SocketAddr) -> io::Result { + match addr { + SocketAddr::V4(addr) => Ok(addr), + SocketAddr::V6(_) => Err(io::Error::new( + io::ErrorKind::InvalidData, + "test listener unexpectedly used IPv6", + )), + } +} + +fn tcp_row( + local_addr: SocketAddrV4, + remote_addr: SocketAddrV4, + process_id: u32, +) -> MIB_TCPROW_OWNER_PID { + MIB_TCPROW_OWNER_PID { + dwState: 0, + dwLocalAddr: u32::from_ne_bytes(local_addr.ip().octets()), + dwLocalPort: local_addr.port().to_be() as u32, + dwRemoteAddr: u32::from_ne_bytes(remote_addr.ip().octets()), + dwRemotePort: remote_addr.port().to_be() as u32, + dwOwningPid: process_id, + } +} + +fn owner_table_buffer(rows: &[MIB_TCPROW_OWNER_PID]) -> (Vec, usize) { + let rows_offset = offset_of!(MIB_TCPTABLE_OWNER_PID, table); + let rows_byte_len = size_of_val(rows); + let byte_len = rows_offset + rows_byte_len; + let mut buffer = aligned_buffer(byte_len).expect("aligned table buffer"); + unsafe { + let buffer_ptr = buffer.as_mut_ptr().cast::(); + std::ptr::write_unaligned(buffer_ptr.cast::(), rows.len() as u32); + std::ptr::copy_nonoverlapping( + rows.as_ptr().cast::(), + buffer_ptr.add(rows_offset), + rows_byte_len, + ); + } + (buffer, byte_len) +} diff --git a/codex-rs/network-proxy/tests/windows_stable_ingress.rs b/codex-rs/network-proxy/tests/windows_stable_ingress.rs new file mode 100644 index 000000000000..510fb5c7649a --- /dev/null +++ b/codex-rs/network-proxy/tests/windows_stable_ingress.rs @@ -0,0 +1,590 @@ +#![cfg(target_os = "windows")] + +use codex_network_proxy::ConfigReloader; +use codex_network_proxy::ConfigReloaderFuture; +use codex_network_proxy::ConfigState; +use codex_network_proxy::NetworkDecision; +use codex_network_proxy::NetworkMode; +use codex_network_proxy::NetworkPolicyDecider; +use codex_network_proxy::NetworkPolicyRequest; +use codex_network_proxy::NetworkProtocol; +use codex_network_proxy::NetworkProxy; +use codex_network_proxy::NetworkProxyConfig; +use codex_network_proxy::NetworkProxyState; +use codex_network_proxy::build_config_state; +use codex_windows_sandbox::ConsoleMode; +use codex_windows_sandbox::LocalSid; +use codex_windows_sandbox::create_process_as_user; +use codex_windows_sandbox::create_readonly_token_with_caps_and_user_from; +use codex_windows_sandbox::get_current_token_for_restriction; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::io::BufRead; +use std::io::BufReader; +use std::io::Read; +use std::io::Write; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::net::TcpListener; +use std::net::TcpStream; +use std::os::windows::io::AsRawHandle; +use std::os::windows::io::FromRawHandle; +use std::os::windows::io::OwnedHandle; +use std::sync::Arc; +use std::sync::Mutex; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use windows_sys::Win32::System::Threading::GetExitCodeProcess; +use windows_sys::Win32::System::Threading::TerminateProcess; +use windows_sys::Win32::System::Threading::WaitForSingleObject; + +const CHILD_MODE_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_CHILD"; +const HTTP_ADDR_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_HTTP_ADDR"; +const SOCKS_ADDR_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_SOCKS_ADDR"; +const ORIGIN_PORT_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_ORIGIN_PORT"; +const ALLOWED_HOST_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_ALLOWED_HOST"; +const DENIED_HOST_ENV: &str = "CODEX_WINDOWS_PROXY_TEST_DENIED_HOST"; +const FIRST_ENVIRONMENT_ID: &str = "first-environment"; +const SECOND_ENVIRONMENT_ID: &str = "second-environment"; +const DECIDER_DENIED_HOST: &str = "not-allowed.invalid"; +const CHILD_TIMEOUT_MS: u32 = 30_000; +const WAIT_OBJECT_0: u32 = 0; + +#[derive(Clone)] +struct StaticReloader(ConfigState); + +impl ConfigReloader for StaticReloader { + fn source_label(&self) -> String { + "test config".to_string() + } + + fn maybe_reload(&self) -> ConfigReloaderFuture<'_, Option> { + Box::pin(async { Ok(None) }) + } + + fn reload_now(&self) -> ConfigReloaderFuture<'_, ConfigState> { + let state = self.0.clone(); + Box::pin(async move { Ok(state) }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restricted_tokens_select_stable_routes_and_cleanup() -> anyhow::Result<()> { + let (origin_port, origin_task) = start_http_origin().await?; + let (first_decider, first_requests) = recording_decider(); + let (second_decider, second_requests) = recording_decider(); + let first_requested = requested_addrs()?; + let first = build_proxy( + first_requested, + "localhost", + /*enable_socks5*/ false, + Some(first_decider), + ) + .await?; + let initial_addrs = (first.http_addr(), first.socks_addr()); + let first_handle = first.run().await?; + let first_sid = first + .network_proxy_restricting_sid(None) + .expect("running proxy should have a route SID"); + first.prepare_for_optional_environment(HashMap::new(), Some(FIRST_ENVIRONMENT_ID))?; + let first_environment_sid = first + .network_proxy_restricting_sid(Some(FIRST_ENVIRONMENT_ID)) + .expect("first environment should have a route SID"); + + let second_requested = requested_addrs()?; + assert_ne!(second_requested.0, initial_addrs.0); + let second = build_proxy( + second_requested, + "127.0.0.1", + /*enable_socks5*/ true, + Some(second_decider), + ) + .await?; + let stable_addrs = (second.http_addr(), second.socks_addr()); + assert_eq!(stable_addrs.0, initial_addrs.0); + assert_eq!(stable_addrs.1, second_requested.1); + assert_eq!((first.http_addr(), first.socks_addr()), stable_addrs); + let second_handle = second.run().await?; + let second_sid = second + .network_proxy_restricting_sid(None) + .expect("running proxy should have a route SID"); + assert_ne!(second_sid, first_sid); + + second.prepare_for_optional_environment(HashMap::new(), Some(SECOND_ENVIRONMENT_ID))?; + let second_environment_sid = second + .network_proxy_restricting_sid(Some(SECOND_ENVIRONMENT_ID)) + .expect("second environment should have a route SID"); + + run_restricted_child( + &first_environment_sid, + stable_addrs, + origin_port, + Some(("localhost", DECIDER_DENIED_HOST)), + /*expect_socks*/ false, + ) + .await?; + assert_recorded_requests( + &first_requests, + FIRST_ENVIRONMENT_ID, + DECIDER_DENIED_HOST, + origin_port, + &[NetworkProtocol::Http], + ); + assert!( + second_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty() + ); + + run_restricted_child( + &second_environment_sid, + stable_addrs, + origin_port, + Some(("127.0.0.1", DECIDER_DENIED_HOST)), + /*expect_socks*/ true, + ) + .await?; + assert_recorded_requests( + &first_requests, + FIRST_ENVIRONMENT_ID, + DECIDER_DENIED_HOST, + origin_port, + &[NetworkProtocol::Http], + ); + assert_recorded_requests( + &second_requests, + SECOND_ENVIRONMENT_ID, + DECIDER_DENIED_HOST, + origin_port, + &[NetworkProtocol::Http, NetworkProtocol::Socks5Tcp], + ); + + run_restricted_child( + &first_sid, + stable_addrs, + origin_port, + Some(("localhost", "127.0.0.1")), + /*expect_socks*/ false, + ) + .await?; + run_restricted_child( + &second_sid, + stable_addrs, + origin_port, + Some(("127.0.0.1", "localhost")), + /*expect_socks*/ true, + ) + .await?; + + first_handle.shutdown().await?; + run_restricted_child( + &first_sid, + stable_addrs, + origin_port, + None, + /*expect_socks*/ false, + ) + .await?; + run_restricted_child( + &first_environment_sid, + stable_addrs, + origin_port, + None, + /*expect_socks*/ false, + ) + .await?; + run_restricted_child( + &second_sid, + stable_addrs, + origin_port, + Some(("127.0.0.1", "localhost")), + /*expect_socks*/ true, + ) + .await?; + + second_handle.shutdown().await?; + drop((first, second)); + + let third_requested = requested_addrs()?; + assert_ne!(third_requested, stable_addrs); + let third = build_proxy( + third_requested, + "localhost", + /*enable_socks5*/ false, + None, + ) + .await?; + assert_eq!((third.http_addr(), third.socks_addr()), stable_addrs); + let third_handle = third.run().await?; + let third_sid = third + .network_proxy_restricting_sid(None) + .expect("running proxy should have a route SID"); + assert_ne!(third_sid, first_sid); + assert_ne!(third_sid, second_sid); + + run_restricted_child( + &third_sid, + stable_addrs, + origin_port, + Some(("localhost", "127.0.0.1")), + /*expect_socks*/ false, + ) + .await?; + drop(third_handle); + assert_eq!(third.network_proxy_restricting_sid(None), None); + run_restricted_child( + &third_sid, + stable_addrs, + origin_port, + None, + /*expect_socks*/ false, + ) + .await?; + origin_task.abort(); + Ok(()) +} + +#[test] +fn restricted_child_exercises_http_and_socks() -> anyhow::Result<()> { + let Ok(mode) = std::env::var(CHILD_MODE_ENV) else { + return Ok(()); + }; + let http_addr = required_env(HTTP_ADDR_ENV)?.parse::()?; + let socks_addr = required_env(SOCKS_ADDR_ENV)?.parse::()?; + let origin_port = required_env(ORIGIN_PORT_ENV)?.parse::()?; + + if mode == "missing-route" { + let authority = format!("localhost:{origin_port}"); + assert!(http_status(http_addr, &authority).is_err()); + assert!(socks_status(socks_addr, "localhost", origin_port).is_err()); + return Ok(()); + } + + let allowed_host = required_env(ALLOWED_HOST_ENV)?; + let denied_host = required_env(DENIED_HOST_ENV)?; + let allowed_authority = format!("{allowed_host}:{origin_port}"); + let denied_authority = format!("{denied_host}:{origin_port}"); + assert_eq!(http_status(http_addr, &allowed_authority)?, 200); + assert_eq!(http_status(http_addr, &denied_authority)?, 403); + if mode == "http-only" { + assert!(socks_status(socks_addr, &allowed_host, origin_port).is_err()); + return Ok(()); + } + assert_eq!( + socks_status(socks_addr, &allowed_host, origin_port)?, + SocksOutcome::Connected + ); + assert!(matches!( + socks_status(socks_addr, &denied_host, origin_port)?, + SocksOutcome::Denied(_) + )); + Ok(()) +} + +async fn build_proxy( + requested_addrs: (SocketAddr, SocketAddr), + allowed_domain: &str, + enable_socks5: bool, + policy_decider: Option>, +) -> anyhow::Result { + let (http_addr, socks_addr) = requested_addrs; + let mut config = NetworkProxyConfig { + enabled: true, + proxy_url: format!("http://{http_addr}"), + socks_url: format!("socks5://{socks_addr}"), + enable_socks5, + enable_socks5_udp: false, + allow_local_binding: true, + mode: NetworkMode::Full, + ..NetworkProxyConfig::default() + }; + config.set_allowed_domains(vec![allowed_domain.to_string()]); + let config_state = build_config_state(config, Default::default())?; + let reloader = Arc::new(StaticReloader(config_state.clone())); + let state = Arc::new(NetworkProxyState::with_reloader(config_state, reloader)); + let mut builder = NetworkProxy::builder().state(state); + if let Some(policy_decider) = policy_decider { + builder = builder.policy_decider_arc(policy_decider); + } + builder.build().await +} + +fn recording_decider() -> ( + Arc, + Arc>>, +) { + let requests = Arc::new(Mutex::new(Vec::new())); + let recorded_requests = Arc::clone(&requests); + let decider: Arc = Arc::new(move |request: NetworkPolicyRequest| { + recorded_requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(request); + async { NetworkDecision::deny("integration test denial") } + }); + (decider, requests) +} + +fn assert_recorded_requests( + requests: &Arc>>, + environment_id: &str, + host: &str, + port: u16, + expected_protocols: &[NetworkProtocol], +) { + let requests = requests + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(requests.len(), expected_protocols.len()); + let actual_protocols = requests + .iter() + .map(|request| request.protocol) + .collect::>(); + assert_eq!(actual_protocols, expected_protocols); + assert!(requests.iter().all(|request| { + request.environment_id.as_deref() == Some(environment_id) + && request.host == host + && request.port == port + })); +} + +fn requested_addrs() -> std::io::Result<(SocketAddr, SocketAddr)> { + let http = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + let socks = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; + Ok((http.local_addr()?, socks.local_addr()?)) +} + +async fn start_http_origin() -> std::io::Result<(u16, tokio::task::JoinHandle<()>)> { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let port = listener.local_addr()?.port(); + let task = tokio::spawn(async move { + while let Ok((mut stream, _)) = listener.accept().await { + tokio::spawn(async move { + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request).await; + let _ = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK", + ) + .await; + }); + } + }); + Ok((port, task)) +} + +async fn run_restricted_child( + route_sid: &str, + proxy_addrs: (SocketAddr, SocketAddr), + origin_port: u16, + policy: Option<(&str, &str)>, + expect_socks: bool, +) -> anyhow::Result<()> { + let route_sid = route_sid.to_string(); + let policy = policy.map(|(allowed, denied)| (allowed.to_string(), denied.to_string())); + tokio::task::spawn_blocking(move || { + run_restricted_child_blocking(&route_sid, proxy_addrs, origin_port, policy, expect_socks) + }) + .await??; + Ok(()) +} + +fn run_restricted_child_blocking( + route_sid: &str, + (http_addr, socks_addr): (SocketAddr, SocketAddr), + origin_port: u16, + policy: Option<(String, String)>, + expect_socks: bool, +) -> anyhow::Result<()> { + let route_sid = LocalSid::from_string(route_sid)?; + let capability_sid = LocalSid::from_string("S-1-5-21-10-20-30-40")?; + let base_token = unsafe { + OwnedHandle::from_raw_handle(get_current_token_for_restriction()? as *mut std::ffi::c_void) + }; + let restricted_token = unsafe { + create_readonly_token_with_caps_and_user_from( + base_token.as_raw_handle() as isize, + &[capability_sid.as_ptr()], + &[route_sid.as_ptr()], + )? + }; + let restricted_token = + unsafe { OwnedHandle::from_raw_handle(restricted_token as *mut std::ffi::c_void) }; + + let mut env = std::env::vars().collect::>(); + env.insert(HTTP_ADDR_ENV.to_string(), http_addr.to_string()); + env.insert(SOCKS_ADDR_ENV.to_string(), socks_addr.to_string()); + env.insert(ORIGIN_PORT_ENV.to_string(), origin_port.to_string()); + match policy { + Some((allowed, denied)) => { + let mode = if expect_socks { "policy" } else { "http-only" }; + env.insert(CHILD_MODE_ENV.to_string(), mode.to_string()); + env.insert(ALLOWED_HOST_ENV.to_string(), allowed); + env.insert(DENIED_HOST_ENV.to_string(), denied); + } + None => { + env.insert(CHILD_MODE_ENV.to_string(), "missing-route".to_string()); + } + } + + let test_exe = std::env::current_exe()?; + let command = vec![ + test_exe.to_string_lossy().into_owned(), + "--exact".to_string(), + "restricted_child_exercises_http_and_socks".to_string(), + "--nocapture".to_string(), + "--test-threads=1".to_string(), + ]; + let cwd = std::env::current_dir()?; + let spawned = unsafe { + create_process_as_user( + restricted_token.as_raw_handle() as isize, + &command, + &cwd, + &env, + /*logs_base_dir*/ None, + /*stdio*/ None, + /*console_mode*/ ConsoleMode::Inherit, + /*use_private_desktop*/ false, + )? + }; + let process = unsafe { + OwnedHandle::from_raw_handle(spawned.process_info.hProcess as *mut std::ffi::c_void) + }; + let _thread = unsafe { + OwnedHandle::from_raw_handle(spawned.process_info.hThread as *mut std::ffi::c_void) + }; + + let wait = unsafe { + WaitForSingleObject( + process.as_raw_handle() as isize, + /*dwMilliseconds*/ CHILD_TIMEOUT_MS, + ) + }; + if wait != WAIT_OBJECT_0 { + unsafe { + TerminateProcess(process.as_raw_handle() as isize, 1); + } + } + let mut exit_code = 1_u32; + unsafe { + GetExitCodeProcess(process.as_raw_handle() as isize, &mut exit_code); + } + anyhow::ensure!( + wait == WAIT_OBJECT_0 && exit_code == 0, + "restricted proxy child failed (wait={wait}, exit={exit_code})" + ); + Ok(()) +} + +fn required_env(key: &str) -> anyhow::Result { + std::env::var(key).map_err(Into::into) +} + +fn http_status(proxy_addr: SocketAddr, authority: &str) -> std::io::Result { + let mut stream = TcpStream::connect(proxy_addr)?; + configure_stream(&stream)?; + write!( + stream, + "GET http://{authority}/ HTTP/1.1\r\nHost: {authority}\r\nConnection: close\r\n\r\n" + )?; + read_http_status(&mut stream) +} + +#[derive(Debug, Eq, PartialEq)] +enum SocksOutcome { + Connected, + Denied(u8), +} + +fn socks_status( + proxy_addr: SocketAddr, + host: &str, + origin_port: u16, +) -> std::io::Result { + let mut stream = TcpStream::connect(proxy_addr)?; + configure_stream(&stream)?; + stream.write_all(&[5, 1, 0])?; + let mut greeting = [0_u8; 2]; + stream.read_exact(&mut greeting)?; + if greeting != [5, 0] { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "SOCKS5 proxy rejected no-authentication method", + )); + } + + let mut request = vec![5, 1, 0]; + if let Ok(ip) = host.parse::() { + request.push(1); + request.extend_from_slice(&ip.octets()); + } else { + let host_len = u8::try_from(host.len()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "SOCKS5 hostname too long") + })?; + request.extend_from_slice(&[3, host_len]); + request.extend_from_slice(host.as_bytes()); + } + request.extend_from_slice(&origin_port.to_be_bytes()); + stream.write_all(&request)?; + + let mut reply = [0_u8; 4]; + stream.read_exact(&mut reply)?; + if reply[0] != 5 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid SOCKS5 response version", + )); + } + if reply[1] != 0 { + return Ok(SocksOutcome::Denied(reply[1])); + } + consume_socks_bound_address(&mut stream, reply[3])?; + Ok(SocksOutcome::Connected) +} + +fn consume_socks_bound_address(stream: &mut TcpStream, address_type: u8) -> std::io::Result<()> { + let address_len = match address_type { + 1 => 4, + 3 => { + let mut len = [0_u8; 1]; + stream.read_exact(&mut len)?; + usize::from(len[0]) + } + 4 => 16, + _ => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "invalid SOCKS5 bound address type", + )); + } + }; + let mut address_and_port = vec![0_u8; address_len + 2]; + stream.read_exact(&mut address_and_port) +} + +fn configure_stream(stream: &TcpStream) -> std::io::Result<()> { + let timeout = Some(Duration::from_secs(5)); + stream.set_read_timeout(timeout)?; + stream.set_write_timeout(timeout) +} + +fn read_http_status(stream: &mut TcpStream) -> std::io::Result { + let mut status_line = String::new(); + if BufReader::new(stream).read_line(&mut status_line)? == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "proxy closed before an HTTP status line", + )); + } + status_line + .split_ascii_whitespace() + .nth(1) + .ok_or_else(|| { + std::io::Error::new(std::io::ErrorKind::InvalidData, "missing HTTP status code") + })? + .parse::() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)) +} diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 51d2d6c98e59..c3c629438033 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -532,6 +532,19 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( let inner_command = std::mem::take(&mut request.command); let proxy_enforced = request.network.is_some(); + let network_proxy_restricting_sid = request + .network + .as_ref() + .map(|network| { + network + .network_proxy_restricting_sid(request.network_environment_id.as_deref()) + .ok_or_else(|| { + SandboxTransformError::WindowsSandboxPreparation( + "managed Windows proxy route is missing its restricting SID".to_string(), + ) + }) + }) + .transpose()?; let use_elevated = windows_sandbox_uses_elevated_backend(request.windows_sandbox_level, proxy_enforced); let overrides = if use_elevated { @@ -576,6 +589,7 @@ fn wrap_windows_sandbox_exec_request_for_direct_spawn( request.windows_sandbox_level, request.windows_sandbox_private_desktop, proxy_enforced, + network_proxy_restricting_sid.as_deref(), proxy_settings_mode, read_roots_override, read_roots_include_platform_defaults, diff --git a/codex-rs/sandboxing/src/spawn.rs b/codex-rs/sandboxing/src/spawn.rs index ae9a6812a529..5766c2b167e6 100644 --- a/codex-rs/sandboxing/src/spawn.rs +++ b/codex-rs/sandboxing/src/spawn.rs @@ -19,6 +19,7 @@ pub struct WindowsSandboxSpawnRequest<'a> { pub workspace_roots: &'a [AbsolutePathBuf], pub windows_sandbox_level: WindowsSandboxLevel, pub proxy_enforced: bool, + pub network_proxy_restricting_sid: Option<&'a str>, pub proxy_settings_mode: WindowsSandboxProxySettingsMode, pub filesystem_overrides: Option<&'a WindowsSandboxFilesystemOverrides>, pub use_private_desktop: bool, @@ -60,6 +61,9 @@ pub async fn spawn_process(request: SpawnRequest<'_>) -> Result env_map: request.env.clone(), windows_sandbox_level: windows.windows_sandbox_level, proxy_enforced: windows.proxy_enforced, + network_proxy_restricting_sid: windows + .network_proxy_restricting_sid + .map(str::to_owned), proxy_settings_mode: windows.proxy_settings_mode, timeout_ms: None, read_roots_override: overrides diff --git a/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs index c167b661fd76..b38b35f307a5 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs @@ -273,19 +273,37 @@ fn spawn_ipc_process(req: &SpawnRequest) -> Result { if cap_psids.is_empty() { anyhow::bail!("runner: empty capability SID list"); } + let network_proxy_restricting_sid = req + .network_proxy_restricting_sid + .as_deref() + .map(LocalSid::from_string) + .transpose() + .context("ConvertStringSidToSidW failed for network proxy restricting SID")?; // The token helpers still take raw SID pointers, but we keep ownership in `LocalSid` // wrappers for as long as possible. That way any failure after SID parsing but before the // child is fully spawned still releases the backing LocalAlloc memory automatically. let cap_psid_ptrs: Vec<*mut _> = cap_psids.iter().map(LocalSid::as_ptr).collect(); + let additional_restricting_sid_ptrs: Vec<*mut _> = network_proxy_restricting_sid + .iter() + .map(LocalSid::as_ptr) + .collect(); let base = OwnedWinHandle::new(unsafe { get_current_token_for_restriction()? }); let h_token = OwnedWinHandle::new(unsafe { match token_mode { WindowsSandboxTokenMode::ReadOnlyCapability => { - create_readonly_token_with_caps_and_user_from(base.raw(), &cap_psid_ptrs) + create_readonly_token_with_caps_and_user_from( + base.raw(), + &cap_psid_ptrs, + &additional_restricting_sid_ptrs, + ) } WindowsSandboxTokenMode::WritableRootsCapability => { - create_workspace_write_token_with_caps_and_user_from(base.raw(), &cap_psid_ptrs) + create_workspace_write_token_with_caps_and_user_from( + base.raw(), + &cap_psid_ptrs, + &additional_restricting_sid_ptrs, + ) } } }?); diff --git a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs index fb99529f03e7..fca07f73c350 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated/ipc_framed.rs @@ -26,7 +26,7 @@ use std::path::PathBuf; const MAX_FRAME_LEN: usize = 8 * 1024 * 1024; /// Protocol version shared by the parent process and elevated command runner. -pub const IPC_PROTOCOL_VERSION: u8 = 4; +pub const IPC_PROTOCOL_VERSION: u8 = 5; /// Length-prefixed, JSON-encoded frame. #[derive(Debug, Serialize, Deserialize, Clone)] @@ -65,6 +65,9 @@ pub struct SpawnRequest { pub codex_home: PathBuf, pub real_codex_home: PathBuf, pub cap_sids: Vec, + /// Optional managed-network identity added only to the child's restricting SID set. + #[serde(default)] + pub network_proxy_restricting_sid: Option, pub timeout_ms: Option, pub tty: bool, #[serde(default)] @@ -224,6 +227,7 @@ mod tests { codex_home: PathBuf::from(r"C:\codex"), real_codex_home: PathBuf::from(r"C:\Users\codex"), cap_sids: vec!["S-1-15-3-1024-1".to_string()], + network_proxy_restricting_sid: Some("S-1-5-21-100-200-300-400".to_string()), timeout_ms: Some(1000), tty: false, stdin_open: false, @@ -245,6 +249,10 @@ mod tests { }; assert_eq!(PermissionProfile::read_only(), payload.permission_profile); assert_eq!(workspace_roots, payload.workspace_roots); + assert_eq!( + Some("S-1-5-21-100-200-300-400"), + payload.network_proxy_restricting_sid.as_deref() + ); } #[test] diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 5475b9542e75..1de7889c1f9f 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -15,6 +15,7 @@ pub struct ElevatedSandboxProfileCaptureRequest<'a> { pub cancellation: Option, pub use_private_desktop: bool, pub proxy_enforced: bool, + pub network_proxy_restricting_sid: Option, pub read_roots_override: Option<&'a [PathBuf]>, pub read_roots_include_platform_defaults: bool, pub write_roots_override: Option<&'a [PathBuf]>, @@ -110,6 +111,7 @@ mod windows_impl { cancellation, use_private_desktop, proxy_enforced, + network_proxy_restricting_sid, read_roots_override, read_roots_include_platform_defaults, write_roots_override, @@ -190,6 +192,7 @@ mod windows_impl { codex_home: sandbox_base.clone(), real_codex_home: codex_home.to_path_buf(), cap_sids, + network_proxy_restricting_sid, timeout_ms, tty: false, stdin_open: false, diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 40ae46b61745..7365e3882452 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 66d203bd74fb..ad72c10497fa 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -705,6 +705,10 @@ const PROXY_ENV_KEYS: &[&str] = &[ "wss_proxy", ]; const ALLOW_LOCAL_BINDING_ENV_KEY: &str = "CODEX_NETWORK_ALLOW_LOCAL_BINDING"; +// Internal wire format shared with network-proxy/src/proxy.rs. The value is a comma-separated, +// sorted list of non-zero loopback proxy ports used only when computing the Windows offline +// sandbox setup marker. +const WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY: &str = "CODEX_WINDOWS_SANDBOX_PROXY_PORTS"; pub(crate) fn offline_proxy_settings_from_env( env_map: &HashMap, @@ -744,6 +748,14 @@ pub(crate) fn proxy_ports_from_env(env_map: &HashMap) -> Vec().ok()) + .filter(|port| *port != 0), + ); + } ports.into_iter().collect() } @@ -1034,7 +1046,11 @@ 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<()> { +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( @@ -1063,8 +1079,8 @@ pub fn run_elevated_provisioning_setup(codex_home: &Path, real_user: &str) -> Re write_roots: Vec::new(), deny_read_paths: Vec::new(), deny_write_paths: Vec::new(), - proxy_ports: Vec::new(), - allow_local_binding: false, + 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, @@ -1264,6 +1280,7 @@ fn filter_sensitive_write_roots(mut roots: Vec, codex_home: &Path) -> V #[cfg(test)] mod tests { use super::WINDOWS_PLATFORM_DEFAULT_READ_ROOTS; + use super::WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY; use super::build_payload_roots; use super::find_setup_exe_for_current_exe; use super::gather_full_read_roots_for_permissions; @@ -1599,8 +1616,12 @@ mod tests { "HTTPS_PROXY".to_string(), "https://example.com:9999".to_string(), ); + env.insert( + WINDOWS_SANDBOX_PROXY_PORTS_ENV_KEY.to_string(), + "8080,43129,0,invalid, 43128,65536".to_string(), + ); - assert_eq!(proxy_ports_from_env(&env), vec![1081, 8080]); + assert_eq!(proxy_ports_from_env(&env), vec![1081, 8080, 43128, 43129]); } #[test] diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index 2c7a74647457..688ec529c414 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -381,7 +381,8 @@ pub unsafe fn create_workspace_write_token_with_caps_from( create_token_with_caps_from(base_token, psid_capabilities, &[]) } -/// Create a restricted token that includes all provided capability SIDs plus the token user SID. +/// Create a restricted token that includes all provided capability SIDs, the token user SID, and +/// any additional restricting SIDs. /// /// This is intended for the elevated sandbox backend, where the token user is the dedicated /// sandbox account rather than the real signed-in user. @@ -391,10 +392,13 @@ pub unsafe fn create_workspace_write_token_with_caps_from( pub unsafe fn create_workspace_write_token_with_caps_and_user_from( base_token: HANDLE, psid_capabilities: &[*mut c_void], + additional_restricting_sids: &[*mut c_void], ) -> Result { - let mut user_sid_bytes = get_user_sid_bytes(base_token)?; - let psid_user = user_sid_bytes.as_mut_ptr() as *mut c_void; - create_token_with_caps_from(base_token, psid_capabilities, &[psid_user]) + create_token_with_caps_user_and_additional_restrictions_from( + base_token, + psid_capabilities, + additional_restricting_sids, + ) } /// Create a restricted token that includes all provided capability SIDs. @@ -408,7 +412,8 @@ pub unsafe fn create_readonly_token_with_caps_from( create_token_with_caps_from(base_token, psid_capabilities, &[]) } -/// Create a restricted token that includes all provided capability SIDs plus the token user SID. +/// Create a restricted token that includes all provided capability SIDs, the token user SID, and +/// any additional restricting SIDs. /// /// This is intended for the elevated sandbox backend, where the token user is the dedicated /// sandbox account rather than the real signed-in user. @@ -418,10 +423,26 @@ pub unsafe fn create_readonly_token_with_caps_from( pub unsafe fn create_readonly_token_with_caps_and_user_from( base_token: HANDLE, psid_capabilities: &[*mut c_void], + additional_restricting_sids: &[*mut c_void], +) -> Result { + create_token_with_caps_user_and_additional_restrictions_from( + base_token, + psid_capabilities, + additional_restricting_sids, + ) +} + +unsafe fn create_token_with_caps_user_and_additional_restrictions_from( + base_token: HANDLE, + psid_capabilities: &[*mut c_void], + additional_restricting_sids: &[*mut c_void], ) -> Result { let mut user_sid_bytes = get_user_sid_bytes(base_token)?; let psid_user = user_sid_bytes.as_mut_ptr() as *mut c_void; - create_token_with_caps_from(base_token, psid_capabilities, &[psid_user]) + let mut extra_restricting_sids = Vec::with_capacity(additional_restricting_sids.len() + 1); + extra_restricting_sids.push(psid_user); + extra_restricting_sids.extend_from_slice(additional_restricting_sids); + create_token_with_caps_from(base_token, psid_capabilities, &extra_restricting_sids) } unsafe fn create_token_with_caps_from( @@ -472,6 +493,8 @@ unsafe fn create_token_with_caps_from( return Err(anyhow!("CreateRestrictedToken failed: {}", GetLastError())); } + // Additional restricting SIDs are identity markers, not capabilities. Deliberately exclude + // them from the default DACL so possessing a route identity cannot grant object access. let mut dacl_sids: Vec<*mut c_void> = Vec::with_capacity(psid_capabilities.len() + 2); dacl_sids.push(psid_logon); dacl_sids.push(psid_everyone); @@ -481,3 +504,7 @@ unsafe fn create_token_with_caps_from( enable_single_privilege(new_token, "SeChangeNotifyPrivilege")?; Ok(new_token) } + +#[cfg(test)] +#[path = "token_tests.rs"] +mod tests; diff --git a/codex-rs/windows-sandbox-rs/src/token_tests.rs b/codex-rs/windows-sandbox-rs/src/token_tests.rs new file mode 100644 index 000000000000..63178355b140 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/token_tests.rs @@ -0,0 +1,69 @@ +use super::*; +use windows_sys::Win32::Security::EqualSid; +use windows_sys::Win32::Security::TokenRestrictedSids; + +unsafe fn token_has_restricting_sid(token: HANDLE, expected_sid: *mut c_void) -> Result { + let mut needed = 0; + GetTokenInformation( + token, + TokenRestrictedSids, + std::ptr::null_mut(), + 0, + &mut needed, + ); + if needed == 0 { + return Err(anyhow!( + "GetTokenInformation(TokenRestrictedSids) size query failed: {}", + GetLastError() + )); + } + + let mut buffer = vec![0_u8; needed as usize]; + if GetTokenInformation( + token, + TokenRestrictedSids, + buffer.as_mut_ptr().cast(), + needed, + &mut needed, + ) == 0 + { + return Err(anyhow!( + "GetTokenInformation(TokenRestrictedSids) failed: {}", + GetLastError() + )); + } + + let group_count = std::ptr::read_unaligned(buffer.as_ptr().cast::()) as usize; + let after_count = buffer.as_ptr().add(std::mem::size_of::()) as usize; + let align = std::mem::align_of::(); + let entries_addr = (after_count + (align - 1)) & !(align - 1); + let restricting_sids = + std::slice::from_raw_parts(entries_addr as *const SID_AND_ATTRIBUTES, group_count); + Ok(restricting_sids + .iter() + .any(|entry| EqualSid(entry.Sid, expected_sid) != 0)) +} + +#[test] +fn elevated_token_includes_network_proxy_restricting_sid() -> Result<()> { + let capability_sid = LocalSid::from_string("S-1-5-21-10-20-30-40")?; + let network_proxy_sid = LocalSid::from_string("S-1-5-21-50-60-70-80")?; + let base_token = unsafe { get_current_token_for_restriction()? }; + let restricted_token = unsafe { + create_readonly_token_with_caps_and_user_from( + base_token, + &[capability_sid.as_ptr()], + &[network_proxy_sid.as_ptr()], + )? + }; + + let has_network_proxy_sid = + unsafe { token_has_restricting_sid(restricted_token, network_proxy_sid.as_ptr()) }; + unsafe { + CloseHandle(restricted_token); + CloseHandle(base_token); + } + + assert!(has_network_proxy_sid?); + Ok(()) +} diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs index 7270fe6d607c..29f00db79e2c 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated.rs @@ -116,6 +116,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil cwd: &Path, mut env_map: HashMap, proxy_enforced: bool, + network_proxy_restricting_sid: Option, proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, timeout_ms: Option, read_roots_override: Option<&[PathBuf]>, @@ -171,6 +172,7 @@ pub(crate) async fn spawn_windows_sandbox_session_elevated_for_permission_profil codex_home: elevated.sandbox_base, real_codex_home: codex_home.to_path_buf(), cap_sids: elevated.cap_sids, + network_proxy_restricting_sid, timeout_ms, tty, stdin_open, diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated_tests.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated_tests.rs index 952727a21690..a5187eb15c9b 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated_tests.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/elevated_tests.rs @@ -76,6 +76,7 @@ fn retry_uses_original_unified_exec_request_and_stops_after_second_failure() { codex_home: PathBuf::from(r"C:\Users\codex\.sandbox"), real_codex_home: PathBuf::from(r"C:\Users\codex"), cap_sids: vec!["S-1-15-3-1024-1".to_string()], + network_proxy_restricting_sid: Some("S-1-5-21-100-200-300-400".to_string()), timeout_ms: Some(5_000), tty: true, stdin_open: true, diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs index 84ccd0d8cc28..92e3493edf02 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs @@ -10,6 +10,7 @@ mod backends; use anyhow::Result; +use anyhow::bail; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; @@ -31,6 +32,7 @@ pub struct WindowsSandboxSessionRequest<'a> { pub env_map: HashMap, pub windows_sandbox_level: WindowsSandboxLevel, pub proxy_enforced: bool, + pub network_proxy_restricting_sid: Option, pub proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, pub timeout_ms: Option, pub read_roots_override: Option<&'a [PathBuf]>, @@ -57,6 +59,7 @@ pub async fn spawn_windows_sandbox_session_for_level( request.cwd, request.env_map, request.proxy_enforced, + request.network_proxy_restricting_sid, request.proxy_settings_mode, request.timeout_ms, request.read_roots_override, @@ -70,6 +73,9 @@ pub async fn spawn_windows_sandbox_session_for_level( ) .await } else { + if request.network_proxy_restricting_sid.is_some() { + bail!("network proxy restricting SID requires the elevated Windows sandbox backend"); + } spawn_windows_sandbox_session_legacy( request.permission_profile, request.workspace_roots, @@ -129,6 +135,7 @@ pub async fn spawn_windows_sandbox_session_elevated_for_permission_profile( cwd: &Path, env_map: HashMap, proxy_enforced: bool, + network_proxy_restricting_sid: Option, timeout_ms: Option, read_roots_override: Option<&[PathBuf]>, read_roots_include_platform_defaults: bool, @@ -147,6 +154,7 @@ pub async fn spawn_windows_sandbox_session_elevated_for_permission_profile( cwd, env_map, proxy_enforced, + network_proxy_restricting_sid, crate::WindowsSandboxProxySettingsMode::Reconcile, timeout_ms, read_roots_override, diff --git a/codex-rs/windows-sandbox-rs/src/wrapper.rs b/codex-rs/windows-sandbox-rs/src/wrapper.rs index f02af26133ca..9729db1b840c 100644 --- a/codex-rs/windows-sandbox-rs/src/wrapper.rs +++ b/codex-rs/windows-sandbox-rs/src/wrapper.rs @@ -24,6 +24,7 @@ const CODEX_HOME_FLAG: &str = "--codex-home"; const DENY_READ_PATHS_JSON_FLAG: &str = "--deny-read-paths-json"; const DENY_WRITE_PATHS_JSON_FLAG: &str = "--deny-write-paths-json"; const ENV_JSON_FLAG: &str = "--env-json"; +const NETWORK_PROXY_RESTRICTING_SID_FLAG: &str = "--network-proxy-restricting-sid"; const PERMISSION_PROFILE_FLAG: &str = "--permission-profile"; const PRIVATE_DESKTOP_FLAG: &str = "--windows-sandbox-private-desktop"; const PRESERVE_PROXY_SETTINGS_FLAG: &str = "--preserve-proxy-settings"; @@ -44,6 +45,7 @@ pub fn create_windows_sandbox_command_args_for_permission_profile( windows_sandbox_level: WindowsSandboxLevel, windows_sandbox_private_desktop: bool, proxy_enforced: bool, + network_proxy_restricting_sid: Option<&str>, proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, read_roots_override: Option<&[PathBuf]>, read_roots_include_platform_defaults: bool, @@ -84,6 +86,10 @@ pub fn create_windows_sandbox_command_args_for_permission_profile( if proxy_enforced { args.push(PROXY_ENFORCED_FLAG.to_string()); } + if let Some(network_proxy_restricting_sid) = network_proxy_restricting_sid { + args.push(NETWORK_PROXY_RESTRICTING_SID_FLAG.to_string()); + args.push(network_proxy_restricting_sid.to_string()); + } if proxy_settings_mode == crate::WindowsSandboxProxySettingsMode::Preserve { args.push(PRESERVE_PROXY_SETTINGS_FLAG.to_string()); } @@ -159,6 +165,7 @@ struct WindowsSandboxWrapperRequest { windows_sandbox_level: WindowsSandboxLevel, windows_sandbox_private_desktop: bool, proxy_enforced: bool, + network_proxy_restricting_sid: Option, proxy_settings_mode: crate::WindowsSandboxProxySettingsMode, read_roots_override: Option>, read_roots_include_platform_defaults: bool, @@ -182,6 +189,7 @@ async fn run_windows_sandbox_wrapper_request(request: WindowsSandboxWrapperReque env_map: request.env_map, windows_sandbox_level: request.windows_sandbox_level, proxy_enforced: request.proxy_enforced, + network_proxy_restricting_sid: request.network_proxy_restricting_sid, proxy_settings_mode: request.proxy_settings_mode, timeout_ms: None, read_roots_override: request.read_roots_override.as_deref(), @@ -208,6 +216,7 @@ fn parse_windows_sandbox_wrapper_args(args: Vec) -> Result) -> Result proxy_enforced = true, + NETWORK_PROXY_RESTRICTING_SID_FLAG => { + network_proxy_restricting_sid = Some(next_flag_value(&mut args, &arg)?); + } READ_ROOTS_INCLUDE_PLATFORM_DEFAULTS_FLAG => { read_roots_include_platform_defaults = true; } @@ -293,6 +305,7 @@ fn parse_windows_sandbox_wrapper_args(args: Vec) -> Result