diff --git a/codex-rs/app-server/src/bin/exec_server.rs b/codex-rs/app-server/src/bin/exec_server.rs index abf58a246b..0b6b7ce001 100644 --- a/codex-rs/app-server/src/bin/exec_server.rs +++ b/codex-rs/app-server/src/bin/exec_server.rs @@ -2,8 +2,8 @@ //! //! This mirrors `//codex-rs/exec-server/testing:exec-server` so Cargo-backed //! app-server integration tests can receive `CARGO_BIN_EXE_exec-server`. It -//! also handles the filesystem-helper argv mode because exec-server re-execs -//! `codex_self_exe` for sandboxed filesystem requests. +//! also handles the helper argv modes because exec-server re-execs +//! `codex_self_exe` for sandboxed filesystem and process requests. use codex_exec_server::ExecServerRuntimePaths; use std::ffi::OsStr; @@ -13,7 +13,12 @@ const CODEX_LINUX_SANDBOX_EXE_ENV_VAR: &str = "CODEX_TEST_LINUX_SANDBOX_EXE"; fn main() -> Result<(), Box> { let mut args = std::env::args_os(); let _ = args.next(); - if args.next().as_deref() == Some(OsStr::new(codex_exec_server::CODEX_FS_HELPER_ARG1)) { + let argv1 = args.next(); + #[cfg(unix)] + if argv1.as_deref() == Some(OsStr::new(codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1)) { + codex_exec_server::run_arg0_exec_helper_main(); + } + if argv1.as_deref() == Some(OsStr::new(codex_exec_server::CODEX_FS_HELPER_ARG1)) { codex_exec_server::run_fs_helper_main(); } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 1c28f81d8c..7951d2cb05 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +#[cfg(unix)] +use codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_install_context::InstallContext; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; @@ -98,6 +100,10 @@ pub fn arg0_dispatch() -> Option { } let argv1 = args.next().unwrap_or_default(); + #[cfg(unix)] + if argv1 == CODEX_ARG0_EXEC_HELPER_ARG1 { + codex_exec_server::run_arg0_exec_helper_main(); + } if argv1 == CODEX_FS_HELPER_ARG1 { codex_exec_server::run_fs_helper_main(); } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 20dc354c85..07dd598862 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -1,5 +1,7 @@ // Aggregates all former standalone integration tests as modules. use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; +#[cfg(unix)] +use codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_test_binary_support::TestBinaryDispatchGuard; @@ -17,6 +19,10 @@ pub static CODEX_ALIASES_TEMP_DIR: Option = { if argv1 == Some(CODEX_CORE_APPLY_PATCH_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } + #[cfg(unix)] + if argv1 == Some(CODEX_ARG0_EXEC_HELPER_ARG1) { + return TestBinaryDispatchMode::DispatchArg0Only; + } if argv1 == Some(CODEX_FS_HELPER_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } diff --git a/codex-rs/exec-server/src/arg0_exec_helper.rs b/codex-rs/exec-server/src/arg0_exec_helper.rs new file mode 100644 index 0000000000..3fc9e81e4c --- /dev/null +++ b/codex-rs/exec-server/src/arg0_exec_helper.rs @@ -0,0 +1,31 @@ +#[cfg(unix)] +use std::process::Command; + +pub const CODEX_ARG0_EXEC_HELPER_ARG1: &str = "--codex-run-as-arg0-exec-helper"; + +#[cfg(unix)] +pub fn main() -> ! { + use std::os::unix::process::CommandExt; + + let mut args = std::env::args_os(); + let _program = args.next(); + let _helper_mode = args.next(); + let Some(arg0) = args.next() else { + eprintln!("missing arg0 for exec helper"); + std::process::exit(1); + }; + let Some(program) = args.next() else { + eprintln!("missing program for exec helper"); + std::process::exit(1); + }; + + let error = Command::new(&program).arg0(arg0).args(args).exec(); + eprintln!("failed to exec {program:?}: {error}"); + std::process::exit(1); +} + +#[cfg(not(unix))] +pub fn main() -> ! { + eprintln!("arg0 exec helper is only supported on Unix"); + std::process::exit(1); +} diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index b07c163961..5b61ed9a44 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -1,3 +1,4 @@ +mod arg0_exec_helper; mod capability_discovery; mod capability_discovery_cache; mod client; @@ -35,6 +36,8 @@ mod websocket_pong_watchdog; use codex_exec_server_protocol as protocol; +pub use arg0_exec_helper::CODEX_ARG0_EXEC_HELPER_ARG1; +pub use arg0_exec_helper::main as run_arg0_exec_helper_main; pub use capability_discovery::CapabilityDiscoveryError; pub use capability_discovery::discover_capability_roots; pub use capability_discovery_cache::ExecutorCapabilityDiscoveryCache; diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index b1efa33402..ca0f394151 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -26,6 +26,8 @@ use codex_sandboxing::with_managed_mitm_ca_readable_root; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; +#[cfg(unix)] +use crate::CODEX_ARG0_EXEC_HELPER_ARG1; use crate::ExecServerRuntimePaths; use crate::protocol::ExecParams; use crate::rpc::internal_error; @@ -121,17 +123,27 @@ pub(crate) async fn prepare_exec_request( native_sandbox_policy_cwd.as_path(), ); let (file_system_policy, network_policy) = permissions.to_runtime_permissions(); + #[cfg(unix)] + let sandbox_helper_paths = params + .arg0 + .iter() + .map(|_| runtime_paths.codex_self_exe.clone()) + .collect::>(); // Bubblewrap launches the configured helper, which may re-enter this executable to apply // seccomp, so the outer filesystem sandbox must expose both paths. #[cfg(target_os = "linux")] - let sandbox_helper_paths = std::iter::once(&runtime_paths.codex_self_exe) - .chain(runtime_paths.codex_linux_sandbox_exe.as_ref()) - .cloned() - .collect::>(); - #[cfg(target_os = "linux")] + let sandbox_helper_paths = { + let mut sandbox_helper_paths = sandbox_helper_paths; + if !sandbox_helper_paths.contains(&runtime_paths.codex_self_exe) { + sandbox_helper_paths.push(runtime_paths.codex_self_exe.clone()); + } + sandbox_helper_paths.extend(runtime_paths.codex_linux_sandbox_exe.iter().cloned()); + sandbox_helper_paths + }; + #[cfg(unix)] let file_system_policy = file_system_policy .with_additional_readable_roots(native_sandbox_policy_cwd.as_path(), &sandbox_helper_paths); - #[cfg(target_os = "linux")] + #[cfg(unix)] let permissions = PermissionProfile::from_runtime_permissions_with_enforcement( permissions.enforcement(), &file_system_policy, @@ -154,16 +166,34 @@ pub(crate) async fn prepare_exec_request( .argv .split_first() .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; + #[cfg(unix)] + let (program, args) = params.arg0.as_ref().map_or_else( + || (program.into(), args.to_vec()), + |arg0| { + let mut helper_args = Vec::with_capacity(params.argv.len() + 2); + helper_args.push(CODEX_ARG0_EXEC_HELPER_ARG1.to_string()); + helper_args.push(arg0.clone()); + helper_args.extend(params.argv.iter().cloned()); + ( + runtime_paths + .codex_self_exe + .as_path() + .as_os_str() + .to_owned(), + helper_args, + ) + }, + ); + #[cfg(not(unix))] + let (program, args) = (program.into(), args.to_vec()); let transform_request = SandboxDirectSpawnTransformRequest { workspace_roots, windows_sandbox_proxy_settings_mode: codex_sandboxing::WindowsSandboxProxySettingsMode::Reconcile, transform: SandboxTransformRequest { - // TODO(jif): Preserve params.arg0 for the inner command across the sandbox - // wrapper, or reject sandboxed requests with a custom arg0. command: SandboxCommand { - program: program.into(), - args: args.to_vec(), + program, + args, cwd: params.cwd.clone(), env, managed_network, diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs index c8aafb865f..2fd2a182b7 100644 --- a/codex-rs/exec-server/src/process_sandbox_tests.rs +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -11,6 +11,8 @@ use codex_network_proxy::RemoteNetworkProxyLaunchConfig; use codex_protocol::config_types::WindowsSandboxLevel; #[cfg(any(unix, windows))] use codex_protocol::models::PermissionProfile; +#[cfg(target_os = "linux")] +use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; @@ -19,6 +21,8 @@ use tokio::io::AsyncWriteExt; use tokio::time::timeout; use super::prepare_exec_request; +#[cfg(unix)] +use crate::CODEX_ARG0_EXEC_HELPER_ARG1; use crate::ExecParams; #[cfg(any(unix, windows))] use crate::ExecServerRuntimePaths; @@ -93,6 +97,61 @@ async fn sandbox_request_wraps_native_argv_on_executor() { ); } +#[cfg(unix)] +#[tokio::test] +async fn sandbox_request_routes_custom_arg0_to_inner_helper() { + 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("process-custom-arg0"), + argv: vec!["/bin/sh".to_string(), "-c".to_string(), "true".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: Some("custom-arg0".to_string()), + sandbox: Some(sandbox), + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .await + .expect("prepare sandboxed request"); + let helper_mode = prepared + .command + .iter() + .position(|arg| arg == CODEX_ARG0_EXEC_HELPER_ARG1) + .expect("sandboxed command should invoke arg0 helper"); + + assert_eq!( + prepared.command[helper_mode..], + [ + CODEX_ARG0_EXEC_HELPER_ARG1, + "custom-arg0", + "/bin/sh", + "-c", + "true", + ] + ); + #[cfg(target_os = "linux")] + assert_eq!(prepared.arg0, Some(CODEX_LINUX_SANDBOX_ARG0.to_string())); + #[cfg(target_os = "macos")] + assert_eq!(prepared.arg0, None); +} + #[cfg(target_os = "macos")] #[tokio::test] async fn sandbox_request_allows_prepared_managed_proxy_port() { diff --git a/codex-rs/exec-server/testing/exec_server.rs b/codex-rs/exec-server/testing/exec_server.rs index dbef4a4899..a0b380a957 100644 --- a/codex-rs/exec-server/testing/exec_server.rs +++ b/codex-rs/exec-server/testing/exec_server.rs @@ -1,12 +1,28 @@ //! Minimal exec-server fixture for Bazel-only integration tests. //! //! Linking only exec-server avoids depending on the full Codex CLI binary -//! when a test only needs a WebSocket executor endpoint. +//! when a test only needs a WebSocket executor endpoint. It handles the arg0 +//! helper mode because sandboxed process requests re-exec this binary. use codex_exec_server::ExecServerRuntimePaths; +#[cfg(unix)] +use std::ffi::OsStr; #[tokio::main] async fn main() -> Result<(), Box> { + #[cfg(unix)] + { + let mut args = std::env::args_os(); + let _ = args.next(); + if args.next().as_deref() + == Some(OsStr::new( + codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1, + )) + { + codex_exec_server::run_arg0_exec_helper_main(); + } + } + let current_exe = std::env::current_exe()?; let runtime_paths = ExecServerRuntimePaths::new(current_exe, /*codex_linux_sandbox_exe*/ None)?; diff --git a/codex-rs/exec-server/tests/common/mod.rs b/codex-rs/exec-server/tests/common/mod.rs index 9eba3c87c6..599d054fb8 100644 --- a/codex-rs/exec-server/tests/common/mod.rs +++ b/codex-rs/exec-server/tests/common/mod.rs @@ -6,6 +6,7 @@ use std::process::Command; use std::process::Stdio; use std::time::Duration; +use codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1; use codex_exec_server::CODEX_FS_HELPER_ARG1; use codex_exec_server::ExecServerRuntimePaths; use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; @@ -25,6 +26,9 @@ const DELAYED_OUTPUT_AFTER_EXIT_CHILD_ARG: &str = "--codex-test-delayed-output-a #[ctor] pub static TEST_BINARY_DISPATCH_GUARD: Option = { let guard = configure_test_binary_dispatch("codex-exec-server-tests", |exe_name, argv1| { + if argv1 == Some(CODEX_ARG0_EXEC_HELPER_ARG1) { + return TestBinaryDispatchMode::DispatchArg0Only; + } if argv1 == Some(CODEX_FS_HELPER_ARG1) { return TestBinaryDispatchMode::DispatchArg0Only; } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index a441dee8e1..fd78ba1ac5 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -88,6 +88,75 @@ async fn create_process_context(use_remote: bool) -> Result { } } +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_sandboxed_process_preserves_custom_arg0() -> Result<()> { + if let Some(warning) = codex_sandboxing::system_bwrap_warning(&PermissionProfile::read_only()) { + eprintln!("skipping bwrap test: {warning}"); + return Ok(()); + } + + let context = create_process_context(/*use_remote*/ true).await?; + let workspace = TempDir::new()?; + let outside_workspace = TempDir::new()?; + let denied_file = outside_workspace.path().join("denied.txt"); + std::fs::write(&denied_file, b"denied")?; + let cwd = PathUri::from_host_native_path(workspace.path())?; + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Read, + }, + ]); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::from_runtime_permissions(&policy, NetworkSandboxPolicy::Restricted), + cwd.clone(), + ); + let session = context + .backend + .start(ExecParams { + process_id: ProcessId::from("proc-custom-arg0"), + argv: vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf '%s' \"$0\"; if /bin/cat \"$CODEX_TEST_DENIED_FILE\" >/dev/null 2>&1; then exit 42; fi" + .to_string(), + ], + cwd, + env_policy: None, + env: HashMap::from([ + ("PATH".to_string(), std::env::var("PATH")?), + ( + "CODEX_TEST_DENIED_FILE".to_string(), + denied_file.to_string_lossy().into_owned(), + ), + ]), + tty: false, + pipe_stdin: false, + arg0: Some("custom-arg0".to_string()), + sandbox: Some(sandbox), + enforce_managed_network: false, + managed_network: None, + network_proxy: None, + }) + .await?; + let output = collect_process_output_from_events(session.process).await?; + + assert_eq!( + output, + ("custom-arg0".to_string(), String::new(), Some(0), true) + ); + Ok(()) +} + async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { let context = create_process_context(use_remote).await?; let session = context