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