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        if spec.configure_process_group {
70            configure_background_process_group(&mut command);
71        }
72
73        command.stdout(Stdio::piped());
74        command.stderr(Stdio::piped());
75        command.stdin(if spec.use_stdin {
76            Stdio::piped()
77        } else {
78            Stdio::null()
79        });
80
81        let child = command.spawn().map_err(|e| {
82            if let Some(violation) = process_sandbox::process_spawn_error(&e) {
83                return ProcessError::SandboxSpawn(format!("{violation:?}"));
84            }
85            ProcessError::Spawn(format!("{e}"))
86        })?;
87
88        let pid = child.id();
89        let pgid = child_process_group_id(pid);
90        let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller { pid });
91
92        Ok(Box::new(RealProcess {
93            pid,
94            pgid,
95            killer,
96            child: Some(child),
97            stdin: None,
98            stdout: None,
99            stderr: None,
100            stdin_taken: false,
101            stdout_taken: false,
102            stderr_taken: false,
103        }))
104    }
105}
106
107struct RealProcess {
108    pid: u32,
109    pgid: Option<u32>,
110    killer: Arc<dyn ProcessKiller>,
111    child: Option<Child>,
112    stdin: Option<ChildStdin>,
113    stdout: Option<ChildStdout>,
114    stderr: Option<ChildStderr>,
115    stdin_taken: bool,
116    stdout_taken: bool,
117    stderr_taken: bool,
118}
119
120impl RealProcess {
121    fn ensure_pipes_taken(&mut self) {
122        if let Some(child) = self.child.as_mut() {
123            if self.stdin.is_none() && !self.stdin_taken {
124                self.stdin = child.stdin.take();
125            }
126            if self.stdout.is_none() && !self.stdout_taken {
127                self.stdout = child.stdout.take();
128            }
129            if self.stderr.is_none() && !self.stderr_taken {
130                self.stderr = child.stderr.take();
131            }
132        }
133    }
134}
135
136impl ProcessHandle for RealProcess {
137    fn pid(&self) -> Option<u32> {
138        Some(self.pid)
139    }
140
141    fn process_group_id(&self) -> Option<u32> {
142        self.pgid
143    }
144
145    fn killer(&self) -> Arc<dyn ProcessKiller> {
146        Arc::clone(&self.killer)
147    }
148
149    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
150        self.ensure_pipes_taken();
151        self.stdin_taken = true;
152        self.stdin
153            .take()
154            .map(|s| Box::new(s) as Box<dyn Write + Send>)
155    }
156
157    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
158        self.ensure_pipes_taken();
159        self.stdout_taken = true;
160        self.stdout
161            .take()
162            .map(|s| Box::new(s) as Box<dyn Read + Send>)
163    }
164
165    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
166        self.ensure_pipes_taken();
167        self.stderr_taken = true;
168        self.stderr
169            .take()
170            .map(|s| Box::new(s) as Box<dyn Read + Send>)
171    }
172
173    fn wait_with_timeout(
174        &mut self,
175        timeout: Option<Duration>,
176    ) -> io::Result<(Option<ExitStatus>, bool)> {
177        let Some(child) = self.child.as_mut() else {
178            return Ok((None, false));
179        };
180        let Some(timeout) = timeout else {
181            let status = child.wait()?;
182            return Ok((Some(decode_status(status)), false));
183        };
184        let start = Instant::now();
185        loop {
186            match child.try_wait()? {
187                Some(status) => return Ok((Some(decode_status(status)), false)),
188                None => {
189                    let elapsed = start.elapsed();
190                    if elapsed >= timeout {
191                        // `killer.kill()` kills the whole process group on Unix
192                        // (negative pid) to reap grandchildren. That path is a
193                        // no-op on non-Unix targets, where `kill_pid_or_group`
194                        // cannot signal by bare pid — so also kill the child
195                        // handle directly (TerminateProcess on Windows) to
196                        // guarantee the subsequent `child.wait()` cannot block
197                        // forever on a timed-out process.
198                        self.killer.kill();
199                        let _ = child.kill();
200                        let _ = child.wait();
201                        return Ok((None, true));
202                    }
203                    let remaining = timeout.checked_sub(elapsed).unwrap_or_default();
204                    thread::sleep(remaining.min(Duration::from_millis(20)));
205                }
206            }
207        }
208    }
209
210    fn wait(&mut self) -> io::Result<ExitStatus> {
211        let child = self
212            .child
213            .as_mut()
214            .ok_or_else(|| io::Error::other("child already reaped"))?;
215        let status = child.wait()?;
216        Ok(decode_status(status))
217    }
218}
219
220struct RealKiller {
221    pid: u32,
222}
223
224impl ProcessKiller for RealKiller {
225    fn kill(&self) {
226        kill_pid_or_group(self.pid);
227    }
228}
229
230#[cfg(unix)]
231fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
232    use std::os::unix::process::ExitStatusExt;
233    if let Some(code) = status.code() {
234        ExitStatus::from_code(code)
235    } else if let Some(sig) = status.signal() {
236        ExitStatus::from_signal(sig)
237    } else {
238        ExitStatus {
239            code: None,
240            signal: None,
241        }
242    }
243}
244
245#[cfg(not(unix))]
246fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
247    ExitStatus::from_code(status.code().unwrap_or(-1))
248}
249
250pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
251    #[cfg(unix)]
252    {
253        extern "C" {
254            fn getpgid(pid: i32) -> i32;
255        }
256        let pgid = unsafe { getpgid(pid as i32) };
257        if pgid > 0 {
258            Some(pgid as u32)
259        } else {
260            None
261        }
262    }
263    #[cfg(not(unix))]
264    {
265        Some(pid)
266    }
267}
268
269pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
270    #[cfg(unix)]
271    unsafe {
272        use std::os::unix::process::CommandExt;
273        command.pre_exec(|| {
274            extern "C" {
275                fn setpgid(pid: i32, pgid: i32) -> i32;
276            }
277            if setpgid(0, 0) == -1 {
278                return Err(std::io::Error::last_os_error());
279            }
280            Ok(())
281        });
282    }
283    #[cfg(not(unix))]
284    {
285        let _ = command;
286    }
287}
288
289/// Send SIGKILL to a pid (and its process group). Public so existing
290/// non-trait paths (e.g. session-end cleanup) can keep using it during
291/// the transition.
292pub(crate) fn kill_pid_or_group(pid: u32) {
293    #[cfg(unix)]
294    {
295        // SAFETY: kill(2) takes a pid_t (i32 on all Unix targets) and a
296        // signal number. Calling it with SIGKILL (9) is well-defined.
297        extern "C" {
298            fn kill(pid: i32, sig: i32) -> i32;
299        }
300        unsafe {
301            kill(-(pid as i32), 9);
302            kill(pid as i32, 9);
303        }
304    }
305    #[cfg(not(unix))]
306    {
307        let _ = pid;
308    }
309}