From 6c6d291f77c765efaeb6206166b67d8c1f9bdb5f Mon Sep 17 00:00:00 2001 From: Eugenio Panero Date: Thu, 25 Jun 2026 20:05:12 -0700 Subject: [PATCH] [codex] expose sandbox ingress to host --- codex-rs/cli/src/debug_sandbox.rs | 1 + codex-rs/core/src/exec.rs | 7 + codex-rs/core/src/landlock.rs | 1 + codex-rs/core/src/sandboxing/mod.rs | 4 + codex-rs/core/src/tasks/user_shell.rs | 1 + .../core/src/tools/handlers/shell_spec.rs | 7 + .../src/tools/handlers/shell_spec_tests.rs | 7 + .../core/src/tools/handlers/unified_exec.rs | 2 + .../handlers/unified_exec/exec_command.rs | 2 + codex-rs/core/src/tools/runtimes/mod_tests.rs | 1 + codex-rs/core/src/tools/runtimes/shell.rs | 1 + .../tools/runtimes/shell/unix_escalation.rs | 4 + .../core/src/tools/runtimes/unified_exec.rs | 7 + codex-rs/core/src/tools/sandboxing.rs | 5 + codex-rs/core/src/tools/sandboxing_tests.rs | 30 +- codex-rs/core/src/unified_exec/mod.rs | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 78 +++++ .../core/src/unified_exec/process_manager.rs | 51 +++- .../src/unified_exec/process_manager_tests.rs | 2 + codex-rs/exec-server-protocol/src/protocol.rs | 4 + codex-rs/exec-server/src/client.rs | 1 + codex-rs/exec-server/src/environment.rs | 2 + codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec-server/src/local_process.rs | 57 +++- codex-rs/exec-server/src/process_sandbox.rs | 6 + .../exec-server/src/process_sandbox_tests.rs | 44 +++ .../exec-server/src/server/handler/tests.rs | 1 + codex-rs/exec-server/src/server/processor.rs | 1 + codex-rs/exec-server/tests/exec_process.rs | 12 + codex-rs/exec-server/tests/relay.rs | 1 + codex-rs/linux-sandbox/src/ingress.rs | 284 ++++++++++++++++++ codex-rs/linux-sandbox/src/landlock.rs | 45 +-- codex-rs/linux-sandbox/src/lib.rs | 2 + codex-rs/linux-sandbox/src/linux_run_main.rs | 45 ++- .../linux-sandbox/src/linux_run_main_tests.rs | 23 ++ codex-rs/linux-sandbox/src/proxy_routing.rs | 4 +- .../tests/suite/managed_proxy.rs | 191 +++++++++++- .../rmcp-client/src/stdio_server_launcher.rs | 1 + codex-rs/sandboxing/src/ingress.rs | 118 ++++++++ codex-rs/sandboxing/src/landlock.rs | 18 +- codex-rs/sandboxing/src/landlock_tests.rs | 26 ++ codex-rs/sandboxing/src/lib.rs | 11 + codex-rs/sandboxing/src/manager.rs | 44 +++ codex-rs/sandboxing/src/manager_tests.rs | 35 +++ codex-rs/utils/pty/src/pipe.rs | 22 ++ codex-rs/utils/pty/src/pty.rs | 23 ++ codex-rs/utils/pty/src/tests.rs | 19 ++ 47 files changed, 1207 insertions(+), 46 deletions(-) create mode 100644 codex-rs/linux-sandbox/src/ingress.rs create mode 100644 codex-rs/sandboxing/src/ingress.rs diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 4f77a4b822..ec9f6c8c29 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -418,6 +418,7 @@ async fn run_command_under_sandbox( sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(enforce_managed_network), + /*ingress*/ None, ); spawn_debug_sandbox_child( codex_linux_sandbox_exe, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 015b20715a..eac2b67043 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -392,6 +392,7 @@ pub fn build_exec_request( enforce_managed_network, environment_id: network_environment_id.as_deref(), network: network.as_ref(), + ingress: None, sandbox_policy_cwd: &sandbox_policy_cwd_uri, codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_deref(), use_legacy_landlock, @@ -444,6 +445,7 @@ pub(crate) async fn execute_exec_request( cwd, env, exec_server_env_config: _, + ingress, network, expiration, capture_policy, @@ -462,6 +464,11 @@ pub(crate) async fn execute_exec_request( exec_server_enforce_managed_network: _, exec_server_managed_network: _, } = exec_request; + if ingress.is_some() { + return Err(CodexErr::UnsupportedOperation( + "ingress requires unified exec".to_string(), + )); + } // TODO(anp): Keep PathUri through the local process launch boundary. let cwd = cwd diff --git a/codex-rs/core/src/landlock.rs b/codex-rs/core/src/landlock.rs index c117f706e1..56448ab5b9 100644 --- a/codex-rs/core/src/landlock.rs +++ b/codex-rs/core/src/landlock.rs @@ -41,6 +41,7 @@ where sandbox_policy_cwd, use_legacy_landlock, allow_network_for_proxy(/*enforce_managed_network*/ false), + /*ingress*/ None, ); let codex_linux_sandbox_exe = codex_linux_sandbox_exe.as_ref(); // Preserve the helper alias when we already have it; otherwise force argv0 diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 1c570e1bbb..97e38bf381 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -48,6 +48,7 @@ pub struct ExecRequest { pub cwd: PathUri, pub env: HashMap, pub(crate) exec_server_env_config: Option, + pub ingress: Option, pub network: Option, pub network_environment_id: Option, pub expiration: ExecExpiration, @@ -93,6 +94,7 @@ impl ExecRequest { cwd, env, exec_server_env_config: None, + ingress: None, network, network_environment_id, expiration, @@ -123,6 +125,7 @@ impl ExecRequest { cwd, sandbox_policy_cwd: windows_sandbox_policy_cwd, mut env, + ingress, network, network_environment_id, sandbox, @@ -152,6 +155,7 @@ impl ExecRequest { cwd, env, exec_server_env_config: None, + ingress, network, network_environment_id, expiration, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index d588e5a356..ea2735295c 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -203,6 +203,7 @@ pub(crate) async fn execute_user_shell_command( cwd: cwd.clone().into(), env: exec_env_map, exec_server_env_config: None, + ingress: None, // `/shell` is the explicit full-access escape hatch, so it must not // inherit a managed proxy from the surrounding session or turn. network: None, diff --git a/codex-rs/core/src/tools/handlers/shell_spec.rs b/codex-rs/core/src/tools/handlers/shell_spec.rs index 3fb563b8d9..6688e29d6c 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec.rs @@ -55,6 +55,13 @@ pub(crate) fn create_exec_command_tool_with_environment_id( )), ), ]); + #[cfg(unix)] + properties.insert( + "ingress".to_string(), + JsonSchema::integer(Some( + "Expose a TCP port from the Linux sandbox through the parent host.".to_string(), + )), + ); if include_shell_parameter { properties.insert( "shell".to_string(), diff --git a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs index b7bbfa54a1..104eccfa6a 100644 --- a/codex-rs/core/src/tools/handlers/shell_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_spec_tests.rs @@ -74,6 +74,13 @@ fn exec_command_tool_matches_expected_spec() { )), ), ]); + #[cfg(unix)] + properties.insert( + "ingress".to_string(), + JsonSchema::integer(Some( + "Expose a TCP port from the Linux sandbox through the parent host.".to_string(), + )), + ); properties.extend(create_approval_parameters( /*exec_permission_approvals_enabled*/ false, )); diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 71e776dbf0..9992977551 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -33,6 +33,8 @@ pub(crate) struct ExecCommandArgs { login: Option, #[serde(default = "default_tty")] tty: bool, + #[serde(default)] + ingress: Option, #[serde(default = "default_exec_yield_time_ms")] yield_time_ms: u64, #[serde(default)] diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index 546ad459e1..ecd0e37a7e 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -242,6 +242,7 @@ impl ExecCommandHandler { let ExecCommandArgs { tty, + ingress, yield_time_ms, max_output_tokens, sandbox_permissions, @@ -355,6 +356,7 @@ impl ExecCommandHandler { shell_mode, network: context.turn.network.clone(), tty, + ingress, sandbox_permissions: effective_additional_permissions.sandbox_permissions, additional_permissions: normalized_additional_permissions, additional_permissions_preapproved: effective_additional_permissions diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 5c2614c9c0..cfffd69fad 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -128,6 +128,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: Some(&proxy), SandboxPermissions::RequireEscalated, ), + /*ingress*/ None, /*environment_id*/ None, ) .expect("prepare exec request"); diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 0d386103db..d5b456e4ac 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -326,6 +326,7 @@ impl ToolRuntime for ShellRuntime { command, options, managed_network, + /*ingress*/ None, Some(&req.turn_environment.environment_id), ) .map_err(ToolError::Codex)?; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 10cb460205..fd8aa8f199 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -144,6 +144,7 @@ pub(super) async fn try_run_zsh_fork( command, options, managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions), + /*ingress*/ None, Some(&req.turn_environment.environment_id), ) .map_err(ToolError::Codex)?; @@ -152,6 +153,7 @@ pub(super) async fn try_run_zsh_fork( cwd: sandbox_cwd, env: sandbox_env, exec_server_env_config: _, + ingress: _, network: sandbox_network, network_environment_id, expiration: _sandbox_expiration, @@ -887,6 +889,7 @@ impl CoreShellCommandExecutor { cwd: self.cwd.clone().into(), env: exec_env, exec_server_env_config: None, + ingress: None, network: self.network.clone(), network_environment_id: self.network_environment_id.clone(), expiration: ExecExpiration::Cancellation(cancel_rx), @@ -1026,6 +1029,7 @@ impl CoreShellCommandExecutor { enforce_managed_network: self.network.is_some(), environment_id: self.network_environment_id.as_deref(), network: self.network.as_ref(), + ingress: None, sandbox_policy_cwd: &sandbox_policy_cwd, codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_deref(), use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5df42cc979..fbb0cea96e 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -74,6 +74,7 @@ pub struct UnifiedExecRequest { pub explicit_env_overrides: HashMap, pub network: Option, pub tty: bool, + pub ingress: Option, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, #[cfg(unix)] @@ -90,6 +91,7 @@ pub struct UnifiedExecApprovalKey { pub command: Vec, pub cwd: PathUri, pub tty: bool, + pub ingress: Option, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, } @@ -163,6 +165,7 @@ impl Approvable for UnifiedExecRuntime<'_> { command: canonicalize_command_for_approval(&req.command), cwd: req.cwd.clone(), tty: req.tty, + ingress: req.ingress, sandbox_permissions: req.sandbox_permissions, additional_permissions: req.additional_permissions.clone(), }] @@ -409,6 +412,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt command, options, managed_network, + req.ingress, Some(&req.turn_environment.environment_id), ) .map_err(ToolError::Codex)?; @@ -477,6 +481,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt options, attempt, managed_network, + req.ingress, /*environment_id*/ Some(&req.turn_environment.environment_id), req.exec_server_env_config.clone(), req.tty, @@ -574,6 +579,7 @@ mod tests { explicit_env_overrides: HashMap::new(), network: None, tty: false, + ingress: None, sandbox_permissions: SandboxPermissions::UseDefault, additional_permissions: None, #[cfg(unix)] @@ -676,6 +682,7 @@ mod tests { explicit_env_overrides: HashMap::new(), network: None, tty: false, + ingress: None, sandbox_permissions, additional_permissions: None, #[cfg(unix)] diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 8e9e274f24..0e0b6bcff0 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -431,6 +431,7 @@ impl<'a> SandboxAttempt<'a> { command: SandboxCommand, options: ExecOptions, network: Option<&NetworkProxy>, + ingress: Option, environment_id: Option<&str>, ) -> Result { let request = self @@ -442,6 +443,7 @@ impl<'a> SandboxAttempt<'a> { enforce_managed_network: self.enforce_managed_network, environment_id, network, + ingress, sandbox_policy_cwd: self.sandbox_cwd, codex_linux_sandbox_exe: self .codex_linux_sandbox_exe @@ -463,6 +465,7 @@ impl<'a> SandboxAttempt<'a> { command: SandboxCommand, options: ExecOptions, network: Option<&NetworkProxy>, + ingress: Option, environment_id: Option<&str>, ) -> Result { let managed_network = command.managed_network.clone(); @@ -480,6 +483,7 @@ impl<'a> SandboxAttempt<'a> { enforce_managed_network: self.enforce_managed_network, environment_id, network, + ingress: None, sandbox_policy_cwd: self.sandbox_cwd, codex_linux_sandbox_exe: None, use_legacy_landlock: self.use_legacy_landlock, @@ -492,6 +496,7 @@ impl<'a> SandboxAttempt<'a> { options, self.workspace_roots.to_vec(), ); + exec_request.ingress = ingress; exec_request.exec_server_managed_network = managed_network; if self.sandbox_requested { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 28b9de9ce1..e31d9733df 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -245,7 +245,13 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { capture_policy: crate::exec::ExecCapturePolicy::ShellTool, }; let request = attempt - .env_for_exec_server(command(), options(), /*network*/ None, Some("remote")) + .env_for_exec_server( + command(), + options(), + /*network*/ None, + /*ingress*/ None, + Some("remote"), + ) .expect("prepare remote exec request"); assert_eq!( @@ -275,9 +281,29 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { Some(managed_network.clone()) ); + let ingress_request = attempt + .env_for_exec_server( + command(), + options(), + /*network*/ None, + /*ingress*/ Some(4173), + Some("remote"), + ) + .expect("prepare ingress remote exec request"); + + assert_eq!(ingress_request.ingress, Some(4173)); + assert_eq!(ingress_request.sandbox, SandboxType::None); + assert!(ingress_request.exec_server_sandbox.is_some()); + attempt.sandbox_requested = false; let request = attempt - .env_for_exec_server(command(), options(), /*network*/ None, Some("remote")) + .env_for_exec_server( + command(), + options(), + /*network*/ None, + /*ingress*/ None, + Some("remote"), + ) .expect("prepare unsandboxed remote exec request"); assert_eq!(request.exec_server_sandbox, None); diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 34d88c8149..28f3e7466a 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -102,6 +102,7 @@ pub(crate) struct ExecCommandRequest { pub shell_mode: UnifiedExecShellMode, pub network: Option, pub tty: bool, + pub ingress: Option, pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, pub additional_permissions_preapproved: bool, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 54ee788e3b..4c686657e8 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -20,6 +20,8 @@ use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; use codex_sandboxing::SandboxType; +#[cfg(unix)] +use codex_sandboxing::ingress::INGRESS_LISTENER_FD_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; @@ -28,6 +30,10 @@ use core_test_support::skip_if_sandbox; use core_test_support::test_codex::test_env as remote_test_env; use pretty_assertions::assert_eq; use std::collections::HashMap; +#[cfg(unix)] +use std::net::Ipv4Addr; +#[cfg(unix)] +use std::net::TcpListener; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Notify; @@ -201,6 +207,78 @@ async fn exec_command_with_tty( }) } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_ingress_passes_inherited_listener_to_child() -> anyhow::Result<()> { + skip_if_sandbox!(Ok(())); + + let (session, turn) = test_session_and_turn().await; + let manager = &session.services.unified_exec_manager; + let process_id = manager.allocate_process_id().await; + #[allow(deprecated)] + let cwd = turn.cwd.clone(); + let command = vec![ + "bash".to_string(), + "-lc".to_string(), + r#"test -n "$CODEX_INGRESS_LISTENER_FD" && eval "true <&${CODEX_INGRESS_LISTENER_FD}" && echo ingress-ready"# + .to_string(), + ]; + let mut env = shell_env(); + env.remove(INGRESS_LISTENER_FD_ENV_VAR); + let mut request = test_exec_request(turn.as_ref(), command, cwd, env); + request.ingress = Some(unused_ingress_port()); + + let process = manager + .open_session_with_prepared_exec_env( + process_id, + &request, + /*tty*/ false, + Box::new(NoopSpawnLifecycle), + turn.environments + .primary() + .expect("turn environment") + .environment + .as_ref(), + ) + .await?; + let OutputHandles { + output_buffer, + output_notify, + output_closed, + output_closed_notify, + cancellation_token, + } = process.output_handles(); + let output = UnifiedExecProcessManager::collect_output_until_deadline( + &output_buffer, + &output_notify, + &output_closed, + &output_closed_notify, + &cancellation_token, + Some(session.subscribe_out_of_band_elicitation_pause_state()), + Instant::now() + Duration::from_secs(2), + ) + .await; + + assert_eq!(process.exit_code(), Some(0)); + assert!( + String::from_utf8_lossy(&output).contains("ingress-ready"), + "expected ingress child output, got: {}", + String::from_utf8_lossy(&output), + ); + manager.release_process_id(process_id).await; + + Ok(()) +} + +#[cfg(unix)] +fn unused_ingress_port() -> u16 { + TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0)) + .expect("bind ephemeral ingress listener") + .local_addr() + .expect("read ephemeral ingress listener address") + .port() +} + #[derive(Debug)] struct TestSpawnLifecycle { inherited_fds: Vec, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index ef22918cd2..c952d40fc0 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -62,6 +62,10 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::protocol::ExecCommandSource; use codex_sandboxing::SandboxCommand; +#[cfg(unix)] +use codex_sandboxing::ingress::IngressListener; +#[cfg(unix)] +use codex_sandboxing::ingress::IngressListenerError; use codex_tools::ToolName; use codex_utils_output_truncation::approx_token_count; use codex_utils_path_uri::PathUri; @@ -188,6 +192,7 @@ fn exec_server_params_for_request( env, tty, pipe_stdin: false, + ingress: request.ingress, arg0: request.arg0.clone(), sandbox: request.exec_server_sandbox.clone(), enforce_managed_network: request.exec_server_enforce_managed_network, @@ -920,6 +925,7 @@ impl UnifiedExecProcessManager { options: ExecOptions, attempt: &SandboxAttempt<'_>, network: Option<&NetworkProxy>, + ingress: Option, environment_id: Option<&str>, exec_server_env_config: Option, tty: bool, @@ -927,9 +933,9 @@ impl UnifiedExecProcessManager { environment: &codex_exec_server::Environment, ) -> Result { let mut request = if environment.is_remote() { - attempt.env_for_exec_server(command, options, network, environment_id) + attempt.env_for_exec_server(command, options, network, ingress, environment_id) } else { - attempt.env_for(command, options, network, environment_id) + attempt.env_for(command, options, network, ingress, environment_id) } .map_err(ToolError::Codex)?; request.exec_server_env_config = exec_server_env_config; @@ -960,8 +966,6 @@ impl UnifiedExecProcessManager { mut spawn_lifecycle: SpawnLifecycleHandle, environment: &codex_exec_server::Environment, ) -> Result { - let inherited_fds = spawn_lifecycle.inherited_fds(); - #[cfg(target_os = "windows")] if request.sandbox == codex_sandboxing::SandboxType::WindowsRestrictedToken { // TODO(anp): Keep PathUri through the Windows sandbox launch boundary. @@ -1049,6 +1053,7 @@ impl UnifiedExecProcessManager { .await; } if environment.is_remote() { + let inherited_fds = spawn_lifecycle.inherited_fds(); if !inherited_fds.is_empty() { return Err(UnifiedExecError::create_process( "remote exec-server does not support inherited file descriptors".to_string(), @@ -1064,6 +1069,22 @@ impl UnifiedExecProcessManager { return UnifiedExecProcess::from_exec_server_started(started).await; } + let mut env = request.env.clone(); + let mut inherited_fds = spawn_lifecycle.inherited_fds(); + #[cfg(unix)] + let ingress = IngressListener::prepare(request.ingress).map_err(ingress_error)?; + #[cfg(unix)] + if let Some(ingress) = ingress.as_ref() { + ingress.add_to_child_env(&mut env); + inherited_fds.push(ingress.inherited_fd()); + } + #[cfg(not(unix))] + if request.ingress.is_some() { + return Err(UnifiedExecError::create_process( + "ingress is only supported on Unix hosts".to_string(), + )); + } + // TODO(anp): Keep PathUri through the local PTY/process launch boundary. let native_cwd = request .cwd @@ -1081,7 +1102,7 @@ impl UnifiedExecProcessManager { program, args, native_cwd.as_path(), - &request.env, + &env, &request.arg0, codex_utils_pty::TerminalSize::default(), &inherited_fds, @@ -1092,7 +1113,7 @@ impl UnifiedExecProcessManager { program, args, native_cwd.as_path(), - &request.env, + &env, &request.arg0, &inherited_fds, ) @@ -1100,6 +1121,8 @@ impl UnifiedExecProcessManager { }; let spawned = spawn_result.map_err(|err| UnifiedExecError::create_process(err.to_string()))?; + #[cfg(unix)] + drop(ingress); spawn_lifecycle.after_spawn(); UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await } @@ -1166,6 +1189,7 @@ impl UnifiedExecProcessManager { .clone(), network: request.network.clone(), tty: request.tty, + ingress: request.ingress, sandbox_permissions: request.sandbox_permissions, additional_permissions: request.additional_permissions.clone(), #[cfg(unix)] @@ -1448,6 +1472,21 @@ impl UnifiedExecProcessManager { } } +#[cfg(unix)] +fn ingress_error(error: IngressListenerError) -> UnifiedExecError { + match error { + IngressListenerError::InvalidPort => { + UnifiedExecError::create_process("ingress port must be between 1 and 65535".to_string()) + } + IngressListenerError::PortInUse(port) => UnifiedExecError::create_process(format!( + "ingress port {port} is already in use by another process" + )), + IngressListenerError::Io(error) => { + UnifiedExecError::create_process(format!("failed to prepare ingress listener: {error}")) + } + } +} + enum ProcessStatus { Alive { exit_code: Option, diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 096cbe0a13..11cacbad29 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -157,6 +157,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ), ]), }), + ingress: None, network: None, network_environment_id: None, expiration: crate::exec::ExecExpiration::DefaultTimeout, @@ -287,6 +288,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { shell_mode: codex_tools::UnifiedExecShellMode::Direct, network: None, tty: true, + ingress: None, sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, additional_permissions: None, additional_permissions_preapproved: false, diff --git a/codex-rs/exec-server-protocol/src/protocol.rs b/codex-rs/exec-server-protocol/src/protocol.rs index 491fc8cf97..e9d0e41391 100644 --- a/codex-rs/exec-server-protocol/src/protocol.rs +++ b/codex-rs/exec-server-protocol/src/protocol.rs @@ -129,6 +129,9 @@ pub struct ExecParams { /// Keep non-tty stdin writable through `process/write`. #[serde(default)] pub pipe_stdin: bool, + /// Expose a TCP port from the Linux sandbox through the executor host. + #[serde(default)] + pub ingress: Option, /// Optional process-visible argv0 override. Values such as `codex-linux-sandbox` are command /// names rather than paths, so this is not a [`PathUri`]. pub arg0: Option, @@ -591,6 +594,7 @@ mod tests { pipe_stdin: false, arg0: None, sandbox: None, + ingress: None, enforce_managed_network: true, managed_network: Some(ManagedNetworkSandboxContext { loopback_ports: vec![43123, 48081], diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index e2429d8f14..547bdaf5e2 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -1374,6 +1374,7 @@ mod tests { env: HashMap::new(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 66f7381103..ab102ad298 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1172,6 +1172,7 @@ mod tests { env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -1212,6 +1213,7 @@ mod tests { env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: Some(sandbox), enforce_managed_network: false, diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index 812d76751c..2d1e3eab85 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -140,6 +140,7 @@ impl FileSystemSandboxRunner { enforce_managed_network: false, environment_id: None, network: None, + ingress: None, sandbox_policy_cwd: &cwd.uri, codex_linux_sandbox_exe: self.runtime_paths.codex_linux_sandbox_exe.as_deref(), use_legacy_landlock: sandbox_context.use_legacy_landlock, diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 7335bd1eed..9c0cd4c50c 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -14,6 +14,10 @@ use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; use codex_protocol::shell_environment; use codex_sandboxing::SandboxType; +#[cfg(unix)] +use codex_sandboxing::ingress::IngressListener; +#[cfg(unix)] +use codex_sandboxing::ingress::IngressListenerError; use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; @@ -234,8 +238,30 @@ impl LocalProcess { params: ExecParams, ) -> Result<(ExecResponse, watch::Sender, ExecProcessEventLog), JSONRPCErrorError> { let process_id = params.process_id.clone(); - let prepared = - prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref())?; + let mut env = child_env(¶ms); + #[cfg(unix)] + let ingress = match IngressListener::prepare(params.ingress) { + Ok(ingress) => ingress, + Err(error) => return Err(ingress_error(error)), + }; + #[cfg(unix)] + let inherited_fds = ingress + .as_ref() + .map(|ingress| vec![ingress.inherited_fd()]) + .unwrap_or_default(); + #[cfg(unix)] + if let Some(ingress) = ingress.as_ref() { + ingress.add_to_child_env(&mut env); + } + #[cfg(not(unix))] + if params.ingress.is_some() { + return Err(invalid_params( + "ingress is only supported on Unix exec-server hosts".to_string(), + )); + } + #[cfg(not(unix))] + let inherited_fds = Vec::new(); + let prepared = prepare_exec_request(¶ms, env, self.runtime_paths.as_ref())?; let (program, args) = prepared .command .split_first() @@ -256,31 +282,34 @@ impl LocalProcess { } let spawned_result = if params.tty { - codex_utils_pty::spawn_pty_process( + codex_utils_pty::pty::spawn_process_with_inherited_fds( program, args, prepared.cwd.as_path(), &prepared.env, &prepared.arg0, TerminalSize::default(), + &inherited_fds, ) .await } else if params.pipe_stdin { - codex_utils_pty::spawn_pipe_process( + codex_utils_pty::pipe::spawn_process_with_inherited_fds( program, args, prepared.cwd.as_path(), &prepared.env, &prepared.arg0, + &inherited_fds, ) .await } else { - codex_utils_pty::spawn_pipe_process_no_stdin( + codex_utils_pty::pipe::spawn_process_no_stdin_with_inherited_fds( program, args, prepared.cwd.as_path(), &prepared.env, &prepared.arg0, + &inherited_fds, ) .await }; @@ -297,6 +326,8 @@ impl LocalProcess { return Err(internal_error(err.to_string())); } }; + #[cfg(unix)] + drop(ingress); let output_notify = Arc::new(Notify::new()); let (wake_tx, _wake_rx) = watch::channel(0); @@ -584,6 +615,21 @@ fn child_env(params: &ExecParams) -> HashMap { env } +#[cfg(unix)] +fn ingress_error(error: IngressListenerError) -> JSONRPCErrorError { + match error { + IngressListenerError::InvalidPort => { + invalid_request("ingress port must be between 1 and 65535".to_string()) + } + IngressListenerError::PortInUse(port) => invalid_request(format!( + "ingress port {port} is already in use by another process" + )), + IngressListenerError::Io(error) => { + internal_error(format!("failed to prepare ingress listener: {error}")) + } + } +} + fn shell_environment_policy(env_policy: &ExecEnvPolicy) -> ShellEnvironmentPolicy { ShellEnvironmentPolicy { inherit: env_policy.inherit.clone(), @@ -995,6 +1041,7 @@ mod tests { env, tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index b5dd54e0b2..ecd2ff9625 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -32,6 +32,11 @@ pub(crate) fn prepare_exec_request( runtime_paths: Option<&ExecServerRuntimePaths>, ) -> Result { let Some(sandbox_context) = params.sandbox.as_ref() else { + if params.ingress.is_some() { + return Err(invalid_params( + "ingress requires sandbox intent on exec-server".to_string(), + )); + } return Ok(PreparedExecRequest { command: params.argv.clone(), cwd: native_path(¶ms.cwd, "cwd")?, @@ -125,6 +130,7 @@ pub(crate) fn prepare_exec_request( enforce_managed_network: params.enforce_managed_network, environment_id: None, network: None, + ingress: params.ingress, sandbox_policy_cwd, codex_linux_sandbox_exe: runtime_paths.codex_linux_sandbox_exe.as_deref(), use_legacy_landlock: sandbox_context.use_legacy_landlock, diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index bc71b57320..cb8bc80d7e 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -43,6 +43,7 @@ fn sandbox_request_wraps_native_argv_on_executor() { env: HashMap::new(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: Some(sandbox), enforce_managed_network: false, @@ -81,6 +82,47 @@ fn sandbox_request_wraps_native_argv_on_executor() { ); } +#[cfg(target_os = "linux")] +#[test] +fn sandbox_request_forwards_ingress_to_linux_sandbox() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let self_exe = std::env::current_exe().expect("current executable"); + let runtime_paths = + ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::workspace_write(), + cwd_uri.clone(), + ); + let params = ExecParams { + process_id: ProcessId::from("ingress"), + argv: vec!["/usr/bin/true".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + ingress: Some(4173), + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + managed_network: None, + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .expect("prepare ingress sandbox request"); + + assert!( + prepared + .command + .windows(2) + .any(|args| args[0] == "--ingress" && args[1] == "4173") + ); +} + #[cfg(target_os = "macos")] #[test] fn sandbox_request_allows_prepared_managed_proxy_port() { @@ -104,6 +146,7 @@ fn sandbox_request_allows_prepared_managed_proxy_port() { env: HashMap::new(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: Some(sandbox), enforce_managed_network: true, @@ -140,6 +183,7 @@ fn native_request_preserves_native_launch_fields() { env: HashMap::new(), tty: false, pipe_stdin: false, + ingress: None, arg0: Some("custom-arg0".to_string()), sandbox: None, enforce_managed_network: false, diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 47416039c6..92de201c98 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -33,6 +33,7 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { env: inherited_path_env(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index 63f6bd45d9..c25a4c0de3 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -590,6 +590,7 @@ mod tests { env, tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 8fe5fe0be9..6027d0bfbc 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -80,6 +80,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -224,6 +225,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -258,6 +260,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -308,6 +311,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -359,6 +363,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -435,6 +440,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { env: Default::default(), tty: true, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -475,6 +481,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re env: Default::default(), tty: false, pipe_stdin: true, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -511,6 +518,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -548,6 +556,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -604,6 +613,7 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -647,6 +657,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( env: Default::default(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, @@ -708,6 +719,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> ]), tty: false, pipe_stdin: true, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 918cba7393..66fa986b0e 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -149,6 +149,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { env: HashMap::new(), tty: false, pipe_stdin: false, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, diff --git a/codex-rs/linux-sandbox/src/ingress.rs b/codex-rs/linux-sandbox/src/ingress.rs new file mode 100644 index 0000000000..0339e930d9 --- /dev/null +++ b/codex-rs/linux-sandbox/src/ingress.rs @@ -0,0 +1,284 @@ +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::Ipv4Addr; +use std::net::TcpListener; +use std::net::TcpStream; +use std::os::fd::FromRawFd; +use std::sync::Arc; +use std::sync::Condvar; +use std::sync::Mutex; +use std::thread; +use std::time::Duration; +use std::time::Instant; + +use codex_sandboxing::ingress::INGRESS_LISTENER_FD_ENV_VAR; + +const INGRESS_BRIDGE_READY: u8 = 1; +const TARGET_SERVER_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const TARGET_SERVER_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(25); +const CONNECTION_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_CONCURRENT_CONNECTIONS: usize = 16; + +pub(crate) fn take_ingress_listener_fd_from_env() -> io::Result> { + let raw_fd = match std::env::var(INGRESS_LISTENER_FD_ENV_VAR) { + Ok(raw_fd) => raw_fd, + Err(std::env::VarError::NotPresent) => return Ok(None), + Err(error) => return Err(io::Error::other(error)), + }; + let listener_fd = raw_fd.parse::().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid ingress listener fd `{raw_fd}`: {error}"), + ) + })?; + if listener_fd < 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid ingress listener fd `{raw_fd}`"), + )); + } + // SAFETY: inner sandbox setup is single-threaded before the final command execs. + unsafe { + std::env::remove_var(INGRESS_LISTENER_FD_ENV_VAR); + } + Ok(Some(listener_fd)) +} + +pub(crate) fn activate_ingress(listener_fd: libc::c_int, ingress_port: u16) -> io::Result<()> { + crate::proxy_routing::ensure_loopback_interface_up()?; + let (read_fd, write_fd) = create_ready_pipe()?; + // SAFETY: ingress activation runs before the sandboxed command starts threads. + let pid = unsafe { libc::fork() }; + if pid < 0 { + let error = io::Error::last_os_error(); + let _ = close_fd(listener_fd); + let _ = close_fd(read_fd); + let _ = close_fd(write_fd); + return Err(error); + } + + if pid == 0 { + if close_fd(read_fd).is_err() { + // SAFETY: this is the forked bridge child; `_exit` avoids unwinding through fork. + unsafe { libc::_exit(1) }; + } + if run_ingress_bridge(listener_fd, ingress_port, write_fd).is_err() { + // SAFETY: this is the forked bridge child; `_exit` avoids unwinding through fork. + unsafe { libc::_exit(1) }; + } + // SAFETY: this is the forked bridge child after its bridge loop returns. + unsafe { libc::_exit(0) }; + } + + if let Err(error) = close_fd(write_fd) { + let _ = close_fd(listener_fd); + let _ = close_fd(read_fd); + return Err(error); + } + let readiness_result = wait_for_ingress_bridge_ready(read_fd); + let close_result = close_fd(listener_fd); + readiness_result?; + close_result +} + +fn wait_for_ingress_bridge_ready(read_fd: libc::c_int) -> io::Result<()> { + let mut ready = [0_u8; 1]; + // SAFETY: this parent owns the read end after closing the child write end. + let mut read_file = unsafe { std::fs::File::from_raw_fd(read_fd) }; + read_file.read_exact(&mut ready).map_err(|error| { + io::Error::new( + error.kind(), + format!("ingress bridge failed before acknowledging readiness: {error}"), + ) + })?; + if ready[0] != INGRESS_BRIDGE_READY { + return Err(io::Error::other( + "ingress bridge did not acknowledge readiness", + )); + } + Ok(()) +} + +fn run_ingress_bridge( + listener_fd: libc::c_int, + ingress_port: u16, + ready_fd: libc::c_int, +) -> io::Result<()> { + crate::proxy_routing::harden_bridge_process()?; + // SAFETY: the launcher transferred ownership of this inherited listener to the bridge child. + let listener = unsafe { TcpListener::from_raw_fd(listener_fd) }; + // SAFETY: this bridge child owns the write end after closing the parent read end. + let mut ready_file = unsafe { std::fs::File::from_raw_fd(ready_fd) }; + ready_file.write_all(&[INGRESS_BRIDGE_READY])?; + drop(ready_file); + + let connection_limiter = Arc::new(ConnectionLimiter::new(MAX_CONCURRENT_CONNECTIONS)); + loop { + let connection_permit = connection_limiter.acquire(); + let (ingress_stream, _) = listener.accept()?; + thread::spawn(move || { + let _connection_permit = connection_permit; + let target_stream = match connect_target_server_with_retry(ingress_port) { + Ok(target_stream) => target_stream, + Err(_) => return, + }; + if set_connection_timeouts(&ingress_stream) + .and_then(|()| set_connection_timeouts(&target_stream)) + .is_err() + { + return; + } + let _ = proxy_bidirectional(ingress_stream, target_stream); + }); + } +} + +fn connect_target_server_with_retry(ingress_port: u16) -> io::Result { + let deadline = Instant::now() + TARGET_SERVER_CONNECT_TIMEOUT; + loop { + match TcpStream::connect((Ipv4Addr::LOCALHOST, ingress_port)) { + Ok(stream) => return Ok(stream), + Err(_) if Instant::now() < deadline => { + thread::sleep(TARGET_SERVER_CONNECT_RETRY_DELAY); + } + Err(error) => return Err(error), + } + } +} + +fn proxy_bidirectional( + mut ingress_stream: TcpStream, + mut target_stream: TcpStream, +) -> io::Result<()> { + let mut ingress_reader = ingress_stream.try_clone()?; + let mut target_writer = target_stream.try_clone()?; + let ingress_to_target = + thread::spawn(move || std::io::copy(&mut ingress_reader, &mut target_writer)); + let target_to_ingress = std::io::copy(&mut target_stream, &mut ingress_stream); + let ingress_to_target = ingress_to_target + .join() + .map_err(|_| io::Error::other("ingress bridge thread panicked"))?; + ingress_to_target?; + target_to_ingress?; + Ok(()) +} + +fn set_connection_timeouts(stream: &TcpStream) -> io::Result<()> { + stream.set_read_timeout(Some(CONNECTION_IDLE_TIMEOUT))?; + stream.set_write_timeout(Some(CONNECTION_IDLE_TIMEOUT)) +} + +struct ConnectionLimiter { + max_connections: usize, + active_connections: Mutex, + connection_available: Condvar, +} + +impl ConnectionLimiter { + fn new(max_connections: usize) -> Self { + Self { + max_connections, + active_connections: Mutex::new(0), + connection_available: Condvar::new(), + } + } + + fn acquire(self: &Arc) -> ConnectionPermit { + let mut active_connections = self + .active_connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while *active_connections >= self.max_connections { + active_connections = self + .connection_available + .wait(active_connections) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + *active_connections += 1; + ConnectionPermit { + limiter: Arc::clone(self), + } + } +} + +struct ConnectionPermit { + limiter: Arc, +} + +impl Drop for ConnectionPermit { + fn drop(&mut self) { + let mut active_connections = self + .limiter + .active_connections + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *active_connections -= 1; + self.limiter.connection_available.notify_one(); + } +} + +fn close_fd(fd: libc::c_int) -> io::Result<()> { + // SAFETY: callers pass a live inherited file descriptor owned by this process. + let result = unsafe { libc::close(fd) }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +fn create_ready_pipe() -> io::Result<(libc::c_int, libc::c_int)> { + let mut pipe_fds = [0; 2]; + // SAFETY: `pipe_fds` points to space for exactly two file descriptors. + let result = unsafe { libc::pipe2(pipe_fds.as_mut_ptr(), libc::O_CLOEXEC) }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + Ok((pipe_fds[0], pipe_fds[1])) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + + #[test] + fn listener_fd_is_taken_from_env() { + // SAFETY: this test mutates the process environment before spawning threads. + unsafe { + std::env::set_var(INGRESS_LISTENER_FD_ENV_VAR, "17"); + } + + assert_eq!( + take_ingress_listener_fd_from_env().expect("take listener fd"), + Some(17) + ); + assert_eq!( + std::env::var(INGRESS_LISTENER_FD_ENV_VAR), + Err(std::env::VarError::NotPresent) + ); + } + + #[test] + fn connection_limiter_waits_for_available_slot() { + let connection_limiter = Arc::new(ConnectionLimiter::new(1)); + let first_connection = connection_limiter.acquire(); + let (acquired_tx, acquired_rx) = mpsc::channel(); + let waiting_limiter = Arc::clone(&connection_limiter); + let waiting_connection = thread::spawn(move || { + let _second_connection = waiting_limiter.acquire(); + acquired_tx.send(()).expect("report acquired connection"); + }); + + assert!( + acquired_rx.recv_timeout(Duration::from_millis(50)).is_err(), + "second connection should wait for the first connection to close" + ); + drop(first_connection); + acquired_rx + .recv_timeout(Duration::from_secs(1)) + .expect("second connection should acquire released slot"); + waiting_connection.join().expect("join waiting connection"); + } +} diff --git a/codex-rs/linux-sandbox/src/landlock.rs b/codex-rs/linux-sandbox/src/landlock.rs index 65ba3ebf17..2ab0d59a74 100644 --- a/codex-rs/linux-sandbox/src/landlock.rs +++ b/codex-rs/linux-sandbox/src/landlock.rs @@ -44,14 +44,14 @@ pub(crate) fn apply_permission_profile_to_current_thread( cwd: &Path, apply_landlock_fs: bool, allow_network_for_proxy: bool, - proxy_routed_network: bool, + allow_ip_sockets_in_isolated_network: bool, ) -> Result<()> { let (file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); let network_seccomp_mode = network_seccomp_mode( network_sandbox_policy, allow_network_for_proxy, - proxy_routed_network, + allow_ip_sockets_in_isolated_network, ); // `PR_SET_NO_NEW_PRIVS` is required for seccomp, but it also prevents @@ -90,7 +90,7 @@ pub(crate) fn apply_permission_profile_to_current_thread( #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum NetworkSeccompMode { Restricted, - ProxyRouted, + LocalIpSockets, } fn should_install_network_seccomp( @@ -105,12 +105,12 @@ fn should_install_network_seccomp( fn network_seccomp_mode( network_sandbox_policy: NetworkSandboxPolicy, allow_network_for_proxy: bool, - proxy_routed_network: bool, + allow_ip_sockets_in_isolated_network: bool, ) -> Option { if !should_install_network_seccomp(network_sandbox_policy, allow_network_for_proxy) { None - } else if proxy_routed_network { - Some(NetworkSeccompMode::ProxyRouted) + } else if allow_ip_sockets_in_isolated_network { + Some(NetworkSeccompMode::LocalIpSockets) } else { Some(NetworkSeccompMode::Restricted) } @@ -215,13 +215,10 @@ fn install_network_seccomp_filter_on_current_thread( rules.insert(libc::SYS_socket, vec![unix_only_rule.clone()]); rules.insert(libc::SYS_socketpair, vec![unix_only_rule]); } - NetworkSeccompMode::ProxyRouted => { - // In proxy-routed mode we allow IP sockets in the isolated - // namespace (used to reach the local TCP bridge) but deny socket() - // for all other families, including AF_UNIX. Only AF_UNIX - // socketpair() remains available for process-local IPC because it - // cannot connect to a socket outside the sandbox or bypass the - // bridge. + NetworkSeccompMode::LocalIpSockets => { + // Local bridges and ingress servers need IP sockets inside + // the isolated namespace. Other socket families remain blocked, + // except AF_UNIX socketpair() for process-local IPC. let deny_non_ip_socket = SeccompRule::new(vec![ SeccompCondition::new( 0, @@ -310,14 +307,26 @@ mod tests { } #[test] - fn managed_proxy_routes_use_proxy_routed_seccomp_mode() { + fn managed_proxy_routes_allow_local_ip_sockets() { assert_eq!( network_seccomp_mode( NetworkSandboxPolicy::Enabled, /*allow_network_for_proxy*/ true, - /*proxy_routed_network*/ true, + /*allow_ip_sockets_in_isolated_network*/ true, ), - Some(NetworkSeccompMode::ProxyRouted) + Some(NetworkSeccompMode::LocalIpSockets) + ); + } + + #[test] + fn ingress_allows_local_ip_sockets_without_proxy() { + assert_eq!( + network_seccomp_mode( + NetworkSandboxPolicy::Restricted, + /*allow_network_for_proxy*/ false, + /*allow_ip_sockets_in_isolated_network*/ true, + ), + Some(NetworkSeccompMode::LocalIpSockets) ); } @@ -327,7 +336,7 @@ mod tests { network_seccomp_mode( NetworkSandboxPolicy::Restricted, /*allow_network_for_proxy*/ false, - /*proxy_routed_network*/ false, + /*allow_ip_sockets_in_isolated_network*/ false, ), Some(NetworkSeccompMode::Restricted) ); @@ -339,7 +348,7 @@ mod tests { network_seccomp_mode( NetworkSandboxPolicy::Enabled, /*allow_network_for_proxy*/ false, - /*proxy_routed_network*/ false, + /*allow_ip_sockets_in_isolated_network*/ false, ), None ); diff --git a/codex-rs/linux-sandbox/src/lib.rs b/codex-rs/linux-sandbox/src/lib.rs index 478cd6c379..e7fd63172f 100644 --- a/codex-rs/linux-sandbox/src/lib.rs +++ b/codex-rs/linux-sandbox/src/lib.rs @@ -12,6 +12,8 @@ mod bwrap; #[cfg(target_os = "linux")] mod exec_util; #[cfg(target_os = "linux")] +mod ingress; +#[cfg(target_os = "linux")] mod landlock; #[cfg(target_os = "linux")] mod launcher; diff --git a/codex-rs/linux-sandbox/src/linux_run_main.rs b/codex-rs/linux-sandbox/src/linux_run_main.rs index 346b1f14c0..b171214a6d 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main.rs @@ -20,6 +20,8 @@ use std::time::Duration; use crate::bwrap::BwrapNetworkMode; use crate::bwrap::BwrapOptions; use crate::bwrap::create_bwrap_command_args; +use crate::ingress::activate_ingress; +use crate::ingress::take_ingress_listener_fd_from_env; use crate::landlock::apply_permission_profile_to_current_thread; use crate::launcher::exec_bwrap; use crate::launcher::preferred_bwrap_supports_argv0; @@ -126,6 +128,10 @@ pub struct LandlockCommand { #[arg(long = "proxy-route-spec", hide = true)] pub proxy_route_spec: Option, + /// Internal: expose a TCP port through an inherited listener. + #[arg(long = "ingress", hide = true)] + pub ingress: Option, + /// When set, skip mounting a fresh `/proc` even though PID isolation is /// still enabled. This is primarily intended for restrictive container /// environments that deny `--proc /proc`. @@ -153,6 +159,7 @@ pub fn run_main() -> ! { apply_seccomp_then_exec, allow_network_for_proxy, proxy_route_spec, + ingress, no_proc, command, } = LandlockCommand::parse(); @@ -161,6 +168,7 @@ pub fn run_main() -> ! { panic!("No command specified to execute."); } ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_legacy_landlock); + ensure_ingress_mode_is_valid(ingress, use_legacy_landlock); let EffectivePermissions { permission_profile, file_system_sandbox_policy, @@ -176,6 +184,14 @@ pub fn run_main() -> ! { // Inner stage: apply seccomp/no_new_privs after bubblewrap has already // established the filesystem view. if apply_seccomp_then_exec { + if let Some(ingress_port) = ingress { + let listener_fd = take_ingress_listener_fd_from_env() + .unwrap_or_else(|err| panic!("error resolving ingress listener fd: {err}")) + .unwrap_or_else(|| panic!("ingress requires inherited listener fd")); + if let Err(err) = activate_ingress(listener_fd, ingress_port) { + panic!("error activating ingress bridge: {err}"); + } + } if allow_network_for_proxy { let spec = proxy_route_spec .as_deref() @@ -184,26 +200,29 @@ pub fn run_main() -> ! { panic!("error activating Linux proxy routing bridge: {err}"); } } - let proxy_routing_active = allow_network_for_proxy; + let allow_ip_sockets_in_isolated_network = allow_network_for_proxy || ingress.is_some(); if let Err(e) = apply_permission_profile_to_current_thread( &permission_profile, &sandbox_policy_cwd, /*apply_landlock_fs*/ false, allow_network_for_proxy, - proxy_routing_active, + allow_ip_sockets_in_isolated_network, ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } exec_or_panic(command); } - if file_system_sandbox_policy.has_full_disk_write_access() && !allow_network_for_proxy { + if file_system_sandbox_policy.has_full_disk_write_access() + && !allow_network_for_proxy + && ingress.is_none() + { if let Err(e) = apply_permission_profile_to_current_thread( &permission_profile, &sandbox_policy_cwd, /*apply_landlock_fs*/ false, allow_network_for_proxy, - /*proxy_routed_network*/ false, + /*allow_ip_sockets_in_isolated_network*/ false, ) { panic!("error applying Linux sandbox restrictions: {e:?}"); } @@ -228,6 +247,7 @@ pub fn run_main() -> ! { permission_profile: &permission_profile, allow_network_for_proxy, proxy_route_spec, + ingress, command, }); run_bwrap_with_proc_fallback( @@ -247,7 +267,7 @@ pub fn run_main() -> ! { &sandbox_policy_cwd, /*apply_landlock_fs*/ true, allow_network_for_proxy, - /*proxy_routed_network*/ false, + /*allow_ip_sockets_in_isolated_network*/ false, ) { panic!("error applying legacy Linux sandbox restrictions: {e:?}"); } @@ -298,6 +318,15 @@ fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_legacy_la } } +fn ensure_ingress_mode_is_valid(ingress: Option, use_legacy_landlock: bool) { + if ingress == Some(0) { + panic!("--ingress requires a non-zero port"); + } + if ingress.is_some() && use_legacy_landlock { + panic!("--ingress requires bubblewrap mode"); + } +} + fn ensure_legacy_landlock_mode_supports_policy( use_legacy_landlock: bool, file_system_sandbox_policy: &FileSystemSandboxPolicy, @@ -1394,6 +1423,7 @@ struct InnerSeccompCommandArgs<'a> { permission_profile: &'a PermissionProfile, allow_network_for_proxy: bool, proxy_route_spec: Option, + ingress: Option, command: Vec, } @@ -1405,6 +1435,7 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec permission_profile, allow_network_for_proxy, proxy_route_spec, + ingress, command, } = args; let current_exe = match std::env::current_exe() { @@ -1437,6 +1468,10 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec inner.push("--proxy-route-spec".to_string()); inner.push(proxy_route_spec); } + if let Some(ingress_port) = ingress { + inner.push("--ingress".to_string()); + inner.push(ingress_port.to_string()); + } inner.push("--".to_string()); inner.extend(command); inner diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 4441af7809..433c7002d2 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -468,6 +468,7 @@ fn managed_proxy_inner_command_includes_route_spec() { permission_profile: &permission_profile, allow_network_for_proxy: true, proxy_route_spec: Some("{\"routes\":[]}".to_string()), + ingress: None, command: vec!["/bin/true".to_string()], }); @@ -484,6 +485,7 @@ fn inner_command_includes_permission_profile_flag() { permission_profile: &permission_profile, allow_network_for_proxy: false, proxy_route_spec: None, + ingress: None, command: vec!["/bin/true".to_string()], }); @@ -503,12 +505,32 @@ fn non_managed_inner_command_omits_route_spec() { permission_profile: &permission_profile, allow_network_for_proxy: false, proxy_route_spec: None, + ingress: None, command: vec!["/bin/true".to_string()], }); assert!(!args.iter().any(|arg| arg == "--proxy-route-spec")); } +#[test] +fn ingress_inner_command_includes_bridge_flag() { + let permission_profile = read_only_permission_profile(); + let args = build_inner_seccomp_command(InnerSeccompCommandArgs { + sandbox_policy_cwd: Path::new("/tmp"), + command_cwd: Some(Path::new("/tmp/link")), + permission_profile: &permission_profile, + allow_network_for_proxy: false, + proxy_route_spec: None, + ingress: Some(4173), + command: vec!["/bin/true".to_string()], + }); + + assert!( + args.windows(2) + .any(|args| args[0] == "--ingress" && args[1] == "4173") + ); +} + #[test] fn managed_proxy_inner_command_requires_route_spec() { let result = std::panic::catch_unwind(|| { @@ -519,6 +541,7 @@ fn managed_proxy_inner_command_requires_route_spec() { permission_profile: &permission_profile, allow_network_for_proxy: true, proxy_route_spec: None, + ingress: None, command: vec!["/bin/true".to_string()], }) }); diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index f171d91745..0fbd035e5f 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -563,7 +563,7 @@ fn bind_local_loopback_listener() -> io::Result { } } -fn ensure_loopback_interface_up() -> io::Result<()> { +pub(crate) fn ensure_loopback_interface_up() -> io::Result<()> { let fd = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM | libc::SOCK_CLOEXEC, 0) }; if fd < 0 { return Err(io::Error::last_os_error()); @@ -636,7 +636,7 @@ fn set_parent_death_signal() -> io::Result<()> { } } -fn harden_bridge_process() -> io::Result<()> { +pub(crate) fn harden_bridge_process() -> io::Result<()> { set_parent_death_signal()?; codex_process_hardening::disable_process_dumping() } diff --git a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs index 6c9a6a0c67..1c67531db2 100644 --- a/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs +++ b/codex-rs/linux-sandbox/tests/suite/managed_proxy.rs @@ -4,12 +4,16 @@ use codex_core::exec_env::create_env; use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::models::PermissionProfile; +use codex_sandboxing::ingress::INGRESS_LISTENER_FD_ENV_VAR; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::io::Read; use std::io::Write; use std::net::Ipv4Addr; +use std::net::Shutdown; use std::net::TcpListener; +use std::net::TcpStream; +use std::os::fd::AsRawFd; use std::process::Output; use std::process::Stdio; use std::time::Duration; @@ -22,6 +26,8 @@ const MANAGED_PROXY_PERMISSION_ERR_SNIPPETS: &[&str] = &[ "loopback: Failed RTM_NEWLINK", "setting up uid map: Permission denied", "No permissions to create a new namespace", + "Creating new namespace failed: Operation not permitted", + "SeccompInstall(Seccomp(Os { code: 22", "error isolating Linux network namespace for proxy mode", ]; @@ -112,6 +118,37 @@ async fn managed_proxy_skip_reason() -> Option { None } +async fn ingress_skip_reason() -> Option { + if should_skip_bwrap_tests().await { + return Some("bubblewrap is unavailable in this environment".to_string()); + } + + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + + let output = run_linux_sandbox_direct( + &["bash", "-c", "true"], + &PermissionProfile::read_only(), + /*allow_network_for_proxy*/ false, + env, + NETWORK_TIMEOUT_MS, + ) + .await; + if output.status.success() { + return None; + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if is_managed_proxy_permission_error(stderr.as_ref()) { + return Some(format!( + "ingress requires kernel namespace privileges unavailable here: {}", + stderr.trim() + )); + } + + None +} + async fn run_linux_sandbox_direct( command: &[&str], permission_profile: &PermissionProfile, @@ -119,6 +156,26 @@ async fn run_linux_sandbox_direct( env: HashMap, timeout_ms: u64, ) -> Output { + let mut cmd = linux_sandbox_command( + command, + permission_profile, + allow_network_for_proxy, + /*ingress*/ None, + env, + ); + tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output()) + .await + .expect("sandbox command should not time out") + .expect("sandbox command should execute") +} + +fn linux_sandbox_command( + command: &[&str], + permission_profile: &PermissionProfile, + allow_network_for_proxy: bool, + ingress: Option, + env: HashMap, +) -> Command { let cwd = std::env::current_dir().expect("current directory should exist"); let permission_profile_json = serde_json::to_string(permission_profile).expect("permission profile should serialize"); @@ -132,6 +189,10 @@ async fn run_linux_sandbox_direct( if allow_network_for_proxy { args.push("--allow-network-for-proxy".to_string()); } + if let Some(ingress_port) = ingress { + args.push("--ingress".to_string()); + args.push(ingress_port.to_string()); + } args.push("--".to_string()); args.extend(command.iter().map(|entry| (*entry).to_string())); @@ -143,10 +204,29 @@ async fn run_linux_sandbox_direct( .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - tokio::time::timeout(Duration::from_millis(timeout_ms), cmd.output()) - .await - .expect("sandbox command should not time out") - .expect("sandbox command should execute") + cmd +} + +fn clear_close_on_exec(fd: libc::c_int) { + // SAFETY: `fd` comes from this process's live `TcpListener`. + let fd_flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + assert!(fd_flags >= 0, "listener fd flags should be readable"); + // SAFETY: `fd` comes from this process's live `TcpListener`. + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, fd_flags & !libc::FD_CLOEXEC) }; + assert_eq!(result, 0, "listener fd should be inherited"); +} + +fn connect_ingress_stream(port: u16) -> TcpStream { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + match TcpStream::connect((Ipv4Addr::LOCALHOST, port)) { + Ok(stream) => return stream, + Err(_) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(25)); + } + Err(error) => panic!("client should connect to ingress listener: {error}"), + } + } } #[tokio::test] @@ -301,3 +381,106 @@ async fn managed_proxy_mode_denies_af_unix_socket_but_allows_socketpair() { String::from_utf8_lossy(&output.stderr) ); } + +#[tokio::test] +async fn ingress_routes_parent_connection_into_sandbox_local_server() { + if let Some(skip_reason) = ingress_skip_reason().await { + eprintln!("skipping ingress test: {skip_reason}"); + return; + } + + let python_available = Command::new("bash") + .arg("-c") + .arg("command -v python3 >/dev/null") + .status() + .await + .expect("python3 probe should execute") + .success(); + if !python_available { + eprintln!("skipping ingress test: python3 is unavailable"); + return; + } + + let listener = + TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind parent-visible ingress listener"); + clear_close_on_exec(listener.as_raw_fd()); + let parent_visible_port = listener + .local_addr() + .expect("ingress listener local addr") + .port(); + let ingress_port = 4173; + + let mut env = create_env_from_core_vars(); + strip_proxy_env(&mut env); + env.insert( + INGRESS_LISTENER_FD_ENV_VAR.to_string(), + listener.as_raw_fd().to_string(), + ); + + let server_script = format!( + "import http.server\n\ + class Handler(http.server.BaseHTTPRequestHandler):\n\ + def do_GET(self):\n\ + body = b'ingress-ok'\n\ + self.send_response(200)\n\ + self.send_header('Content-Length', str(len(body)))\n\ + self.end_headers()\n\ + self.wfile.write(body)\n\ + def log_message(self, format, *args):\n\ + pass\n\ + server = http.server.ThreadingHTTPServer(('127.0.0.1', {ingress_port}), Handler)\n\ + server.handle_request()\n" + ); + let command = ["python3", "-c", server_script.as_str()]; + let mut cmd = linux_sandbox_command( + &command, + &PermissionProfile::read_only(), + /*allow_network_for_proxy*/ false, + Some(ingress_port), + env, + ); + cmd.kill_on_drop(true); + let child = cmd.spawn().expect("ingress sandbox should spawn"); + + let mut ingress_stream = connect_ingress_stream(parent_visible_port); + ingress_stream + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("set ingress read timeout"); + ingress_stream + .write_all(b"GET / HTTP/1.1\r\nHost: terminal.local\r\nConnection: close\r\n\r\n") + .expect("write ingress request"); + ingress_stream + .shutdown(Shutdown::Write) + .expect("close ingress request body"); + let mut response = Vec::new(); + let response_result = ingress_stream.read_to_end(&mut response); + drop(ingress_stream); + + let output = tokio::time::timeout( + Duration::from_millis(NETWORK_TIMEOUT_MS), + child.wait_with_output(), + ) + .await + .expect("ingress sandbox should exit after one request") + .expect("ingress sandbox should execute"); + response_result.unwrap_or_else(|error| { + panic!( + "read ingress response: {error}; status={:?}; stdout={}; stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + }); + let response = String::from_utf8_lossy(&response); + assert!( + response.contains("ingress-ok"), + "expected ingress response body, got response: {response}" + ); + assert!( + output.status.success(), + "expected ingress sandbox to execute successfully; status={:?}; stdout={}; stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 7d03e1437e..eb3ce29c34 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -506,6 +506,7 @@ impl ExecutorStdioServerLauncher { env, tty: false, pipe_stdin: true, + ingress: None, arg0: None, sandbox: None, enforce_managed_network: false, diff --git a/codex-rs/sandboxing/src/ingress.rs b/codex-rs/sandboxing/src/ingress.rs new file mode 100644 index 0000000000..4bc2869867 --- /dev/null +++ b/codex-rs/sandboxing/src/ingress.rs @@ -0,0 +1,118 @@ +use std::collections::HashMap; +use std::io; +use std::net::Ipv4Addr; +use std::net::TcpListener; +use std::os::fd::AsRawFd; + +/// Environment variable carrying the inherited ingress listener fd. +pub const INGRESS_LISTENER_FD_ENV_VAR: &str = "CODEX_INGRESS_LISTENER_FD"; + +/// Parent-owned TCP ingress for one sandboxed process. +pub struct IngressListener { + listener: TcpListener, +} + +/// Error returned while preparing process ingress. +#[derive(Debug)] +pub enum IngressListenerError { + InvalidPort, + PortInUse(u16), + Io(io::Error), +} + +impl IngressListener { + /// Bind the requested parent-visible TCP port when ingress is enabled. + pub fn prepare(ingress: Option) -> Result, IngressListenerError> { + if ingress == Some(0) { + return Err(IngressListenerError::InvalidPort); + } + ingress.map(Self::bind).transpose() + } + + /// Add the inherited listener fd consumed by `codex-linux-sandbox`. + pub fn add_to_child_env(&self, env: &mut HashMap) { + env.insert( + INGRESS_LISTENER_FD_ENV_VAR.to_string(), + self.listener.as_raw_fd().to_string(), + ); + } + + /// Return the listener fd that the spawned sandbox helper must preserve. + pub fn inherited_fd(&self) -> i32 { + self.listener.as_raw_fd() + } + + fn bind(port: u16) -> Result { + let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, port)).map_err(|error| { + if error.kind() == io::ErrorKind::AddrInUse { + IngressListenerError::PortInUse(port) + } else { + IngressListenerError::Io(error) + } + })?; + set_close_on_exec(listener.as_raw_fd())?; + Ok(Self { listener }) + } +} + +impl From for IngressListenerError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +fn set_close_on_exec(fd: libc::c_int) -> io::Result<()> { + // SAFETY: `fd` comes from this process's live `TcpListener`. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + return Err(io::Error::last_os_error()); + } + + // SAFETY: `fd` comes from this process's live `TcpListener`. + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn listener_exports_inherited_fd_to_child_env() { + let listener = IngressListener::bind(0).expect("bind ingress listener"); + let mut env = HashMap::new(); + + listener.add_to_child_env(&mut env); + + assert_eq!( + env.get(INGRESS_LISTENER_FD_ENV_VAR), + Some(&listener.inherited_fd().to_string()) + ); + } + + #[test] + fn listener_keeps_close_on_exec_in_parent() { + let listener = IngressListener::bind(0).expect("bind ingress listener"); + + assert_ne!(fd_flags(listener.inherited_fd()) & libc::FD_CLOEXEC, 0); + } + + #[test] + fn listener_rejects_ephemeral_ingress_port() { + assert!(matches!( + IngressListener::prepare(Some(0)), + Err(IngressListenerError::InvalidPort) + )); + } + + fn fd_flags(fd: libc::c_int) -> libc::c_int { + // SAFETY: `fd` comes from this test's live `TcpListener`. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + assert!(flags >= 0, "read fd flags: {}", io::Error::last_os_error()); + flags + } +} diff --git a/codex-rs/sandboxing/src/landlock.rs b/codex-rs/sandboxing/src/landlock.rs index 819fc9365e..49f4a92044 100644 --- a/codex-rs/sandboxing/src/landlock.rs +++ b/codex-rs/sandboxing/src/landlock.rs @@ -27,6 +27,7 @@ pub fn create_linux_sandbox_command_args_for_permission_profile( sandbox_policy_cwd: &Path, use_legacy_landlock: bool, allow_network_for_proxy: bool, + ingress: Option, ) -> Vec { let permission_profile_json = serde_json::to_string(permission_profile) .unwrap_or_else(|err| panic!("failed to serialize permission profile: {err}")); @@ -47,13 +48,17 @@ pub fn create_linux_sandbox_command_args_for_permission_profile( "--permission-profile".to_string(), permission_profile_json, ]; - // Proxy-only networking requires bubblewrap's isolated network namespace. - if use_legacy_landlock && !allow_network_for_proxy { + // Network bridges require bubblewrap's isolated network namespace. + if use_legacy_landlock && !allow_network_for_proxy && ingress.is_none() { linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { linux_cmd.push("--allow-network-for-proxy".to_string()); } + if let Some(ingress_port) = ingress { + linux_cmd.push("--ingress".to_string()); + linux_cmd.push(ingress_port.to_string()); + } linux_cmd.push("--".to_string()); linux_cmd.extend(command); linux_cmd @@ -68,6 +73,7 @@ fn create_linux_sandbox_command_args( sandbox_policy_cwd: &Path, use_legacy_landlock: bool, allow_network_for_proxy: bool, + ingress: Option, ) -> Vec { let command_cwd = command_cwd .to_str() @@ -84,13 +90,17 @@ fn create_linux_sandbox_command_args( "--command-cwd".to_string(), command_cwd, ]; - // Proxy-only networking requires bubblewrap's isolated network namespace. - if use_legacy_landlock && !allow_network_for_proxy { + // Network bridges require bubblewrap's isolated network namespace. + if use_legacy_landlock && !allow_network_for_proxy && ingress.is_none() { linux_cmd.push("--use-legacy-landlock".to_string()); } if allow_network_for_proxy { linux_cmd.push("--allow-network-for-proxy".to_string()); } + if let Some(ingress_port) = ingress { + linux_cmd.push("--ingress".to_string()); + linux_cmd.push(ingress_port.to_string()); + } // Separator so that command arguments starting with `-` are not parsed as // options of the helper itself. diff --git a/codex-rs/sandboxing/src/landlock_tests.rs b/codex-rs/sandboxing/src/landlock_tests.rs index d0bbab8400..bdba620d92 100644 --- a/codex-rs/sandboxing/src/landlock_tests.rs +++ b/codex-rs/sandboxing/src/landlock_tests.rs @@ -13,6 +13,7 @@ fn legacy_landlock_flag_is_included_when_requested() { cwd, /*use_legacy_landlock*/ false, /*allow_network_for_proxy*/ false, + /*ingress*/ None, ); assert_eq!( default_bwrap.contains(&"--use-legacy-landlock".to_string()), @@ -25,6 +26,7 @@ fn legacy_landlock_flag_is_included_when_requested() { cwd, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ false, + /*ingress*/ None, ); assert_eq!( legacy_landlock.contains(&"--use-legacy-landlock".to_string()), @@ -46,6 +48,7 @@ fn proxy_flag_takes_precedence_over_legacy_landlock() { cwd, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ true, + /*ingress*/ None, ); assert_eq!( args.contains(&"--allow-network-for-proxy".to_string()), @@ -68,6 +71,7 @@ fn permission_profile_flag_is_included() { cwd, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ false, + /*ingress*/ None, ); assert_eq!( @@ -93,3 +97,25 @@ fn proxy_network_requires_managed_requirements() { true ); } + +#[test] +fn ingress_forces_bubblewrap_bridge_flag() { + let command = vec!["/bin/true".to_string()]; + let command_cwd = Path::new("/tmp/link"); + let cwd = Path::new("/tmp"); + + let args = create_linux_sandbox_command_args( + command, + command_cwd, + cwd, + /*use_legacy_landlock*/ true, + /*allow_network_for_proxy*/ false, + /*ingress*/ Some(4173), + ); + + assert!( + args.windows(2) + .any(|args| args[0] == "--ingress" && args[1] == "4173") + ); + assert_eq!(args.contains(&"--use-legacy-landlock".to_string()), false); +} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index c9109146bd..5ad3a682eb 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,6 +1,8 @@ #[cfg(target_os = "linux")] mod bwrap; mod denial; +#[cfg(unix)] +pub mod ingress; pub mod landlock; mod manager; pub mod policy_transforms; @@ -54,6 +56,15 @@ impl From for CodexErr { SandboxTransformError::EnvironmentNetworkProxy(message) => { CodexErr::UnsupportedOperation(message) } + SandboxTransformError::IngressRequiresLinuxSandbox => { + CodexErr::UnsupportedOperation("ingress requires the Linux sandbox".to_string()) + } + #[cfg(target_os = "linux")] + SandboxTransformError::IngressRequiresIsolatedNetwork => { + CodexErr::UnsupportedOperation( + "ingress requires isolated or managed-proxy Linux networking".to_string(), + ) + } #[cfg(target_os = "linux")] SandboxTransformError::Wsl1UnsupportedForBubblewrap => { CodexErr::UnsupportedOperation(crate::bwrap::WSL1_BWRAP_WARNING.to_string()) diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 51d2d6c98e..92790f469c 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -114,6 +114,7 @@ pub struct SandboxExecRequest { pub cwd: PathUri, pub sandbox_policy_cwd: PathUri, pub env: HashMap, + pub ingress: Option, pub network: Option, pub network_environment_id: Option, pub sandbox: SandboxType, @@ -137,6 +138,7 @@ pub struct SandboxTransformRequest<'a> { // TODO(viyatb): Evaluate switching this to Option> // to make shared ownership explicit across runtime/sandbox plumbing. pub network: Option<&'a NetworkProxy>, + pub ingress: Option, pub sandbox_policy_cwd: &'a PathUri, pub codex_linux_sandbox_exe: Option<&'a Path>, pub use_legacy_landlock: bool, @@ -213,6 +215,9 @@ pub enum SandboxTransformError { }, MissingLinuxSandboxExecutable, EnvironmentNetworkProxy(String), + IngressRequiresLinuxSandbox, + #[cfg(target_os = "linux")] + IngressRequiresIsolatedNetwork, #[cfg(target_os = "linux")] Wsl1UnsupportedForBubblewrap, #[cfg(not(target_os = "macos"))] @@ -240,6 +245,14 @@ impl std::fmt::Display for SandboxTransformError { Self::EnvironmentNetworkProxy(err) => { write!(f, "failed to prepare environment network proxy: {err}") } + Self::IngressRequiresLinuxSandbox => { + write!(f, "ingress requires the Linux sandbox") + } + #[cfg(target_os = "linux")] + Self::IngressRequiresIsolatedNetwork => write!( + f, + "ingress requires isolated or managed-proxy Linux networking" + ), #[cfg(target_os = "linux")] Self::Wsl1UnsupportedForBubblewrap => write!(f, "{WSL1_BWRAP_WARNING}"), #[cfg(not(target_os = "macos"))] @@ -259,6 +272,9 @@ impl std::error::Error for SandboxTransformError { | Self::InvalidSandboxPolicyCwd { source, .. } => Some(source), Self::MissingLinuxSandboxExecutable => None, Self::EnvironmentNetworkProxy(_) => None, + Self::IngressRequiresLinuxSandbox => None, + #[cfg(target_os = "linux")] + Self::IngressRequiresIsolatedNetwork => None, #[cfg(target_os = "linux")] Self::Wsl1UnsupportedForBubblewrap => None, #[cfg(not(target_os = "macos"))] @@ -329,6 +345,7 @@ impl SandboxManager { enforce_managed_network, environment_id, network, + ingress, sandbox_policy_cwd, codex_linux_sandbox_exe, use_legacy_landlock, @@ -350,6 +367,9 @@ impl SandboxManager { ); let (base_file_system_policy, base_network_policy) = base_effective_permission_profile.to_runtime_permissions(); + if ingress.is_some() && sandbox != SandboxType::LinuxSeccomp { + return Err(SandboxTransformError::IngressRequiresLinuxSandbox); + } let mut argv = Vec::with_capacity(1 + command.args.len()); argv.push(command.program); argv.extend(command.args.into_iter().map(OsString::from)); @@ -392,8 +412,15 @@ impl SandboxManager { &pending.effective_file_system_policy, use_legacy_landlock, allow_proxy_network, + ingress, is_wsl1(), )?; + #[cfg(target_os = "linux")] + ensure_ingress_has_isolated_network( + ingress, + pending.effective_network_policy, + allow_proxy_network, + )?; let mut args = create_linux_sandbox_command_args_for_permission_profile( os_argv_to_strings(argv), pending.native_command_cwd.as_path(), @@ -401,6 +428,7 @@ impl SandboxManager { pending.native_sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_proxy_network, + ingress, ); let mut full_command = Vec::with_capacity(1 + args.len()); full_command.push(os_string_to_command_component(exe.as_os_str().to_owned())); @@ -449,6 +477,7 @@ impl SandboxManager { cwd: command.cwd, sandbox_policy_cwd: sandbox_policy_cwd.clone(), env: command.env, + ingress, network: network.cloned(), network_environment_id: environment_id.map(str::to_string), sandbox, @@ -665,9 +694,11 @@ fn ensure_linux_bubblewrap_is_supported( file_system_sandbox_policy: &FileSystemSandboxPolicy, use_legacy_landlock: bool, allow_network_for_proxy: bool, + ingress: Option, is_wsl1: bool, ) -> Result<(), SandboxTransformError> { let requires_bubblewrap = allow_network_for_proxy + || ingress.is_some() || (!use_legacy_landlock && !file_system_sandbox_policy.has_full_disk_write_access()); if is_wsl1 && requires_bubblewrap { return Err(SandboxTransformError::Wsl1UnsupportedForBubblewrap); @@ -676,6 +707,19 @@ fn ensure_linux_bubblewrap_is_supported( Ok(()) } +#[cfg(target_os = "linux")] +fn ensure_ingress_has_isolated_network( + ingress: Option, + network_sandbox_policy: NetworkSandboxPolicy, + allow_network_for_proxy: bool, +) -> Result<(), SandboxTransformError> { + if ingress.is_some() && network_sandbox_policy.is_enabled() && !allow_network_for_proxy { + return Err(SandboxTransformError::IngressRequiresIsolatedNetwork); + } + + Ok(()) +} + fn os_argv_to_strings(argv: Vec) -> Vec { argv.into_iter() .map(os_string_to_command_component) diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index c1a52182d7..6e788061a3 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -100,6 +100,7 @@ fn unsandboxed_transform_preserves_foreign_cwd_and_unrestricted_file_system_poli enforce_managed_network: false, environment_id: None, network: None, + ingress: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, use_legacy_landlock: false, @@ -156,6 +157,7 @@ fn transform_additional_permissions_enable_network_for_external_sandbox() { enforce_managed_network: false, environment_id: None, network: None, + ingress: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, use_legacy_landlock: false, @@ -227,6 +229,7 @@ fn transform_additional_permissions_preserves_denied_entries() { enforce_managed_network: false, environment_id: None, network: None, + ingress: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, use_legacy_landlock: false, @@ -325,6 +328,7 @@ fn transform_linux_seccomp_request( enforce_managed_network: false, environment_id: None, network: None, + ingress: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: Some(codex_linux_sandbox_exe), use_legacy_landlock: false, @@ -349,6 +353,7 @@ fn wsl1_rejects_linux_bubblewrap_path() { &restricted_policy, /*use_legacy_landlock*/ false, /*allow_network_for_proxy*/ false, + /*ingress*/ None, /*is_wsl1*/ true, ), Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap) @@ -358,6 +363,7 @@ fn wsl1_rejects_linux_bubblewrap_path() { &FileSystemSandboxPolicy::unrestricted(), /*use_legacy_landlock*/ false, /*allow_network_for_proxy*/ true, + /*ingress*/ None, /*is_wsl1*/ true, ), Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap) @@ -367,6 +373,7 @@ fn wsl1_rejects_linux_bubblewrap_path() { &FileSystemSandboxPolicy::unrestricted(), /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ true, + /*ingress*/ None, /*is_wsl1*/ true, ), Err(super::SandboxTransformError::Wsl1UnsupportedForBubblewrap) @@ -381,6 +388,7 @@ fn wsl1_allows_non_bubblewrap_linux_paths() { &FileSystemSandboxPolicy::unrestricted(), /*use_legacy_landlock*/ false, /*allow_network_for_proxy*/ false, + /*ingress*/ None, /*is_wsl1*/ true, ) .is_ok() @@ -397,12 +405,38 @@ fn wsl1_allows_non_bubblewrap_linux_paths() { &restricted_policy, /*use_legacy_landlock*/ true, /*allow_network_for_proxy*/ false, + /*ingress*/ None, /*is_wsl1*/ true, ) .is_ok() ); } +#[cfg(target_os = "linux")] +#[test] +fn ingress_requires_isolated_or_managed_proxy_network() { + assert!(matches!( + super::ensure_ingress_has_isolated_network( + /*ingress*/ Some(4173), + NetworkSandboxPolicy::Enabled, + /*allow_network_for_proxy*/ false, + ), + Err(super::SandboxTransformError::IngressRequiresIsolatedNetwork) + )); + super::ensure_ingress_has_isolated_network( + /*ingress*/ Some(4173), + NetworkSandboxPolicy::Restricted, + /*allow_network_for_proxy*/ false, + ) + .expect("restricted network uses an isolated namespace"); + super::ensure_ingress_has_isolated_network( + /*ingress*/ Some(4173), + NetworkSandboxPolicy::Enabled, + /*allow_network_for_proxy*/ true, + ) + .expect("managed proxy uses an isolated namespace"); +} + #[cfg(target_os = "linux")] #[test] fn transform_linux_seccomp_preserves_helper_path_in_arg0_when_available() { @@ -518,6 +552,7 @@ fn transform_for_direct_spawn_windows_materializes_inner_helper() { enforce_managed_network: false, environment_id: None, network: None, + ingress: None, sandbox_policy_cwd: &cwd_uri, codex_linux_sandbox_exe: None, use_legacy_landlock: false, diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index 3a9b62d9b7..7b6ac09acd 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -140,6 +140,7 @@ async fn spawn_process_with_stdin_mode( crate::process_group::detach_from_tty()?; #[cfg(target_os = "linux")] crate::process_group::set_parent_death_signal(parent_pid)?; + crate::pty::make_inherited_fds_inheritable(&inherited_fds)?; crate::pty::close_inherited_fds_except(&inherited_fds); Ok(()) }); @@ -274,6 +275,27 @@ pub async fn spawn_process( spawn_process_with_stdin_mode(program, args, cwd, env, arg0, PipeStdinMode::Piped, &[]).await } +/// Spawn a process using regular pipes and preserve selected inherited file descriptors. +pub async fn spawn_process_with_inherited_fds( + program: &str, + args: &[String], + cwd: &Path, + env: &HashMap, + arg0: &Option, + inherited_fds: &[i32], +) -> Result { + spawn_process_with_stdin_mode( + program, + args, + cwd, + env, + arg0, + PipeStdinMode::Piped, + inherited_fds, + ) + .await +} + /// Spawn a process using regular pipes, but close stdin immediately. pub async fn spawn_process_no_stdin( program: &str, diff --git a/codex-rs/utils/pty/src/pty.rs b/codex-rs/utils/pty/src/pty.rs index f2690c4deb..3313531060 100644 --- a/codex-rs/utils/pty/src/pty.rs +++ b/codex-rs/utils/pty/src/pty.rs @@ -345,6 +345,7 @@ async fn spawn_process_preserving_fds( return Err(std::io::Error::last_os_error()); } + make_inherited_fds_inheritable(&inherited_fds)?; close_inherited_fds_except(&inherited_fds); Ok(()) }); @@ -479,6 +480,28 @@ fn set_cloexec(fd: RawFd) -> std::io::Result<()> { Ok(()) } +#[cfg(unix)] +pub(crate) fn make_inherited_fds_inheritable(inherited_fds: &[RawFd]) -> std::io::Result<()> { + for inherited_fd in inherited_fds { + clear_cloexec(*inherited_fd)?; + } + + Ok(()) +} + +#[cfg(unix)] +fn clear_cloexec(fd: RawFd) -> std::io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags == -1 { + return Err(std::io::Error::last_os_error()); + } + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) }; + if result == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + #[cfg(unix)] pub(crate) fn close_inherited_fds_except(preserved_fds: &[RawFd]) { if let Ok(dir) = std::fs::read_dir("/dev/fd") { diff --git a/codex-rs/utils/pty/src/tests.rs b/codex-rs/utils/pty/src/tests.rs index f9255176f4..185e0e0f8b 100644 --- a/codex-rs/utils/pty/src/tests.rs +++ b/codex-rs/utils/pty/src/tests.rs @@ -833,6 +833,7 @@ async fn pty_spawn_can_preserve_inherited_fds() -> anyhow::Result<()> { let mut read_end = unsafe { std::fs::File::from_raw_fd(fds[0]) }; let write_end = unsafe { std::fs::File::from_raw_fd(fds[1]) }; + set_close_on_exec(write_end.as_raw_fd())?; let mut env_map: HashMap = std::env::vars().collect(); env_map.insert( @@ -1071,6 +1072,7 @@ async fn pipe_spawn_no_stdin_can_preserve_inherited_fds() -> anyhow::Result<()> let mut read_end = unsafe { std::fs::File::from_raw_fd(fds[0]) }; let write_end = unsafe { std::fs::File::from_raw_fd(fds[1]) }; + set_close_on_exec(write_end.as_raw_fd())?; let mut env_map: HashMap = std::env::vars().collect(); env_map.insert( @@ -1101,3 +1103,20 @@ async fn pipe_spawn_no_stdin_can_preserve_inherited_fds() -> anyhow::Result<()> Ok(()) } + +#[cfg(unix)] +fn set_close_on_exec(fd: std::os::fd::RawFd) -> std::io::Result<()> { + // SAFETY: `fd` comes from a live file descriptor owned by the current test. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags == -1 { + return Err(std::io::Error::last_os_error()); + } + + // SAFETY: `fd` comes from a live file descriptor owned by the current test. + let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) }; + if result == -1 { + return Err(std::io::Error::last_os_error()); + } + + Ok(()) +}