//! A supervised, byte-capped stdio child. //! //! The child is launched with a cleared environment and a literal allowlist. //! It speaks a machine protocol, so it gets pipes rather than a PTY: a TTY //! would invite terminal control sequences in both directions for no benefit. //! Its stderr is discarded rather than captured, because a harness's diagnostic //! output is exactly the kind of text that ends up in a log carrying a path or //! a token fragment. //! //! Reading a pipe blocks. The child therefore splits into three owners: a //! reader that may block on its own thread, a writer, and a killer the //! supervising thread keeps. Shutdown kills first and joins second, so a child //! that has gone quiet — or hostile — can never hold the caller hostage. //! //! The child is put in its own process group and the group is what gets //! killed. A pipe reports end of file only when *every* write end closes, so //! killing the direct child alone is not enough: a launcher that execs the //! real program as a grandchild with inherited stdio — which is how the npm //! distribution of Codex works — leaves that grandchild holding our stdout, //! and the reader would block forever on a pipe nothing will ever write to. use std::io::{BufRead, BufReader, Read, Write}; use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; use std::sync::{Arc, Mutex}; use crate::HarnessError; /// Environment a probe child may inherit. /// /// Literal names only: never a pattern and never a user-supplied name. /// /// This deliberately mirrors but does not reuse the terminal crate's private /// allowlist. AGENTS.md keeps the harness and terminal boundaries separate, /// and the two lists genuinely differ: a probe needs `CODEX_HOME` so it reads /// the same account the user's panes use, and has no use for `SHELL`. /// `CODEX_HOME` is a filesystem path, never a secret. pub const PROBE_ENV_ALLOWLIST: &[&str] = &[ "CODEX_HOME", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "PATH", "TMPDIR", "USER", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_RUNTIME_DIR", ]; /// A single frame longer than this is treated as a fault rather than buffered. /// A hostile or broken relay must not be able to grow this process's memory. pub(crate) const MAX_LINE_BYTES: usize = 256 * 1024; /// A freshly launched child, before its pipes are handed to their owners. pub(crate) struct ProbeChild { child: Arc>, group: u32, stdin: ChildStdin, stdout: ChildStdout, } impl ProbeChild { /// Launches a probe program with a minimal environment. /// /// This runs on the caller's thread, following the same pattern as the /// runtime actor: start synchronously so a missing program is an immediate /// error, then transfer the pipes to their threads. /// /// # Errors /// /// Returns [`HarnessError::EmptyProgram`] for a blank program name and /// [`HarnessError::SpawnFailed`] if the process cannot start. The /// underlying [`std::io::Error`] is deliberately reduced to its /// [`std::io::ErrorKind`] so a filesystem path cannot travel in an error. pub(crate) fn spawn(program: &str, arguments: &[&str]) -> Result { if program.trim().is_empty() { return Err(HarnessError::EmptyProgram); } let mut command = Command::new(program); command .args(arguments) .env_clear() .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()); #[cfg(unix)] { use std::os::unix::process::CommandExt; // The child leads its own group, so its group ID is its process ID // and every descendant it does not deliberately detach joins it. command.process_group(0); } for name in PROBE_ENV_ALLOWLIST { if let Some(value) = std::env::var_os(name) { command.env(name, value); } } let mut child = command .spawn() .map_err(|error| HarnessError::SpawnFailed(error.kind()))?; let broken = HarnessError::SpawnFailed(std::io::ErrorKind::BrokenPipe); let group = child.id(); let stdin = child.stdin.take().ok_or(broken)?; let stdout = child.stdout.take().ok_or(broken)?; Ok(Self { child: Arc::new(Mutex::new(child)), group, stdin, stdout, }) } /// Splits the child into its three independent owners. pub(crate) fn into_parts(self) -> (ProbeWriter, ProbeReader, ProbeKiller) { ( ProbeWriter { stdin: self.stdin }, ProbeReader { reader: BufReader::new(self.stdout), }, ProbeKiller { child: self.child, group: self.group, }, ) } } /// The writing half. pub(crate) struct ProbeWriter { stdin: ChildStdin, } impl ProbeWriter { /// Writes one newline-terminated frame. /// /// # Errors /// /// Returns [`HarnessError::Eof`] once the child has closed its input. pub(crate) fn write_line(&mut self, line: &str) -> Result<(), HarnessError> { self.stdin .write_all(line.as_bytes()) .and_then(|()| self.stdin.write_all(b"\n")) .and_then(|()| self.stdin.flush()) .map_err(|_| HarnessError::Eof) } } /// The reading half. Its methods block, so it belongs on its own thread. pub(crate) struct ProbeReader { reader: BufReader, } impl ProbeReader { /// Reads one frame, refusing an oversized line instead of buffering it. /// /// # Errors /// /// Returns [`HarnessError::Eof`] at end of stream and /// [`HarnessError::OversizedLine`] past [`MAX_LINE_BYTES`]. pub(crate) fn read_line(&mut self) -> Result { let mut buffer = Vec::new(); let read = self .reader .by_ref() .take(MAX_LINE_BYTES as u64 + 1) .read_until(b'\n', &mut buffer) .map_err(|_| HarnessError::Eof)?; if read == 0 { return Err(HarnessError::Eof); } if read > MAX_LINE_BYTES { return Err(HarnessError::OversizedLine(read)); } String::from_utf8(buffer).map_err(|_| HarnessError::Malformed) } } /// The half a supervisor keeps so it can always stop the child. #[derive(Clone)] pub(crate) struct ProbeKiller { child: Arc>, group: u32, } impl ProbeKiller { /// Ends the child and everything in its process group, then reaps it. /// /// Killing the whole group is what closes every write end of our stdout /// pipe, which is what unblocks the reader. Idempotent: killing an /// already-dead process is not an error worth reporting, and a poisoned /// lock means another thread died mid-kill, which the operating system has /// already resolved. pub(crate) fn kill(&self) { self.kill_group(); if let Ok(mut child) = self.child.lock() { let _ = child.kill(); let _ = child.wait(); } } #[cfg(unix)] fn kill_group(&self) { use nix::sys::signal::{Signal, killpg}; use nix::unistd::Pid; if let Ok(group) = i32::try_from(self.group) { let _ = killpg(Pid::from_raw(group), Signal::SIGKILL); } } #[cfg(not(unix))] fn kill_group(&self) {} } #[cfg(test)] mod tests { use super::{MAX_LINE_BYTES, PROBE_ENV_ALLOWLIST, ProbeChild}; use crate::HarnessError; #[test] fn an_empty_program_is_refused_before_spawning() { assert!(matches!( ProbeChild::spawn(" ", &[]).err(), Some(HarnessError::EmptyProgram) )); } #[test] fn a_missing_program_reports_a_kind_and_never_a_path() { let Err(error) = ProbeChild::spawn("lumbridge-no-such-probe-binary", &[]) else { panic!("a missing program cannot spawn"); }; assert!(matches!(error, HarnessError::SpawnFailed(_))); assert!( !error.to_string().contains("lumbridge-no-such-probe-binary"), "an error must not carry a filesystem path" ); } #[test] fn the_allowlist_is_literal_sorted_and_free_of_secret_bearing_names() { let mut sorted = PROBE_ENV_ALLOWLIST.to_vec(); sorted.sort_unstable(); assert_eq!(sorted, PROBE_ENV_ALLOWLIST); for name in PROBE_ENV_ALLOWLIST { let upper = name.to_uppercase(); assert!( !upper.contains("TOKEN") && !upper.contains("KEY") && !upper.contains("SECRET") && !upper.contains("PASSWORD"), "{name} could carry a credential into a child" ); } } #[test] fn a_child_round_trips_one_frame() { let Ok(child) = ProbeChild::spawn("cat", &[]) else { return; // cat is not guaranteed on every supported platform. }; let (mut writer, mut reader, killer) = child.into_parts(); writer .write_line(r#"{"ok":true}"#) .expect("cat accepts input"); assert_eq!( reader.read_line().expect("cat echoes the line"), "{\"ok\":true}\n" ); killer.kill(); } #[test] fn an_oversized_frame_is_refused_rather_than_buffered() { // Generated by the child so the test never deadlocks writing more than // a pipe buffer into a process that is not reading. let script = format!("head -c {} /dev/zero | tr '\\0' 'x'", MAX_LINE_BYTES + 16); let Ok(child) = ProbeChild::spawn("sh", &["-c", &script]) else { return; }; let (_writer, mut reader, killer) = child.into_parts(); assert!(matches!( reader.read_line(), Err(HarnessError::OversizedLine(_)) )); killer.kill(); } #[test] fn killing_a_child_unblocks_a_reader_blocked_on_a_grandchild() { // A launcher that backgrounds a helper and waits is the shape of the // npm Codex distribution: the helper inherits our stdout, so killing // only the direct child would leave the reader blocked forever. let Ok(child) = ProbeChild::spawn("sh", &["-c", "sleep 30 & wait"]) else { return; }; let (_writer, mut reader, killer) = child.into_parts(); let reading = std::thread::spawn(move || reader.read_line()); killer.kill(); let outcome = reading.join().expect("the reader thread must not panic"); assert!( matches!(outcome, Err(HarnessError::Eof)), "killing the group must end the read rather than hang it" ); } #[test] fn killing_a_child_unblocks_its_reader() { // `sleep` never writes, so the reader is blocked until the kill lands. let Ok(child) = ProbeChild::spawn("sleep", &["30"]) else { return; }; let (_writer, mut reader, killer) = child.into_parts(); let reading = std::thread::spawn(move || reader.read_line()); killer.kill(); let outcome = reading.join().expect("the reader thread must not panic"); assert!( matches!(outcome, Err(HarnessError::Eof)), "a killed child must end the read rather than hang it" ); } }