diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 825de0b06a..dd28581942 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2340,6 +2340,7 @@ dependencies = [ "codex-shell-escalation", "codex-utils-absolute-path", "codex-utils-home-dir", + "codex-utils-pty", "codex-windows-sandbox", "dotenvy", "pathdiff", @@ -5013,6 +5014,7 @@ name = "codex-utils-pty" version = "0.0.0" dependencies = [ "anyhow", + "ctor 0.6.3", "filedescriptor", "lazy_static", "libc", diff --git a/codex-rs/app-server/src/bin/exec_server.rs b/codex-rs/app-server/src/bin/exec_server.rs index ee65d8ffad..49fce24620 100644 --- a/codex-rs/app-server/src/bin/exec_server.rs +++ b/codex-rs/app-server/src/bin/exec_server.rs @@ -13,6 +13,8 @@ use std::ffi::OsStr; const CODEX_LINUX_SANDBOX_EXE_ENV_VAR: &str = "CODEX_TEST_LINUX_SANDBOX_EXE"; fn main() -> Result<(), Box> { + #[cfg(target_os = "linux")] + codex_utils_pty::init_spawn_helper(std::env::args_os()); let mut args = std::env::args_os(); let _ = args.next(); let argv1 = args.next(); diff --git a/codex-rs/app-server/tests/suite/v2/process_exec.rs b/codex-rs/app-server/tests/suite/v2/process_exec.rs index 0050872a5b..9ac5bc28de 100644 --- a/codex-rs/app-server/tests/suite/v2/process_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/process_exec.rs @@ -188,6 +188,53 @@ async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +#[tokio::test] +async fn process_spawn_exec_failure_releases_handle_for_retry() -> Result<()> { + let codex_home = TempDir::new()?; + let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; + let process_handle = "retry-after-exec-error".to_string(); + let request_id = mcp + .send_process_spawn_request(process_spawn_params( + process_handle.clone(), + codex_home.path(), + vec!["/codex-missing-process-spawn-target".to_string()], + )?) + .await?; + let error = mcp + .read_stream_until_error_message(RequestId::Integer(request_id)) + .await?; + assert!(error.error.message.starts_with("failed to spawn process:")); + + let request_id = mcp + .send_process_spawn_request(process_spawn_params( + process_handle.clone(), + codex_home.path(), + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "printf retried; exit 19".to_string(), + ], + )?) + .await?; + let response = mcp + .read_stream_until_response_message(RequestId::Integer(request_id)) + .await?; + assert_eq!(response.result, serde_json::json!({})); + assert_eq!( + read_process_exited(&mut mcp).await?, + ProcessExitedNotification { + process_handle, + exit_code: 19, + stdout: "retried".to_string(), + stdout_cap_reached: false, + stderr: String::new(), + stderr_cap_reached: false, + } + ); + Ok(()) +} + #[tokio::test] async fn process_kill_terminates_running_process() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 4619d6203b..b6d4c2d404 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -23,6 +23,7 @@ codex-sandboxing = { workspace = true } codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } +codex-utils-pty = { workspace = true } dotenvy = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 6b0d99a437..5bed3b93ef 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -58,6 +58,8 @@ impl Arg0PathEntryGuard { } pub fn arg0_dispatch() -> Option { + #[cfg(target_os = "linux")] + codex_utils_pty::init_spawn_helper(std::env::args_os()); // Determine if we were invoked via the special alias. let mut args = std::env::args_os(); let argv0 = args.next().unwrap_or_default(); diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 9c4bc06996..e5154ec1a6 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -1251,6 +1251,20 @@ mod tests { #[cfg(not(target_os = "windows"))] use crate::protocol::NetworkPolicyRequestResponse; + #[cfg(target_os = "linux")] + #[ctor::ctor] + fn initialize_spawn_helper() { + use std::os::unix::ffi::OsStringExt; + let command_line = std::fs::read("/proc/self/cmdline").expect("test command line"); + codex_utils_pty::init_spawn_helper( + command_line + .strip_suffix(&[0]) + .unwrap_or(&command_line) + .split(|byte| *byte == 0) + .map(|arg| std::ffi::OsString::from_vec(arg.to_vec())), + ); + } + fn test_exec_params(env: HashMap) -> ExecParams { ExecParams { metadata: None, diff --git a/codex-rs/exec-server/testing/BUILD.bazel b/codex-rs/exec-server/testing/BUILD.bazel index 93cb4ceab9..34b5978a51 100644 --- a/codex-rs/exec-server/testing/BUILD.bazel +++ b/codex-rs/exec-server/testing/BUILD.bazel @@ -41,6 +41,7 @@ rust_binary( deps = [ "//codex-rs/exec-server", "//codex-rs/http-client", + "//codex-rs/utils/pty", "@crates//:tokio", ], ) diff --git a/codex-rs/exec-server/testing/exec_server.rs b/codex-rs/exec-server/testing/exec_server.rs index 9c6773995b..cb57ab7061 100644 --- a/codex-rs/exec-server/testing/exec_server.rs +++ b/codex-rs/exec-server/testing/exec_server.rs @@ -12,6 +12,8 @@ use std::ffi::OsStr; const CODEX_LINUX_SANDBOX_EXE_ENV_VAR: &str = "CODEX_TEST_LINUX_SANDBOX_EXE"; fn main() -> Result<(), Box> { + #[cfg(target_os = "linux")] + codex_utils_pty::init_spawn_helper(std::env::args_os()); let mut args = std::env::args_os(); let _ = args.next(); let argv1 = args.next(); diff --git a/codex-rs/utils/pty/Cargo.toml b/codex-rs/utils/pty/Cargo.toml index 69942817ca..d235d6995f 100644 --- a/codex-rs/utils/pty/Cargo.toml +++ b/codex-rs/utils/pty/Cargo.toml @@ -13,6 +13,7 @@ portable-pty = { workspace = true } tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } [dev-dependencies] +ctor = { workspace = true } tempfile = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/utils/pty/src/lib.rs b/codex-rs/utils/pty/src/lib.rs index d3438698b4..cbe03baf81 100644 --- a/codex-rs/utils/pty/src/lib.rs +++ b/codex-rs/utils/pty/src/lib.rs @@ -55,3 +55,13 @@ pub use win::PsuedoCon; pub use win::conpty::RawConPty; #[cfg(windows)] pub use windows_input::WindowsTtyInputNormalizer; + +#[cfg(target_os = "linux")] +mod spawn_helper; +#[cfg(target_os = "linux")] +mod spawn_helper_main; +#[cfg(target_os = "linux")] +pub use spawn_helper::init_spawn_helper; +#[cfg(all(test, target_os = "linux"))] +#[path = "spawn_helper_tests.rs"] +mod spawn_helper_tests; diff --git a/codex-rs/utils/pty/src/pipe.rs b/codex-rs/utils/pty/src/pipe.rs index abf88d0a79..dcf95e4717 100644 --- a/codex-rs/utils/pty/src/pipe.rs +++ b/codex-rs/utils/pty/src/pipe.rs @@ -172,6 +172,12 @@ async fn spawn_process_with_stdin_mode( #[cfg(windows)] let job = crate::win::JobObject::create().map(Arc::new); + #[cfg(target_os = "linux")] + let mut child = match crate::spawn_helper::spawn(&command).await? { + Some(child) => child, + None => command.spawn()?, + }; + #[cfg(not(target_os = "linux"))] let mut child = command.spawn()?; #[cfg(windows)] let windows_terminator = { diff --git a/codex-rs/utils/pty/src/posix_child.rs b/codex-rs/utils/pty/src/posix_child.rs index 5ef37588c7..bf82774794 100644 --- a/codex-rs/utils/pty/src/posix_child.rs +++ b/codex-rs/utils/pty/src/posix_child.rs @@ -3,7 +3,7 @@ //! Spawn attributes and file actions implement the shared command's process-group //! and descriptor policies. Bare commands search the child's PATH. Callers choose //! whether incompatible executable formats and failed searches may retry through -//! Tokio. Linux uses this for detached sessions with ambient descriptor inheritance. +//! Tokio. Linux also uses it to start the registered process-setup helper. //! Each native child owns its PID until it has been reaped. use std::ffi::CString; @@ -405,10 +405,10 @@ fn c_string(value: &OsStr) -> io::Result { .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "nul byte in MCP command")) } -/// Keeps a child pipe source above stdio so `dup2` cannot clobber another source -/// when the parent has closed standard descriptors. Close-on-exec disposes of -/// this extra descriptor after the spawn actions duplicate it onto stdio. -fn child_fd(fd: OwnedFd) -> io::Result { +/// Keeps a child descriptor above stdio so `dup2` cannot clobber it when the +/// parent has closed standard descriptors. Spawn actions either duplicate it +/// onto stdio or explicitly preserve it across exec. +pub(crate) fn child_fd(fd: OwnedFd) -> io::Result { // SAFETY: fcntl duplicates this live descriptor; the returned fd is newly owned. let duplicate = unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) }; cvt_errno(duplicate)?; diff --git a/codex-rs/utils/pty/src/spawn_helper.rs b/codex-rs/utils/pty/src/spawn_helper.rs new file mode 100644 index 0000000000..1437823b4b --- /dev/null +++ b/codex-rs/utils/pty/src/spawn_helper.rs @@ -0,0 +1,172 @@ +//! Linux launch setup in a fresh image, avoiding a fork of the app-server. +//! +//! The shared launcher preserves descriptors and owns reaping. A private socket transfers +//! the target environment after exec. The helper itself starts with an empty +//! environment so parent and target loader settings cannot affect its bootstrap. +//! A prefix followed by EOF acknowledges target exec; setup failures append errno. +//! Exiting without a prefix means the target has not run, so direct spawn is safe. + +use std::ffi::CString; +use std::io; +use std::os::fd::AsRawFd; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; + +use crate::child_command::ChildDropPolicy; + +pub(super) const HELPER_ARG: &str = "--codex-run-as-process-setup"; +pub(super) const MAX_ENV_BYTES: usize = 8 * 1024 * 1024; +pub(super) const REPORT_PREFIX: u8 = 0; +static HELPER_READY: AtomicBool = AtomicBool::new(false); +const HELPER_EXE: &str = "/proc/self/exe"; + +/// Dispatch internal setup, or register this executable for later helper launches. +/// Pass the full argument list before starting threads or application initialization. +/// Executables that do not register retain their existing spawning behavior. +/// Before Rust initializes argv (notably musl constructors), read the kernel's +/// command line so re-execution can dispatch before registering the helper. +pub fn init_spawn_helper(args: impl IntoIterator) { + use std::os::unix::ffi::OsStringExt; + + let mut args = args.into_iter().collect::>(); + if args.is_empty() { + let Ok(command_line) = std::fs::read("/proc/self/cmdline") else { + return; + }; + let Some(command_line) = command_line.strip_suffix(&[0]) else { + return; + }; + args = command_line + .split(|byte| *byte == 0) + .map(|arg| std::ffi::OsString::from_vec(arg.to_vec())) + .collect(); + } + let mut args = args.into_iter().skip(1); + if args.next().as_deref() == Some(std::ffi::OsStr::new(HELPER_ARG)) { + crate::spawn_helper_main::dispatch(args); + } + if std::fs::metadata(HELPER_EXE).is_ok() { + HELPER_READY.store(true, Ordering::Relaxed); + } +} + +/// Start the helper and await target exec without blocking a runtime worker. +/// Return `None` when registration, helper resource setup, or helper launch fails, +/// preserving compatibility with callers and sandboxes that only permit the target. +pub(crate) async fn spawn(target: &crate::Command) -> io::Result> { + target.validate()?; + if !HELPER_READY.load(Ordering::Relaxed) { + return Ok(None); + } + let settings = target.inner.as_std(); + let mut environment = Vec::new(); + for (key, value) in settings.get_envs().filter_map(|(k, v)| v.map(|v| (k, v))) { + let mut entry = key.as_bytes().to_vec(); + entry.push(b'='); + entry.extend_from_slice(value.as_bytes()); + environment.extend_from_slice(CString::new(entry)?.as_bytes_with_nul()); + } + if environment.len() > MAX_ENV_BYTES { + return Err(io::Error::from_raw_os_error(libc::E2BIG)); + } + // Helper-only resources may be denied or exhaust descriptors even when + // the original command can still spawn. Drop them before trying the fallback. + let prepared = (|| -> io::Result<_> { + let (control, helper_control) = UnixStream::pair()?; + let helper_control = crate::child::posix::child_fd(helper_control.into())?; + control.set_nonblocking(true)?; + let control = tokio::net::UnixStream::from_std(control)?; + let fd = helper_control.as_raw_fd(); + let mut helper = crate::Command::new(HELPER_EXE); + helper + .arg(HELPER_ARG) + .arg(fd.to_string()) + .arg(std::process::id().to_string()) + .arg( + target + .inherited_fds + .iter() + .map(ToString::to_string) + .collect::>() + .join(","), + ) + .arg(settings.get_current_dir().unwrap_or_else(|| Path::new("."))) + .arg(settings.get_program()) + .arg(target.arg0.as_deref().unwrap_or(settings.get_program())) + .args(settings.get_args()) + .drop_policy(ChildDropPolicy::ReapOnly) + .stdin(match &target.stdin { + crate::ChildStdin::Piped => crate::ChildStdin::Piped, + crate::ChildStdin::Null => crate::ChildStdin::Null, + crate::ChildStdin::File(fd) => crate::ChildStdin::File(fd.try_clone()?), + }); + helper.inherited_fds = target.inherited_fds.clone(); + helper.inherited_fds.push(fd); + Ok((control, helper, helper_control)) + })(); + let (mut control, helper, helper_control) = match prepared { + Ok(prepared) => prepared, + Err(_) => return Ok(None), + }; + // Call the shared native backend directly: the helper needs no child callbacks. + // Unsupported libc/platform setup falls back at the original target boundary. + let child = match crate::child::posix::NativeChild::spawn(&helper) { + Ok(Some(child)) => child, + Ok(None) | Err(_) => return Ok(None), + }; + drop(helper); + drop(helper_control); + // All inherited descriptors were passed to the helper synchronously, before + // the first suspension. The caller can now close its copies safely. + let mut starting = StartingChild(Some(child)); + let sent = async { + control + .write_all(&(environment.len() as u32).to_le_bytes()) + .await?; + control.write_all(&environment).await + } + .await; + let mut report = Vec::new(); + let received = control.take(/*limit*/ 6).read_to_end(&mut report).await; + if report.is_empty() { + // Bootstrap can fail before dispatch (for example in the dynamic loader). + // No prefix means the target never ran. Kill and reap the incomplete + // helper before falling back, including when its socket was reset. + return Ok(None); + } + if (report.len() != 1 && report.len() != 5) || report.first() != Some(&REPORT_PREFIX) { + sent?; + received?; + return Err(io::Error::other("invalid spawn helper report")); + } + if report.len() == 5 { + // Prefer the setup errno when failure closed the socket during env transfer. + let errno = i32::from_le_bytes(report[1..].try_into().map_err(io::Error::other)?); + return Err(io::Error::from_raw_os_error(errno)); + } + sent?; + received?; + Ok(starting.0.take()) +} + +/// Kill incomplete launches on cancellation; the shared child still owns reaping. +struct StartingChild(Option); + +impl Drop for StartingChild { + fn drop(&mut self) { + if let Some(pid) = self.0.as_ref().and_then(crate::Child::id) { + // SAFETY: The child retains this unreaped PID. Kill covers both sides + // of setsid and the window after target exec but before acknowledgement. + unsafe { + libc::kill(-(pid as libc::pid_t), libc::SIGKILL); + libc::kill(pid as libc::pid_t, libc::SIGKILL); + } + } + } +} diff --git a/codex-rs/utils/pty/src/spawn_helper_main.rs b/codex-rs/utils/pty/src/spawn_helper_main.rs new file mode 100644 index 0000000000..263322e579 --- /dev/null +++ b/codex-rs/utils/pty/src/spawn_helper_main.rs @@ -0,0 +1,100 @@ +//! Early executable dispatch for Linux pipe spawns. All child setup happens +//! after exec, before application initialization or runtime threads exist. +//! A report prefix precedes target exec; failures append errno before exit. + +use crate::spawn_helper::MAX_ENV_BYTES; +use crate::spawn_helper::REPORT_PREFIX; +use std::fs::File; +use std::io; +use std::io::Read; +use std::io::Write; +use std::os::fd::FromRawFd; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::process::CommandExt; +use std::process::Command; + +/// Complete setup in the fresh, single-threaded image, then replace it with the target. +pub(super) fn dispatch(mut args: impl Iterator) -> ! { + let Some(control_fd) = args + .next() + .and_then(|arg| arg.to_str()?.parse::().ok()) + .filter(|fd| *fd > 2 && unsafe { libc::fcntl(*fd, libc::F_GETFD) } != -1) + else { + std::process::exit(127); + }; + // The parent passes ownership of this descriptor through posix_spawn. + let mut control = unsafe { File::from_raw_fd(control_fd) }; + let mut reported = false; + let result = (|| -> io::Result<()> { + let parent_pid = args + .next() + .and_then(|arg| arg.to_str()?.parse::().ok()) + .filter(|pid| *pid > 0) + .ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?; + let preserved = args + .next() + .ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?; + let preserved = preserved + .to_str() + .ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?; + let inherited_fds = preserved + .split(',') + .filter(|fd| !fd.is_empty()) + .map(str::parse::) + .collect::, _>>() + .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; + let cwd = args + .next() + .ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?; + let program = args + .next() + .ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?; + let arg0 = args + .next() + .ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?; + let mut command = Command::new(program); + command.current_dir(cwd).arg0(arg0).args(args).env_clear(); + if unsafe { libc::fcntl(control_fd, libc::F_SETFD, libc::FD_CLOEXEC) } == -1 { + return Err(io::Error::last_os_error()); + } + crate::process_group::detach_from_tty()?; + crate::process_group::set_parent_death_signal(parent_pid)?; + // Allocation is safe in this fresh, single-threaded image. CLOEXEC + // leaves the report socket open until the target actually execs. + crate::pty::close_inherited_fds_except(&inherited_fds); + let mut size = [0; 4]; + control.read_exact(&mut size)?; + let size = u32::from_le_bytes(size) as usize; + if size > MAX_ENV_BYTES { + return Err(io::Error::from_raw_os_error(libc::E2BIG)); + } + let mut environment = vec![0; size]; + control.read_exact(&mut environment)?; + for entry in environment + .split(|byte| *byte == 0) + .filter(|entry| !entry.is_empty()) + { + let separator = entry + .iter() + .position(|byte| *byte == b'=') + .ok_or_else(|| io::Error::from_raw_os_error(libc::EINVAL))?; + command.env( + std::ffi::OsStr::from_bytes(&entry[..separator]), + std::ffi::OsStr::from_bytes(&entry[separator + 1..]), + ); + } + control.write_all(&[REPORT_PREFIX])?; + reported = true; + Err(command.exec()) + })(); + if !reported { + let _ = control.write_all(&[REPORT_PREFIX]); + } + let errno = result + .err() + .and_then(|error| error.raw_os_error()) + .unwrap_or(libc::EIO); + let _ = control.write_all(&errno.to_le_bytes()); + // No diagnostics: program arguments and environment may contain secrets. + std::process::exit(127); +} diff --git a/codex-rs/utils/pty/src/spawn_helper_tests.rs b/codex-rs/utils/pty/src/spawn_helper_tests.rs new file mode 100644 index 0000000000..ffbc717941 --- /dev/null +++ b/codex-rs/utils/pty/src/spawn_helper_tests.rs @@ -0,0 +1,309 @@ +//! Verify Linux helper dispatch, launch errors, and target environment isolation. + +use std::collections::HashMap; +use std::io; +use std::path::Path; + +use pretty_assertions::assert_eq; + +use crate::spawn_pipe_process; +use crate::spawn_pipe_process_no_stdin; + +#[ctor::ctor] +fn initialize_spawn_helper() { + if let Ok(arguments) = std::fs::read("/proc/self/cmdline") { + let mut arguments = arguments.split(|byte| *byte == 0).skip(1); + if arguments.next() == Some(crate::spawn_helper::HELPER_ARG.as_bytes()) + && arguments.any(|arg| arg == b"--codex-test-fail-helper-bootstrap") + { + // Simulate a loader failure before the helper can report setup. + std::process::exit(127); + } + } + #[cfg(target_env = "gnu")] + if std::env::var_os("CODEX_TEST_PARENT_LOADER_ENV").is_some() { + // SAFETY: This isolated fixture is still single-threaded, before the test + // harness starts. Simulate .env loading after the dynamic loader ran. + unsafe { std::env::set_var("LD_TRACE_LOADED_OBJECTS", "1") }; + } + if std::env::var_os("CODEX_TEST_EARLY_HELPER_ARGV").is_some() { + crate::init_spawn_helper(std::iter::empty()); + } else { + crate::init_spawn_helper(std::env::args_os()); + } +} + +#[tokio::test] +async fn helper_preserves_cwd_env_arg0_and_streams() -> anyhow::Result<()> { + thread_local! { static FORKS: std::cell::Cell = const { std::cell::Cell::new(0) }; } + extern "C" fn after_fork() { + FORKS.with(|count| count.set(count.get() + 1)); + } + FORKS.with(|count| count.set(0)); + assert_eq!( + unsafe { libc::pthread_atfork(None, Some(after_fork), None) }, + 0 + ); + let mut child = spawn_pipe_process( + "sh", + &["-c".into(), "read value; printf '%s|%s|%s|%s' \"$0\" \"$MARKER\" \"$PWD\" \"$value\"; printf error >&2; exit 23".into()], + Path::new("/tmp"), + &HashMap::from([("PATH".into(), "/bin".into()), ("MARKER".into(), "a value".into())]), + &Some("custom-arg0".into()), + &[], + ).await?; + #[cfg(target_env = "gnu")] + let native_available = unsafe { + !libc::dlsym( + libc::RTLD_DEFAULT, + c"posix_spawn_file_actions_addchdir_np".as_ptr(), + ) + .is_null() + }; + #[cfg(not(target_env = "gnu"))] + let native_available = true; + if native_available { + assert_eq!(FORKS.with(std::cell::Cell::get), 0); + } + child + .session + .writer_sender() + .send(b"input\n".to_vec()) + .await?; + child.session.close_stdin(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + while let Some(chunk) = child.stdout_rx.recv().await { + stdout.extend(chunk); + } + while let Some(chunk) = child.stderr_rx.recv().await { + stderr.extend(chunk); + } + assert_eq!( + (stdout, stderr, child.exit_rx.await?), + ( + b"custom-arg0|a value|/tmp|input".to_vec(), + b"error".to_vec(), + 23 + ) + ); + Ok(()) +} + +#[tokio::test] +async fn helper_reports_target_exec_and_cwd_errno() { + for (program, cwd, errno) in [ + ("/codex-missing-spawn-target", "/", libc::ENOENT), + ("/bin/sh", "/codex-missing-spawn-directory", libc::ENOENT), + ("/", "/", libc::EACCES), + ] { + let result = + spawn_pipe_process_no_stdin(program, &[], Path::new(cwd), &HashMap::new(), &None, &[]) + .await; + let error = result.expect_err("invalid target must fail at spawn"); + assert_eq!( + error + .downcast_ref::() + .and_then(io::Error::raw_os_error), + Some(errno) + ); + } +} + +#[tokio::test] +async fn target_loader_environment_does_not_disable_helper_dispatch() -> anyhow::Result<()> { + if !Path::new("/proc/self/exe").exists() { + eprintln!("skipping loader isolation test: procfs is unavailable"); + return Ok(()); + } + // glibc's loader exits before main when this is set. It must affect the + // requested executable only, after the helper has completed its setup. + let mut command = crate::Command::new("/codex-missing-loader-env-target"); + command.current_dir("/").env("LD_TRACE_LOADED_OBJECTS", "1"); + // A failed helper bootstrap returns None; require the helper's target error. + let Err(error) = crate::spawn_helper::spawn(&command).await else { + panic!("helper must reach target exec despite loader settings"); + }; + assert_eq!(error.raw_os_error(), Some(libc::ENOENT)); + Ok(()) +} + +#[test] +fn helper_dispatch_before_rust_initializes_argv() -> anyhow::Result<()> { + let output = std::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "spawn_helper_tests::helper_preserves_cwd_env_arg0_and_streams", + "--nocapture", + ]) + .env("CODEX_TEST_EARLY_HELPER_ARGV", "1") + .output()?; + assert!(output.status.success(), "{output:?}"); + Ok(()) +} + +#[tokio::test] +async fn helper_bootstrap_failure_falls_back_to_direct_spawn() -> anyhow::Result<()> { + let mut child = spawn_pipe_process_no_stdin( + "/bin/echo", + &["--codex-test-fail-helper-bootstrap".into()], + Path::new("/"), + &HashMap::new(), + &None, + &[], + ) + .await?; + let mut stdout = Vec::new(); + while let Some(chunk) = child.stdout_rx.recv().await { + stdout.extend(chunk); + } + assert_eq!( + (child.exit_rx.await?, stdout), + (0, b"--codex-test-fail-helper-bootstrap\n".to_vec()) + ); + Ok(()) +} + +#[tokio::test] +async fn helper_control_socket_survives_closed_stdio() -> anyhow::Result<()> { + use std::os::fd::AsRawFd; + use std::os::fd::BorrowedFd; + + if std::env::var_os("CODEX_TEST_HELPER_CLOSED_STDIO").is_none() { + let output = std::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "spawn_helper_tests::helper_control_socket_survives_closed_stdio", + "--nocapture", + ]) + .env("CODEX_TEST_HELPER_CLOSED_STDIO", "1") + .output()?; + assert!(output.status.success(), "{output:?}"); + return Ok(()); + } + let mut command = crate::Command::new("/bin/echo"); + command.arg("closed-stdio").stdin(crate::ChildStdin::Null); + let Some(probe) = crate::spawn_helper::spawn(&command).await? else { + return Ok(()); + }; + assert!(probe.wait_with_output().await?.status.success()); + // This isolated subprocess has an initialized runtime. Close stdio only + // after saving it, so the helper socket really receives descriptor 0 or 1. + let saved = [0, 1] + .map(|fd| unsafe { BorrowedFd::borrow_raw(fd) }.try_clone_to_owned()) + .into_iter() + .collect::>>()?; + for fd in [0, 1] { + assert_eq!(unsafe { libc::close(fd) }, 0); + } + let result = crate::spawn_helper::spawn(&command).await; + for (fd, saved) in [0, 1].into_iter().zip(saved) { + assert_eq!(unsafe { libc::dup2(saved.as_raw_fd(), fd) }, fd); + } + let output = result + .expect("closed stdio must not break the helper handshake") + .expect("closed stdio must not force a fallback") + .wait_with_output() + .await?; + assert_eq!( + (output.status.code(), output.stdout, output.stderr), + (Some(0), b"closed-stdio\n".to_vec(), vec![]) + ); + Ok(()) +} + +#[tokio::test] +async fn helper_descriptor_pressure_falls_back_to_direct_spawn() -> anyhow::Result<()> { + use std::os::fd::AsRawFd; + use std::os::fd::FromRawFd; + use std::os::fd::OwnedFd; + if std::env::var_os("CODEX_TEST_HELPER_FD_PRESSURE").is_none() { + let output = std::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "spawn_helper_tests::helper_descriptor_pressure_falls_back_to_direct_spawn", + "--nocapture", + ]) + .env("CODEX_TEST_HELPER_FD_PRESSURE", "1") + .output()?; + assert!(output.status.success(), "{output:?}"); + return Ok(()); + } + let source = std::fs::File::open("/dev/null")?; + let originals = (0..16) + .map(|_| { + // SAFETY: fcntl duplicates a live descriptor into a newly owned slot. + let fd = unsafe { libc::fcntl(source.as_raw_fd(), libc::F_DUPFD, 3) }; + anyhow::ensure!(fd >= 3, "{}", io::Error::last_os_error()); + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) + }) + .collect::>>()?; + let targets = originals.iter().map(AsRawFd::as_raw_fd).collect::>(); + let mut command = crate::Command::new("/bin/echo"); + command + .arg("fallback") + .stdin(crate::ChildStdin::File(source.try_clone()?.into())) + .process_mode(crate::ProcessMode::NewSession) + .terminate_on_parent_death() + .preserve_fds(&targets); + // Restrict only this subprocess, after constructing the target's stdio. + let mut limit = std::mem::MaybeUninit::::uninit(); + assert_eq!( + unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, limit.as_mut_ptr()) }, + 0 + ); + let mut limit = unsafe { limit.assume_init() }; + limit.rlim_cur = limit.rlim_cur.min(128); + assert_eq!(unsafe { libc::setrlimit(libc::RLIMIT_NOFILE, &limit) }, 0); + let mut occupied = Vec::new(); + loop { + let fd = unsafe { libc::fcntl(source.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) }; + if fd == -1 { + assert_eq!( + io::Error::last_os_error().raw_os_error(), + Some(libc::EMFILE) + ); + break; + } + occupied.push(unsafe { OwnedFd::from_raw_fd(fd) }); + } + // Direct spawning fits, but the helper also clones stdin and opens its control socket. + anyhow::ensure!(occupied.len() >= 7, "not enough descriptors to reserve"); + occupied.truncate(occupied.len() - 7); + assert!(crate::spawn_helper::spawn(&command).await?.is_none()); + let output = command.spawn()?.wait_with_output().await?; + assert_eq!( + (output.status.code(), output.stdout, output.stderr), + (Some(0), b"fallback\n".to_vec(), vec![]) + ); + Ok(()) +} + +#[cfg(target_env = "gnu")] +#[tokio::test] +async fn parent_loader_environment_does_not_disable_helper_dispatch() -> anyhow::Result<()> { + if !Path::new("/proc/self/exe").exists() { + eprintln!("skipping loader isolation test: procfs is unavailable"); + return Ok(()); + } + if std::env::var_os("CODEX_TEST_PARENT_LOADER_ENV").is_none() { + let output = std::process::Command::new(std::env::current_exe()?) + .args([ + "--exact", + "spawn_helper_tests::parent_loader_environment_does_not_disable_helper_dispatch", + "--nocapture", + ]) + .env("CODEX_TEST_PARENT_LOADER_ENV", "1") + .env_remove("LD_TRACE_LOADED_OBJECTS") + .output()?; + assert!(output.status.success(), "{output:?}"); + return Ok(()); + } + let mut command = crate::Command::new("/codex-missing-parent-loader-env-target"); + command.current_dir("/"); + let Err(error) = crate::spawn_helper::spawn(&command).await else { + panic!("helper must reach target exec despite parent loader settings"); + }; + assert_eq!(error.raw_os_error(), Some(libc::ENOENT)); + Ok(()) +}