From 01a5998d2806438ded7ad9567718cc6972b8a776 Mon Sep 17 00:00:00 2001 From: Sander Striker Date: Sun, 25 Oct 2026 00:30:03 +0200 Subject: [PATCH] fix: close the launcher's descriptors in the sandbox intermediate A confined run on Linux stopped when the harness wrote more than 64 KiB to standard output. The process that becomes pid 1 forks from an intermediate process. The intermediate does not exec, so it kept open all the descriptors of the launcher. One of these is the write end of the exec-error socket that std reads in spawn. Thus spawn did not return until the run ended, nothing read the capture pipe, and the harness stopped when the pipe was full. Now the intermediate closes all descriptors from 3 up after its fork (close_range, or a close loop on a kernel without it). Pid 1 keeps its own copy of the exec-error socket, thus a failure before its exec still gets to spawn. Add a launch test: a confined prompted run whose harness writes 200000 bytes completes in time, and all of the output gets to the launcher. Co-Authored-By: Claude Opus 5.5 --- crates/memory/tests/launch.rs | 73 +++++++++++++++++++++++++++++++++++ crates/sandbox/src/linux.rs | 23 ++++++++++- 2 files changed, 95 insertions(+), 1 deletion(-) diff --git a/crates/memory/tests/launch.rs b/crates/memory/tests/launch.rs index a3d50c7..62ba0bd 100644 --- a/crates/memory/tests/launch.rs +++ b/crates/memory/tests/launch.rs @@ -696,6 +696,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`]. ///