diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2009300325..15c108994b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2096,6 +2096,7 @@ dependencies = [ "clap", "codex-app-server-protocol", "codex-protocol", + "codex-sandboxing", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-pty", @@ -2685,6 +2686,7 @@ dependencies = [ "dunce", "libc", "pretty_assertions", + "serde", "serde_json", "tempfile", "tracing", diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 86b6163b87..339d1c0e67 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -38,6 +38,8 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::ReviewDecision; +use codex_sandboxing::SandboxLaunchConfig; +use codex_sandboxing::SandboxLaunchMode; use codex_sandboxing::SandboxablePreference; use codex_shell_command::powershell::prefix_powershell_script_with_utf8; use codex_tools::UnifiedExecShellMode; @@ -82,6 +84,24 @@ pub struct UnifiedExecRuntime<'a> { shell_mode: UnifiedExecShellMode, } +fn build_remote_exec_sandbox_config(attempt: &SandboxAttempt<'_>) -> SandboxLaunchConfig { + SandboxLaunchConfig { + mode: if matches!(attempt.sandbox, codex_sandboxing::SandboxType::None) { + SandboxLaunchMode::Disabled + } else { + SandboxLaunchMode::Auto + }, + policy: attempt.policy.clone(), + file_system_policy: attempt.file_system_policy.clone(), + network_policy: attempt.network_policy, + sandbox_policy_cwd: attempt.sandbox_cwd.to_path_buf(), + enforce_managed_network: attempt.enforce_managed_network, + windows_sandbox_level: attempt.windows_sandbox_level, + windows_sandbox_private_desktop: attempt.windows_sandbox_private_desktop, + use_legacy_landlock: attempt.use_legacy_landlock, + } +} + impl<'a> UnifiedExecRuntime<'a> { /// Creates a runtime bound to the shared unified-exec process manager. pub fn new(manager: &'a UnifiedExecProcessManager, shell_mode: UnifiedExecShellMode) -> Self { @@ -219,6 +239,31 @@ impl<'a> ToolRuntime for UnifiedExecRunt if let Some(network) = req.network.as_ref() { network.apply_to_env(&mut env); } + // Remote exec-server now owns sandbox argv construction, so this branch + // keeps sending raw command data until we collapse the launch APIs. + if ctx.turn.environment.exec_server_url().is_some() { + return self + .manager + .open_session_with_remote_exec( + req.process_id, + command, + req.cwd.clone(), + env, + req.tty, + Some(build_remote_exec_sandbox_config(attempt)), + ctx.turn.environment.as_ref(), + ) + .await + .map_err(|err| match err { + UnifiedExecError::SandboxDenied { output, .. } => { + ToolError::Codex(CodexErr::Sandbox(SandboxErr::Denied { + output: Box::new(output), + network_policy_decision: None, + })) + } + other => ToolError::Rejected(other.to_string()), + }); + } if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode { let command = build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone()) diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 9d77ac4e97..53712d206a 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -609,11 +609,12 @@ impl UnifiedExecProcessManager { env: request.env.clone(), tty, arg0: request.arg0.clone(), + sandbox: None, }) .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; - return UnifiedExecProcess::from_remote_started(started, request.sandbox).await; - } + return UnifiedExecProcess::from_remote_started(started, request.sandbox).await; + } let spawn_result = if tty { codex_utils_pty::pty::spawn_process_with_inherited_fds( @@ -643,6 +644,33 @@ impl UnifiedExecProcessManager { UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await } + pub(crate) async fn open_session_with_remote_exec( + &self, + process_id: i32, + command: Vec, + cwd: PathBuf, + env: HashMap, + tty: bool, + sandbox: Option, + environment: &codex_exec_server::Environment, + ) -> Result { + let started = environment + .get_exec_backend() + .start(codex_exec_server::ExecParams { + process_id: exec_server_process_id(process_id).into(), + argv: command, + cwd, + env, + tty, + arg0: None, + sandbox, + }) + .await + .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; + let sandbox_type = started.sandbox_type; + UnifiedExecProcess::from_remote_started(started, sandbox_type).await + } + pub(super) async fn open_session_with_sandbox( &self, request: &ExecCommandRequest, diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 2816d3370d..736a5a6915 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -74,6 +74,7 @@ async fn remote_process(write_status: WriteStatus) -> UnifiedExecProcess { read_responses: Mutex::new(VecDeque::new()), wake_tx, }), + sandbox_type: SandboxType::None, }; UnifiedExecProcess::from_remote_started(started, SandboxType::None) @@ -126,6 +127,7 @@ async fn remote_process_waits_for_early_exit_event() { }])), wake_tx: wake_tx.clone(), }), + sandbox_type: SandboxType::None, }; tokio::spawn(async move { diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 7470717a04..3b3e0b1f32 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -850,13 +850,15 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let open_call_id = "uexec-delayed-open"; let open_args = json!({ - "cmd": "sleep 3 && echo MARKER1 && sleep 3 && echo MARKER2", + "cmd": "sleep 2 && echo MARKER1 && sleep 5 && echo MARKER2", "yield_time_ms": 10, "tty": true, }); // Poll stdin three times: first for no output, second after the first marker, - // and a final long poll to capture the second marker. + // and a final long poll to capture the second marker. Keep a wider buffer + // between MARKER1 and process exit so slower environments still reach the + // third write while the session is live. let first_poll_call_id = "uexec-delayed-poll-1"; let first_poll_args = json!({ "chars": "x", @@ -868,7 +870,7 @@ async fn unified_exec_terminal_interaction_captures_delayed_output() -> Result<( let second_poll_args = json!({ "chars": "x", "session_id": 1000, - "yield_time_ms": 4000, + "yield_time_ms": 3500, }); let third_poll_call_id = "uexec-delayed-poll-3"; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 41d65cf3d2..5121ddf604 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -19,6 +19,8 @@ arc-swap = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } clap = { workspace = true, features = ["derive"] } +codex-protocol = { workspace = true } +codex-sandboxing = { workspace = true } codex-app-server-protocol = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 3323db0064..abe5d59fec 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -291,6 +291,7 @@ mod tests { env: Default::default(), tty: false, arg0: None, + sandbox: None, }) .await .expect("start process"); diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 7c7f0af8ce..f03b2045e0 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; use std::collections::VecDeque; +use std::path::Path; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -7,6 +9,9 @@ use std::time::Duration; use async_trait::async_trait; use codex_app_server_protocol::JSONRPCErrorError; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxType; +use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::TerminalSize; use tokio::sync::Mutex; @@ -78,6 +83,7 @@ struct Inner { processes: Mutex>, initialize_requested: AtomicBool, initialized: AtomicBool, + runtime: ExecServerRuntimeConfig, } #[derive(Clone)] @@ -91,6 +97,33 @@ struct LocalExecProcess { wake_tx: watch::Sender, } +#[derive(Clone, Debug, Default)] +struct ExecServerRuntimeConfig { + codex_linux_sandbox_exe: Option, +} + +impl ExecServerRuntimeConfig { + fn detect() -> Self { + let env_path = std::env::var_os("CODEX_LINUX_SANDBOX_EXE").map(PathBuf::from); + let sibling_path = std::env::current_exe().ok().and_then(|current_exe| { + current_exe + .parent() + .map(|parent| parent.join(CODEX_LINUX_SANDBOX_ARG0)) + .filter(|candidate| candidate.exists()) + }); + Self { + codex_linux_sandbox_exe: env_path.or(sibling_path), + } + } +} + +struct PreparedExecLaunch { + argv: Vec, + env: HashMap, + arg0: Option, + sandbox_type: SandboxType, +} + impl Default for LocalProcess { fn default() -> Self { let (outgoing_tx, mut outgoing_rx) = @@ -108,6 +141,7 @@ impl LocalProcess { processes: Mutex::new(HashMap::new()), initialize_requested: AtomicBool::new(false), initialized: AtomicBool::new(false), + runtime: ExecServerRuntimeConfig::detect(), }), } } @@ -168,7 +202,8 @@ impl LocalProcess { ) -> Result<(ExecResponse, watch::Sender), JSONRPCErrorError> { self.require_initialized_for("exec")?; let process_id = params.process_id.clone(); - let (program, args) = params + let launch = prepare_exec_launch(¶ms, &self.inner.runtime)?; + let (program, args) = launch .argv .split_first() .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; @@ -188,8 +223,8 @@ impl LocalProcess { program, args, params.cwd.as_path(), - ¶ms.env, - ¶ms.arg0, + &launch.env, + &launch.arg0, TerminalSize::default(), ) .await @@ -198,8 +233,8 @@ impl LocalProcess { program, args, params.cwd.as_path(), - ¶ms.env, - ¶ms.arg0, + &launch.env, + &launch.arg0, ) .await }; @@ -264,7 +299,13 @@ impl LocalProcess { output_notify, )); - Ok((ExecResponse { process_id }, wake_tx)) + Ok(( + ExecResponse { + process_id, + sandbox_type: launch.sandbox_type, + }, + wake_tx, + )) } pub(crate) async fn exec(&self, params: ExecParams) -> Result { @@ -424,6 +465,7 @@ impl ExecBackend for LocalProcess { backend: self.clone(), wake_tx, }), + sandbox_type: response.sandbox_type, }) } } @@ -458,6 +500,56 @@ impl ExecProcess for LocalExecProcess { } } +fn build_sandbox_command( + argv: &[String], + cwd: &Path, + env: &HashMap, +) -> Result { + let (program, args) = argv + .split_first() + .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; + Ok(SandboxCommand { + program: program.clone().into(), + args: args.to_vec(), + cwd: cwd.to_path_buf(), + env: env.clone(), + additional_permissions: None, + }) +} + +fn prepare_exec_launch( + params: &ExecParams, + runtime: &ExecServerRuntimeConfig, +) -> Result { + let Some(sandbox) = params.sandbox.as_ref() else { + return Ok(PreparedExecLaunch { + argv: params.argv.clone(), + env: params.env.clone(), + arg0: params.arg0.clone(), + sandbox_type: SandboxType::None, + }); + }; + + let command = build_sandbox_command(¶ms.argv, params.cwd.as_path(), ¶ms.env)?; + let transformed = sandbox + .transform( + command, + // TODO: Thread managed-network proxy state across exec-server so + // sandbox profile generation preserves proxy-specific allowances. + /*network*/ + None, + runtime.codex_linux_sandbox_exe.as_ref(), + ) + .map_err(|err| internal_error(format!("failed to build sandbox launch: {err}")))?; + + Ok(PreparedExecLaunch { + argv: transformed.command, + env: transformed.env, + arg0: transformed.arg0, + sandbox_type: transformed.sandbox, + }) +} + impl LocalProcess { async fn read( &self, diff --git a/codex-rs/exec-server/src/process.rs b/codex-rs/exec-server/src/process.rs index e455c77bdc..f9e4352881 100644 --- a/codex-rs/exec-server/src/process.rs +++ b/codex-rs/exec-server/src/process.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; +use codex_sandboxing::SandboxType; use tokio::sync::watch; use crate::ExecServerError; @@ -11,6 +12,7 @@ use crate::protocol::WriteResponse; pub struct StartedExecProcess { pub process: Arc, + pub sandbox_type: SandboxType, } #[async_trait] diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 8a61a9de1c..ac5f85b125 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::protocol::SandboxPolicy; +use codex_sandboxing::SandboxLaunchConfig; +use codex_sandboxing::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; use serde::Deserialize; use serde::Serialize; @@ -63,12 +65,14 @@ pub struct ExecParams { pub env: HashMap, pub tty: bool, pub arg0: Option, + pub sandbox: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExecResponse { pub process_id: ProcessId, + pub sandbox_type: SandboxType, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/src/remote_process.rs b/codex-rs/exec-server/src/remote_process.rs index d3163b475e..74d6f092ca 100644 --- a/codex-rs/exec-server/src/remote_process.rs +++ b/codex-rs/exec-server/src/remote_process.rs @@ -35,13 +35,17 @@ impl ExecBackend for RemoteProcess { async fn start(&self, params: ExecParams) -> Result { let process_id = params.process_id.clone(); let session = self.client.register_session(&process_id).await?; - if let Err(err) = self.client.exec(params).await { - session.unregister().await; - return Err(err); - } + let response = match self.client.exec(params).await { + Ok(response) => response, + Err(err) => { + session.unregister().await; + return Err(err); + } + }; Ok(StartedExecProcess { process: Arc::new(RemoteExecProcess { session }), + sandbox_type: response.sandbox_type, }) } } diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 0bd78fffb9..7b2321b56d 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -29,6 +29,7 @@ fn exec_params(process_id: &str) -> ExecParams { env, tty: false, arg0: None, + sandbox: None, } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index ab232ccd3e..9dcb8363b3 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -54,6 +54,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { env: Default::default(), tty: false, arg0: None, + sandbox: None, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -130,11 +131,12 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { env: Default::default(), tty: false, arg0: None, + sandbox: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "session output\n"); @@ -159,13 +161,14 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { env: Default::default(), tty: true, arg0: None, + sandbox: None, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); tokio::time::sleep(Duration::from_millis(200)).await; session.process.write(b"hello\n".to_vec()).await?; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -195,12 +198,13 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( env: Default::default(), tty: false, arg0: None, + sandbox: None, }) .await?; tokio::time::sleep(Duration::from_millis(200)).await; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "queued output\n"); @@ -225,6 +229,7 @@ async fn remote_exec_process_reports_transport_disconnect() -> Result<()> { env: Default::default(), tty: false, arg0: None, + sandbox: None, }) .await?; diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index c210b5a9cc..896f574d95 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_exec_server::ExecResponse; use codex_exec_server::InitializeParams; use codex_exec_server::ProcessId; +use codex_sandboxing::SandboxType; use common::exec_server::exec_server; use pretty_assertions::assert_eq; @@ -63,7 +64,8 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), + sandbox_type: SandboxType::None, } ); diff --git a/codex-rs/sandboxing/Cargo.toml b/codex-rs/sandboxing/Cargo.toml index 274e5bce22..8fa17c7038 100644 --- a/codex-rs/sandboxing/Cargo.toml +++ b/codex-rs/sandboxing/Cargo.toml @@ -17,6 +17,7 @@ codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } dunce = { workspace = true } libc = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tracing = { workspace = true, features = ["log"] } url = { workspace = true } diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 244f65bb0a..ee8e7c2e4f 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -12,6 +12,8 @@ pub use bwrap::find_system_bwrap_in_path; pub use bwrap::system_bwrap_warning; pub use manager::SandboxCommand; pub use manager::SandboxExecRequest; +pub use manager::SandboxLaunchConfig; +pub use manager::SandboxLaunchMode; pub use manager::SandboxManager; pub use manager::SandboxTransformError; pub use manager::SandboxTransformRequest; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index 4ed413735a..00043362c6 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -16,11 +16,15 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; +use serde::Deserialize; +use serde::Serialize; use std::collections::HashMap; use std::ffi::OsString; use std::path::Path; +use std::path::PathBuf; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] pub enum SandboxType { None, MacosSeatbelt, @@ -46,6 +50,68 @@ pub enum SandboxablePreference { Forbid, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SandboxLaunchMode { + Disabled, + Auto, + Require, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SandboxLaunchConfig { + pub mode: SandboxLaunchMode, + pub policy: SandboxPolicy, + pub file_system_policy: FileSystemSandboxPolicy, + pub network_policy: NetworkSandboxPolicy, + pub sandbox_policy_cwd: PathBuf, + pub enforce_managed_network: bool, + pub windows_sandbox_level: WindowsSandboxLevel, + pub windows_sandbox_private_desktop: bool, + pub use_legacy_landlock: bool, +} + +impl SandboxLaunchConfig { + pub fn sandbox_type(&self) -> SandboxType { + let preference = match self.mode { + SandboxLaunchMode::Disabled => return SandboxType::None, + SandboxLaunchMode::Auto => SandboxablePreference::Auto, + SandboxLaunchMode::Require => SandboxablePreference::Require, + }; + + SandboxManager::new().select_initial( + &self.file_system_policy, + self.network_policy, + preference, + self.windows_sandbox_level, + self.enforce_managed_network, + ) + } + + pub fn transform( + &self, + command: SandboxCommand, + network: Option<&NetworkProxy>, + codex_linux_sandbox_exe: Option<&PathBuf>, + ) -> Result { + SandboxManager::new().transform(SandboxTransformRequest { + command, + policy: &self.policy, + file_system_policy: &self.file_system_policy, + network_policy: self.network_policy, + sandbox: self.sandbox_type(), + enforce_managed_network: self.enforce_managed_network, + network, + sandbox_policy_cwd: self.sandbox_policy_cwd.as_path(), + codex_linux_sandbox_exe, + use_legacy_landlock: self.use_legacy_landlock, + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + }) + } +} + pub fn get_platform_sandbox(windows_sandbox_enabled: bool) -> Option { if cfg!(target_os = "macos") { Some(SandboxType::MacosSeatbelt) diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index 0daff82e15..a987d12cb8 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -27,9 +27,9 @@ use tempfile::TempDir; fn assert_seatbelt_denied(stderr: &[u8], path: &Path) { let stderr = String::from_utf8_lossy(stderr); - let expected = format!("bash: {}: Operation not permitted\n", path.display()); + let path_display = path.display().to_string(); assert!( - stderr == expected + (stderr.contains(&path_display) && stderr.contains("Operation not permitted")) || stderr.contains("sandbox-exec: sandbox_apply: Operation not permitted"), "unexpected stderr: {stderr}" );