diff --git a/crates/memory/tests/launch.rs b/crates/memory/tests/launch.rs index 722d87d..2d54765 100644 --- a/crates/memory/tests/launch.rs +++ b/crates/memory/tests/launch.rs @@ -856,6 +856,79 @@ fn a_prompted_launch_runs_headless_and_insists_on_a_handoff() { ); } +/// A confined prompted run captures the harness's standard output through a pipe, which +/// holds 64 KiB. A harness that writes more than that finishes, and all of what it wrote +/// reaches the launcher's own output. Bounded in time: a launch that never returns is +/// this test failing, and its process group is killed so nothing outlives the test. +#[cfg(target_os = "linux")] +#[test] +fn a_confined_prompted_run_captures_more_than_a_pipe_holds() { + use std::os::unix::process::CommandExt; + use std::time::{Duration, Instant}; + if !namespaces_available() { + eprintln!("skipping: unprivileged namespaces are not available here"); + return; + } + let dir = tempfile::tempdir().unwrap(); + let root = init_root(dir.path()); + let work = dir.path().join("work"); + std::fs::create_dir_all(&work).unwrap(); + let bins = fake_harness( + &work, + "printf 'begin\\n'; head -c 200000 /dev/zero | tr '\\0' a; printf '\\nend\\n'; exit 0", + ); + let stdout = dir.path().join("stdout"); + let stderr = dir.path().join("stderr"); + let mut cmd = memory(); + cmd.args([ + "--root", + root.to_str().unwrap(), + "launch", + "--harness", + "claude", + "--auth", + "key", + ]) + .args(["--workdir", work.to_str().unwrap()]) + .args(["--prompt", "hello", "--no-handoff"]) + .env("PATH", path_with(&bins)) + .env("ANTHROPIC_API_KEY", "sk-ant-placeholder") + .stdin(Stdio::null()) + .stdout(std::fs::File::create(&stdout).unwrap()) + .stderr(std::fs::File::create(&stderr).unwrap()) + .process_group(0); + let mut child = cmd.spawn().unwrap(); + let deadline = Instant::now() + Duration::from_secs(90); + let status = loop { + if let Some(s) = child.try_wait().unwrap() { + break s; + } + if Instant::now() > deadline { + Command::new("kill") + .args(["-KILL", "--", &format!("-{}", child.id())]) + .status() + .ok(); + child.wait().ok(); + panic!( + "the launch had not returned after 90 s, with {} bytes out: {}", + std::fs::metadata(&stdout).map(|m| m.len()).unwrap_or(0), + std::fs::read_to_string(&stderr).unwrap_or_default() + ); + } + std::thread::sleep(Duration::from_millis(100)); + }; + let err = std::fs::read_to_string(&stderr).unwrap(); + assert_eq!(status.code(), Some(0), "{err}"); + assert!(err.contains("confined to"), "{err}"); + let out = std::fs::read_to_string(&stdout).unwrap(); + let want = format!("begin\n{}\nend\n", "a".repeat(200_000)); + assert!( + out.contains(&want), + "the harness's output, whole: {} bytes out", + out.len() + ); +} + // ---- the wslc backend's inside half, driven by a fake host ---- /// `memory inside` writes run requests for the host and waits for exit codes. This fake diff --git a/crates/sandbox/src/linux.rs b/crates/sandbox/src/linux.rs index 1724717..aabe172 100644 --- a/crates/sandbox/src/linux.rs +++ b/crates/sandbox/src/linux.rs @@ -575,7 +575,8 @@ fn cloexec_from_three(limit: libc::c_int) -> std::io::Result<()> { fn become_pid_one() -> Result<(), Error> { unshare(CloneFlags::CLONE_NEWPID).map_err(|e| confine(format!("unshare pid: {e}")))?; // SAFETY: fork between fork and exec in an already single-threaded child; the parent - // branch only waits and exits, the child branch only mounts and returns to exec. + // branch only closes descriptors, waits and exits, the child branch only mounts and + // returns to exec. let pid = unsafe { libc::fork() }; if pid < 0 { return Err(confine(format!( @@ -584,6 +585,11 @@ fn become_pid_one() -> Result<(), Error> { ))); } if pid > 0 { + // The intermediate never execs, so its copies of the launcher's descriptors + // stay open until it exits: among them std's exec-error channel, whose end of + // file is what lets `spawn` return. The child, pid 1, keeps its own copies and + // reports through that channel as before. + close_from_three(); let mut status: libc::c_int = 0; // SAFETY: waitpid on our own child with a valid status pointer. let r = unsafe { libc::waitpid(pid, &mut status, 0) }; @@ -650,6 +656,21 @@ fn say_the_relay_died(signal: i32, core_dumped: bool) { unsafe { libc::write(2, buf.as_ptr().cast(), n) }; } +/// Close every descriptor from 3 up. Runs between fork and exec, so it allocates nothing: +/// `close_range` (Linux 5.9) in one call, or else `close` on each number below the +/// open-files limit. +fn close_from_three() { + // SAFETY: close_range with integer arguments only. + let r = unsafe { libc::syscall(libc::SYS_close_range, 3, libc::c_uint::MAX, 0) }; + if r == 0 { + return; + } + for fd in 3..open_files_limit() { + // SAFETY: close of a descriptor number; one that is not open fails with EBADF. + unsafe { libc::close(fd) }; + } +} + /// What `memory relay` does inside: spawn the harness and wait for it. The relay is /// already pid 1 of the harness's namespace, courtesy of [`run`]. ///