Skip to main content

harn_hostlib/process/
real.rs

1//! Production [`ProcessSpawner`] implementation backed by
2//! `std::process::Command` + `harn_vm::process_sandbox`.
3
4use std::io::{self, Read, Write};
5use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Stdio};
6use std::sync::{Arc, LazyLock};
7use std::thread;
8use std::time::{Duration, Instant};
9
10use harn_vm::process_sandbox;
11
12use super::handle::{
13    EnvMode, ExitStatus, ProcessError, ProcessHandle, ProcessKiller, ProcessSpawner, SpawnSpec,
14    WaitOutcome,
15};
16
17/// Spawner that produces real OS processes via `std::process::Command`.
18pub struct RealSpawner;
19
20static REAL_SPAWNER: LazyLock<Arc<dyn ProcessSpawner>> =
21    LazyLock::new(|| Arc::new(RealSpawner) as Arc<dyn ProcessSpawner>);
22
23/// Returns the singleton real spawner used as the default.
24pub fn default_spawner() -> Arc<dyn ProcessSpawner> {
25    Arc::clone(&REAL_SPAWNER)
26}
27
28impl ProcessSpawner for RealSpawner {
29    fn spawn(&self, spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError> {
30        if spec.program.is_empty() {
31            return Err(ProcessError::InvalidArgv(
32                "first element of argv must be a non-empty program name".to_string(),
33            ));
34        }
35
36        let mut command = process_sandbox::std_command_for(&spec.program, &spec.args)
37            .map_err(|e| ProcessError::SandboxSetup(format!("{e:?}")))?;
38
39        if let Some(cwd) = spec.cwd.as_ref() {
40            process_sandbox::enforce_process_cwd(cwd)
41                .map_err(|e| ProcessError::SandboxCwd(format!("{e:?}")))?;
42            command.current_dir(cwd);
43        }
44
45        match spec.env_mode {
46            // `Replace` starts from an empty environment, so nothing to strip.
47            EnvMode::Replace => {
48                command.env_clear();
49            }
50            // `InheritClean`/`Patch` inherit the full parent environment. Strip
51            // secret-bearing variables (provider `*_API_KEY`s, `GITHUB_TOKEN`,
52            // `HARN_CLOUD_API_KEY`, etc.) so build/test commands — and the model
53            // that reads their stdout as the tool result — never see them.
54            // Caller-supplied `env` below is applied afterward and is an
55            // explicit opt-in, so it is intentionally not filtered here.
56            EnvMode::InheritClean | EnvMode::Patch => {
57                for (key, _) in std::env::vars_os() {
58                    if let Some(name) = key.to_str() {
59                        if super::handle::is_sensitive_env_name(name) {
60                            command.env_remove(&key);
61                        }
62                    }
63                }
64            }
65        }
66        // Caller-requested inherited-env strips (e.g. a harness spawning a
67        // child harn/burin process that must not write into the parent's
68        // event-log or transcript dirs). Applied before `spec.env`, so an
69        // explicitly supplied override still wins.
70        for key in &spec.env_remove {
71            command.env_remove(key);
72        }
73        for (key, value) in &spec.env {
74            command.env(key, value);
75        }
76
77        // Point the child's temp dir at a sandbox-writable, workspace-local
78        // location so compiler linkers (rustc/cc/ld, Go, Swift, …) and other
79        // toolchains that honor TMPDIR/TMP/TEMP don't false-fail trying to write
80        // intermediates to the unwritable system /tmp under a restricted
81        // sandbox profile. Applied after the caller's `spec.env` so an explicit
82        // caller-set TMPDIR wins; only keys the caller did not set receive the
83        // overlay. No-op when the active profile is unrestricted or no writable
84        // workspace root is available. TMPDIR/TMP/TEMP are workspace paths, not
85        // secrets, so this does not widen the env-secret-scrub surface above.
86        for (key, value) in process_sandbox::active_workspace_tmpdir_env() {
87            if spec.env.contains_key(&key) {
88                continue;
89            }
90            command.env(key, value);
91        }
92
93        // Pin tool *message* output to a deterministic English/UTF-8 locale so
94        // downstream English-diagnostic matchers (deterministic syntax repair,
95        // error-signature grounding, completion/pass-fail classification) do not
96        // misfire for a non-Anglosphere user whose shell localizes compiler/test
97        // output. A user-inherited `LC_ALL` overrides `LC_MESSAGES`, so strip it
98        // first — unless the caller pinned it. Then apply the overlay with the
99        // same caller-wins rule as the TMPDIR overlay above.
100        if !spec
101            .env
102            .contains_key(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV)
103        {
104            command.env_remove(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
105        }
106        for (key, value) in process_sandbox::deterministic_message_locale_env() {
107            if spec.env.contains_key(&key) {
108                continue;
109            }
110            command.env(key, value);
111        }
112
113        if spec.configure_process_group {
114            configure_background_process_group(&mut command);
115        }
116
117        command.stdout(Stdio::piped());
118        command.stderr(Stdio::piped());
119        command.stdin(if spec.use_stdin {
120            Stdio::piped()
121        } else {
122            Stdio::null()
123        });
124
125        let child = command.spawn().map_err(|e| {
126            if let Some(violation) = process_sandbox::process_spawn_error(&e) {
127                return ProcessError::SandboxSpawn(format!("{violation:?}"));
128            }
129            ProcessError::Spawn(format!("{e}"))
130        })?;
131
132        let pid = child.id();
133        let pgid = child_process_group_id(pid);
134        let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller { pid });
135
136        Ok(Box::new(RealProcess {
137            pid,
138            pgid,
139            killer,
140            child: Some(child),
141            stdin: None,
142            stdout: None,
143            stderr: None,
144            stdin_taken: false,
145            stdout_taken: false,
146            stderr_taken: false,
147        }))
148    }
149}
150
151struct RealProcess {
152    pid: u32,
153    pgid: Option<u32>,
154    killer: Arc<dyn ProcessKiller>,
155    child: Option<Child>,
156    stdin: Option<ChildStdin>,
157    stdout: Option<ChildStdout>,
158    stderr: Option<ChildStderr>,
159    stdin_taken: bool,
160    stdout_taken: bool,
161    stderr_taken: bool,
162}
163
164impl RealProcess {
165    fn ensure_pipes_taken(&mut self) {
166        if let Some(child) = self.child.as_mut() {
167            if self.stdin.is_none() && !self.stdin_taken {
168                self.stdin = child.stdin.take();
169            }
170            if self.stdout.is_none() && !self.stdout_taken {
171                self.stdout = child.stdout.take();
172            }
173            if self.stderr.is_none() && !self.stderr_taken {
174                self.stderr = child.stderr.take();
175            }
176        }
177    }
178}
179
180impl ProcessHandle for RealProcess {
181    fn pid(&self) -> Option<u32> {
182        Some(self.pid)
183    }
184
185    fn process_group_id(&self) -> Option<u32> {
186        self.pgid
187    }
188
189    fn killer(&self) -> Arc<dyn ProcessKiller> {
190        Arc::clone(&self.killer)
191    }
192
193    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
194        self.ensure_pipes_taken();
195        self.stdin_taken = true;
196        self.stdin
197            .take()
198            .map(|s| Box::new(s) as Box<dyn Write + Send>)
199    }
200
201    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
202        self.ensure_pipes_taken();
203        self.stdout_taken = true;
204        self.stdout
205            .take()
206            .map(|s| Box::new(s) as Box<dyn Read + Send>)
207    }
208
209    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
210        self.ensure_pipes_taken();
211        self.stderr_taken = true;
212        self.stderr
213            .take()
214            .map(|s| Box::new(s) as Box<dyn Read + Send>)
215    }
216
217    fn wait_with_timeout(
218        &mut self,
219        timeout: Option<Duration>,
220        interrupt: &dyn Fn() -> bool,
221    ) -> io::Result<WaitOutcome> {
222        let killer = Arc::clone(&self.killer);
223        let Some(child) = self.child.as_mut() else {
224            return Err(io::Error::other("child already reaped"));
225        };
226        let deadline = timeout.map(|timeout| Instant::now() + timeout);
227        loop {
228            match child.try_wait()? {
229                Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
230                None => {
231                    if interrupt() {
232                        // Scope cancellation / deadline expiry: graceful
233                        // group termination (SIGTERM, grace, SIGKILL) shared
234                        // with the VM-side `process.*` builtins.
235                        harn_vm::op_interrupt::terminate_child_group(child);
236                        return Ok(WaitOutcome::Interrupted);
237                    }
238                    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
239                        // `killer.kill()` kills the process tree/group on
240                        // Unix. That path is a no-op on non-Unix targets, so
241                        // also kill the child handle directly
242                        // (TerminateProcess on Windows) to guarantee the
243                        // subsequent `child.wait()` cannot block forever on a
244                        // timed-out process.
245                        killer.kill();
246                        let _ = child.kill();
247                        let _ = child.wait();
248                        return Ok(WaitOutcome::TimedOut);
249                    }
250                    let sleep = deadline
251                        .map(|deadline| deadline.saturating_duration_since(Instant::now()))
252                        .unwrap_or(Duration::MAX)
253                        .min(Duration::from_millis(20));
254                    thread::sleep(sleep);
255                }
256            }
257        }
258    }
259
260    fn wait(&mut self) -> io::Result<ExitStatus> {
261        let child = self
262            .child
263            .as_mut()
264            .ok_or_else(|| io::Error::other("child already reaped"))?;
265        let status = child.wait()?;
266        Ok(decode_status(status))
267    }
268}
269
270struct RealKiller {
271    pid: u32,
272}
273
274impl ProcessKiller for RealKiller {
275    fn kill(&self) {
276        harn_vm::op_interrupt::signal_pid_tree_and_group(self.pid, 9);
277    }
278}
279
280#[cfg(unix)]
281fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
282    use std::os::unix::process::ExitStatusExt;
283    if let Some(code) = status.code() {
284        ExitStatus::from_code(code)
285    } else if let Some(sig) = status.signal() {
286        ExitStatus::from_signal(sig)
287    } else {
288        ExitStatus {
289            code: None,
290            signal: None,
291        }
292    }
293}
294
295#[cfg(not(unix))]
296fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
297    ExitStatus::from_code(status.code().unwrap_or(-1))
298}
299
300pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
301    #[cfg(unix)]
302    {
303        extern "C" {
304            fn getpgid(pid: i32) -> i32;
305        }
306        let pgid = unsafe { getpgid(pid as i32) };
307        if pgid > 0 {
308            Some(pgid as u32)
309        } else {
310            None
311        }
312    }
313    #[cfg(not(unix))]
314    {
315        Some(pid)
316    }
317}
318
319pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
320    #[cfg(unix)]
321    unsafe {
322        use std::os::unix::process::CommandExt;
323        command.pre_exec(|| {
324            extern "C" {
325                fn setpgid(pid: i32, pgid: i32) -> i32;
326            }
327            if setpgid(0, 0) == -1 {
328                return Err(std::io::Error::last_os_error());
329            }
330            Ok(())
331        });
332    }
333    #[cfg(not(unix))]
334    {
335        let _ = command;
336    }
337}