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        for (key, value) in &spec.env {
67            command.env(key, value);
68        }
69
70        // Point the child's temp dir at a sandbox-writable, workspace-local
71        // location so compiler linkers (rustc/cc/ld, Go, Swift, …) and other
72        // toolchains that honor TMPDIR/TMP/TEMP don't false-fail trying to write
73        // intermediates to the unwritable system /tmp under a restricted
74        // sandbox profile. Applied after the caller's `spec.env` so an explicit
75        // caller-set TMPDIR wins; only keys the caller did not set receive the
76        // overlay. No-op when the active profile is unrestricted or no writable
77        // workspace root is available. TMPDIR/TMP/TEMP are workspace paths, not
78        // secrets, so this does not widen the env-secret-scrub surface above.
79        for (key, value) in process_sandbox::active_workspace_tmpdir_env() {
80            if spec.env.contains_key(&key) {
81                continue;
82            }
83            command.env(key, value);
84        }
85
86        // Pin tool *message* output to a deterministic English/UTF-8 locale so
87        // downstream English-diagnostic matchers (deterministic syntax repair,
88        // error-signature grounding, completion/pass-fail classification) do not
89        // misfire for a non-Anglosphere user whose shell localizes compiler/test
90        // output. A user-inherited `LC_ALL` overrides `LC_MESSAGES`, so strip it
91        // first — unless the caller pinned it. Then apply the overlay with the
92        // same caller-wins rule as the TMPDIR overlay above.
93        if !spec
94            .env
95            .contains_key(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV)
96        {
97            command.env_remove(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
98        }
99        for (key, value) in process_sandbox::deterministic_message_locale_env() {
100            if spec.env.contains_key(&key) {
101                continue;
102            }
103            command.env(key, value);
104        }
105
106        if spec.configure_process_group {
107            configure_background_process_group(&mut command);
108        }
109
110        command.stdout(Stdio::piped());
111        command.stderr(Stdio::piped());
112        command.stdin(if spec.use_stdin {
113            Stdio::piped()
114        } else {
115            Stdio::null()
116        });
117
118        let child = command.spawn().map_err(|e| {
119            if let Some(violation) = process_sandbox::process_spawn_error(&e) {
120                return ProcessError::SandboxSpawn(format!("{violation:?}"));
121            }
122            ProcessError::Spawn(format!("{e}"))
123        })?;
124
125        let pid = child.id();
126        let pgid = child_process_group_id(pid);
127        let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller { pid });
128
129        Ok(Box::new(RealProcess {
130            pid,
131            pgid,
132            killer,
133            child: Some(child),
134            stdin: None,
135            stdout: None,
136            stderr: None,
137            stdin_taken: false,
138            stdout_taken: false,
139            stderr_taken: false,
140        }))
141    }
142}
143
144struct RealProcess {
145    pid: u32,
146    pgid: Option<u32>,
147    killer: Arc<dyn ProcessKiller>,
148    child: Option<Child>,
149    stdin: Option<ChildStdin>,
150    stdout: Option<ChildStdout>,
151    stderr: Option<ChildStderr>,
152    stdin_taken: bool,
153    stdout_taken: bool,
154    stderr_taken: bool,
155}
156
157impl RealProcess {
158    fn ensure_pipes_taken(&mut self) {
159        if let Some(child) = self.child.as_mut() {
160            if self.stdin.is_none() && !self.stdin_taken {
161                self.stdin = child.stdin.take();
162            }
163            if self.stdout.is_none() && !self.stdout_taken {
164                self.stdout = child.stdout.take();
165            }
166            if self.stderr.is_none() && !self.stderr_taken {
167                self.stderr = child.stderr.take();
168            }
169        }
170    }
171}
172
173impl ProcessHandle for RealProcess {
174    fn pid(&self) -> Option<u32> {
175        Some(self.pid)
176    }
177
178    fn process_group_id(&self) -> Option<u32> {
179        self.pgid
180    }
181
182    fn killer(&self) -> Arc<dyn ProcessKiller> {
183        Arc::clone(&self.killer)
184    }
185
186    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
187        self.ensure_pipes_taken();
188        self.stdin_taken = true;
189        self.stdin
190            .take()
191            .map(|s| Box::new(s) as Box<dyn Write + Send>)
192    }
193
194    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
195        self.ensure_pipes_taken();
196        self.stdout_taken = true;
197        self.stdout
198            .take()
199            .map(|s| Box::new(s) as Box<dyn Read + Send>)
200    }
201
202    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
203        self.ensure_pipes_taken();
204        self.stderr_taken = true;
205        self.stderr
206            .take()
207            .map(|s| Box::new(s) as Box<dyn Read + Send>)
208    }
209
210    fn wait_with_timeout(
211        &mut self,
212        timeout: Option<Duration>,
213        interrupt: &dyn Fn() -> bool,
214    ) -> io::Result<WaitOutcome> {
215        let killer = Arc::clone(&self.killer);
216        let Some(child) = self.child.as_mut() else {
217            return Err(io::Error::other("child already reaped"));
218        };
219        let deadline = timeout.map(|timeout| Instant::now() + timeout);
220        loop {
221            match child.try_wait()? {
222                Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
223                None => {
224                    if interrupt() {
225                        // Scope cancellation / deadline expiry: graceful
226                        // group termination (SIGTERM, grace, SIGKILL) shared
227                        // with the VM-side `process.*` builtins.
228                        harn_vm::op_interrupt::terminate_child_group(child);
229                        return Ok(WaitOutcome::Interrupted);
230                    }
231                    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
232                        // `killer.kill()` kills the whole process group on Unix
233                        // (negative pid) to reap grandchildren. That path is a
234                        // no-op on non-Unix targets, where `kill_pid_or_group`
235                        // cannot signal by bare pid — so also kill the child
236                        // handle directly (TerminateProcess on Windows) to
237                        // guarantee the subsequent `child.wait()` cannot block
238                        // forever on a timed-out process.
239                        killer.kill();
240                        let _ = child.kill();
241                        let _ = child.wait();
242                        return Ok(WaitOutcome::TimedOut);
243                    }
244                    let sleep = deadline
245                        .map(|deadline| deadline.saturating_duration_since(Instant::now()))
246                        .unwrap_or(Duration::MAX)
247                        .min(Duration::from_millis(20));
248                    thread::sleep(sleep);
249                }
250            }
251        }
252    }
253
254    fn wait(&mut self) -> io::Result<ExitStatus> {
255        let child = self
256            .child
257            .as_mut()
258            .ok_or_else(|| io::Error::other("child already reaped"))?;
259        let status = child.wait()?;
260        Ok(decode_status(status))
261    }
262}
263
264struct RealKiller {
265    pid: u32,
266}
267
268impl ProcessKiller for RealKiller {
269    fn kill(&self) {
270        kill_pid_or_group(self.pid);
271    }
272}
273
274#[cfg(unix)]
275fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
276    use std::os::unix::process::ExitStatusExt;
277    if let Some(code) = status.code() {
278        ExitStatus::from_code(code)
279    } else if let Some(sig) = status.signal() {
280        ExitStatus::from_signal(sig)
281    } else {
282        ExitStatus {
283            code: None,
284            signal: None,
285        }
286    }
287}
288
289#[cfg(not(unix))]
290fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
291    ExitStatus::from_code(status.code().unwrap_or(-1))
292}
293
294pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
295    #[cfg(unix)]
296    {
297        extern "C" {
298            fn getpgid(pid: i32) -> i32;
299        }
300        let pgid = unsafe { getpgid(pid as i32) };
301        if pgid > 0 {
302            Some(pgid as u32)
303        } else {
304            None
305        }
306    }
307    #[cfg(not(unix))]
308    {
309        Some(pid)
310    }
311}
312
313pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
314    #[cfg(unix)]
315    unsafe {
316        use std::os::unix::process::CommandExt;
317        command.pre_exec(|| {
318            extern "C" {
319                fn setpgid(pid: i32, pgid: i32) -> i32;
320            }
321            if setpgid(0, 0) == -1 {
322                return Err(std::io::Error::last_os_error());
323            }
324            Ok(())
325        });
326    }
327    #[cfg(not(unix))]
328    {
329        let _ = command;
330    }
331}
332
333/// Send SIGKILL to a pid (and its process group). Public so existing
334/// non-trait paths (e.g. session-end cleanup) can keep using it during
335/// the transition.
336pub(crate) fn kill_pid_or_group(pid: u32) {
337    #[cfg(unix)]
338    {
339        // SAFETY: kill(2) takes a pid_t (i32 on all Unix targets) and a
340        // signal number. Calling it with SIGKILL (9) is well-defined.
341        extern "C" {
342            fn kill(pid: i32, sig: i32) -> i32;
343        }
344        unsafe {
345            kill(-(pid as i32), 9);
346            kill(pid as i32, 9);
347        }
348    }
349    #[cfg(not(unix))]
350    {
351        let _ = pid;
352    }
353}