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, ProcessCleanupReport, ProcessError, ProcessHandle, ProcessKiller,
14    ProcessSpawner, SpawnSpec, 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        let cleanup_token = harn_vm::op_interrupt::new_process_cleanup_token();
117        command.env(
118            harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
119            &cleanup_token,
120        );
121
122        command.stdout(Stdio::piped());
123        command.stderr(Stdio::piped());
124        command.stdin(if spec.use_stdin {
125            Stdio::piped()
126        } else {
127            Stdio::null()
128        });
129
130        let child = command.spawn().map_err(|e| {
131            if let Some(violation) = process_sandbox::process_spawn_error(&e) {
132                return ProcessError::SandboxSpawn(format!("{violation:?}"));
133            }
134            ProcessError::Spawn(format!("{e}"))
135        })?;
136
137        let pid = child.id();
138        let pgid = child_process_group_id(pid);
139        let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller {
140            pid,
141            cleanup_token: cleanup_token.clone(),
142        });
143
144        Ok(Box::new(RealProcess {
145            pid,
146            pgid,
147            cleanup_token,
148            killer,
149            child: Some(child),
150            stdin: None,
151            stdout: None,
152            stderr: None,
153            stdin_taken: false,
154            stdout_taken: false,
155            stderr_taken: false,
156        }))
157    }
158}
159
160struct RealProcess {
161    pid: u32,
162    pgid: Option<u32>,
163    cleanup_token: String,
164    killer: Arc<dyn ProcessKiller>,
165    child: Option<Child>,
166    stdin: Option<ChildStdin>,
167    stdout: Option<ChildStdout>,
168    stderr: Option<ChildStderr>,
169    stdin_taken: bool,
170    stdout_taken: bool,
171    stderr_taken: bool,
172}
173
174impl RealProcess {
175    fn ensure_pipes_taken(&mut self) {
176        if let Some(child) = self.child.as_mut() {
177            if self.stdin.is_none() && !self.stdin_taken {
178                self.stdin = child.stdin.take();
179            }
180            if self.stdout.is_none() && !self.stdout_taken {
181                self.stdout = child.stdout.take();
182            }
183            if self.stderr.is_none() && !self.stderr_taken {
184                self.stderr = child.stderr.take();
185            }
186        }
187    }
188}
189
190impl ProcessHandle for RealProcess {
191    fn pid(&self) -> Option<u32> {
192        Some(self.pid)
193    }
194
195    fn process_group_id(&self) -> Option<u32> {
196        self.pgid
197    }
198
199    fn killer(&self) -> Arc<dyn ProcessKiller> {
200        Arc::clone(&self.killer)
201    }
202
203    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
204        self.ensure_pipes_taken();
205        self.stdin_taken = true;
206        self.stdin
207            .take()
208            .map(|s| Box::new(s) as Box<dyn Write + Send>)
209    }
210
211    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
212        self.ensure_pipes_taken();
213        self.stdout_taken = true;
214        self.stdout
215            .take()
216            .map(|s| Box::new(s) as Box<dyn Read + Send>)
217    }
218
219    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
220        self.ensure_pipes_taken();
221        self.stderr_taken = true;
222        self.stderr
223            .take()
224            .map(|s| Box::new(s) as Box<dyn Read + Send>)
225    }
226
227    fn wait_with_timeout(
228        &mut self,
229        timeout: Option<Duration>,
230        interrupt: &dyn Fn() -> bool,
231    ) -> io::Result<WaitOutcome> {
232        let killer = Arc::clone(&self.killer);
233        let Some(child) = self.child.as_mut() else {
234            return Err(io::Error::other("child already reaped"));
235        };
236        let deadline = timeout.map(|timeout| Instant::now() + timeout);
237        loop {
238            match child.try_wait()? {
239                Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
240                None => {
241                    if interrupt() {
242                        // Scope cancellation / deadline expiry: graceful
243                        // group termination (SIGTERM, grace, SIGKILL) shared
244                        // with the VM-side `process.*` builtins.
245                        let (_, report) =
246                            harn_vm::op_interrupt::terminate_child_group_with_cleanup_token_report(
247                                child,
248                                Some(&self.cleanup_token),
249                            );
250                        return Ok(WaitOutcome::Interrupted(report));
251                    }
252                    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
253                        // `killer.kill()` kills the process tree/group on
254                        // Unix. That path is a no-op on non-Unix targets, so
255                        // also kill the child handle directly
256                        // (TerminateProcess on Windows) to guarantee the
257                        // subsequent `child.wait()` cannot block forever on a
258                        // timed-out process.
259                        let mut report = killer.kill();
260                        let _ = child.kill();
261                        let _ = child.wait();
262                        report.refresh_survivor_status();
263                        return Ok(WaitOutcome::TimedOut(report));
264                    }
265                    let sleep = deadline
266                        .map(|deadline| deadline.saturating_duration_since(Instant::now()))
267                        .unwrap_or(Duration::MAX)
268                        .min(Duration::from_millis(20));
269                    thread::sleep(sleep);
270                }
271            }
272        }
273    }
274
275    fn wait(&mut self) -> io::Result<ExitStatus> {
276        let child = self
277            .child
278            .as_mut()
279            .ok_or_else(|| io::Error::other("child already reaped"))?;
280        let status = child.wait()?;
281        Ok(decode_status(status))
282    }
283}
284
285struct RealKiller {
286    pid: u32,
287    cleanup_token: String,
288}
289
290impl ProcessKiller for RealKiller {
291    fn kill(&self) -> ProcessCleanupReport {
292        harn_vm::op_interrupt::signal_pid_tree_group_and_token_with_report(
293            self.pid,
294            Some(&self.cleanup_token),
295            9,
296        )
297    }
298}
299
300#[cfg(unix)]
301fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
302    use std::os::unix::process::ExitStatusExt;
303    if let Some(code) = status.code() {
304        ExitStatus::from_code(code)
305    } else if let Some(sig) = status.signal() {
306        ExitStatus::from_signal(sig)
307    } else {
308        ExitStatus {
309            code: None,
310            signal: None,
311        }
312    }
313}
314
315#[cfg(not(unix))]
316fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
317    ExitStatus::from_code(status.code().unwrap_or(-1))
318}
319
320pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
321    #[cfg(unix)]
322    {
323        extern "C" {
324            fn getpgid(pid: i32) -> i32;
325        }
326        let pgid = unsafe { getpgid(pid as i32) };
327        if pgid > 0 {
328            Some(pgid as u32)
329        } else {
330            None
331        }
332    }
333    #[cfg(not(unix))]
334    {
335        Some(pid)
336    }
337}
338
339pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
340    #[cfg(unix)]
341    unsafe {
342        use std::os::unix::process::CommandExt;
343        command.pre_exec(|| {
344            extern "C" {
345                fn setpgid(pid: i32, pgid: i32) -> i32;
346            }
347            if setpgid(0, 0) == -1 {
348                return Err(std::io::Error::last_os_error());
349            }
350            Ok(())
351        });
352    }
353    #[cfg(not(unix))]
354    {
355        let _ = command;
356    }
357}