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::fs::OpenOptions;
5use std::io::{self, Read, Write};
6use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, Stdio};
7use std::sync::{Arc, LazyLock};
8use std::thread;
9use std::time::{Duration, Instant};
10
11use harn_vm::process_sandbox;
12
13use super::handle::{
14    EnvMode, ExitStatus, OutputCapture, ProcessCleanupReport, ProcessError, ProcessHandle,
15    ProcessKiller, ProcessSpawner, SpawnSpec, WaitOutcome,
16};
17
18/// Spawner that produces real OS processes via `std::process::Command`.
19pub struct RealSpawner;
20
21static REAL_SPAWNER: LazyLock<Arc<dyn ProcessSpawner>> =
22    LazyLock::new(|| Arc::new(RealSpawner) as Arc<dyn ProcessSpawner>);
23
24/// Returns the singleton real spawner used as the default.
25pub fn default_spawner() -> Arc<dyn ProcessSpawner> {
26    Arc::clone(&REAL_SPAWNER)
27}
28
29impl ProcessSpawner for RealSpawner {
30    fn spawn(&self, spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError> {
31        let (mut command, cleanup_token) = prepare_command(&spec, None)?;
32        let child = command.spawn().map_err(map_spawn_error)?;
33
34        let pid = child.id();
35        let pgid = child_process_group_id(pid);
36        let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller {
37            pid,
38            cleanup_token: cleanup_token.clone(),
39        });
40
41        Ok(Box::new(RealProcess {
42            pid,
43            pgid,
44            cleanup_token,
45            killer,
46            child: Some(child),
47            stdin: None,
48            stdout: None,
49            stderr: None,
50            stdin_taken: false,
51            stdout_taken: false,
52            stderr_taken: false,
53        }))
54    }
55}
56
57fn prepare_command(
58    spec: &SpawnSpec,
59    cleanup_token: Option<String>,
60) -> Result<(Command, String), ProcessError> {
61    if spec.program.is_empty() {
62        return Err(ProcessError::InvalidArgv(
63            "first element of argv must be a non-empty program name".to_string(),
64        ));
65    }
66
67    let mut command = process_sandbox::std_command_for(&spec.program, &spec.args)
68        .map_err(|e| ProcessError::SandboxSetup(format!("{e:?}")))?;
69
70    if let Some(cwd) = spec.cwd.as_ref() {
71        process_sandbox::enforce_process_cwd(cwd)
72            .map_err(|e| ProcessError::SandboxCwd(format!("{e:?}")))?;
73        command.current_dir(cwd);
74    }
75
76    match spec.env_mode {
77        // `Replace` starts from an empty environment, so nothing to strip.
78        EnvMode::Replace => {
79            command.env_clear();
80        }
81        // `InheritClean`/`Patch` inherit the full parent environment. Strip
82        // secret-bearing variables (provider `*_API_KEY`s, `GITHUB_TOKEN`,
83        // `HARN_CLOUD_API_KEY`, etc.) so build/test commands — and the model
84        // that reads their stdout as the tool result — never see them.
85        // Caller-supplied `env` below is applied afterward and is an
86        // explicit opt-in, so it is intentionally not filtered here.
87        EnvMode::InheritClean | EnvMode::Patch => {
88            for (key, _) in std::env::vars_os() {
89                if let Some(name) = key.to_str() {
90                    if super::handle::is_sensitive_env_name(name) {
91                        command.env_remove(&key);
92                    }
93                }
94            }
95        }
96    }
97    // Caller-requested inherited-env strips (e.g. a harness spawning a
98    // child harn/burin process that must not write into the parent's
99    // event-log or transcript dirs). Applied before `spec.env`, so an
100    // explicitly supplied override still wins.
101    for key in &spec.env_remove {
102        command.env_remove(key);
103    }
104    for (key, value) in &spec.env {
105        command.env(key, value);
106    }
107
108    // Point the child's temp dir at a sandbox-writable, workspace-local
109    // location so compiler linkers (rustc/cc/ld, Go, Swift, …) and other
110    // toolchains that honor TMPDIR/TMP/TEMP don't false-fail trying to write
111    // intermediates to the unwritable system /tmp under a restricted
112    // sandbox profile. Applied after the caller's `spec.env` so an explicit
113    // caller-set TMPDIR wins; only keys the caller did not set receive the
114    // overlay. No-op when the active profile is unrestricted or no writable
115    // workspace root is available. TMPDIR/TMP/TEMP are workspace paths, not
116    // secrets, so this does not widen the env-secret-scrub surface above.
117    for (key, value) in process_sandbox::active_workspace_tmpdir_env() {
118        if spec.env.contains_key(&key) {
119            continue;
120        }
121        command.env(key, value);
122    }
123
124    // Pin tool *message* output to a deterministic English/UTF-8 locale so
125    // downstream English-diagnostic matchers (deterministic syntax repair,
126    // error-signature grounding, completion/pass-fail classification) do not
127    // misfire for a non-Anglosphere user whose shell localizes compiler/test
128    // output. A user-inherited `LC_ALL` overrides `LC_MESSAGES`, so strip it
129    // first — unless the caller pinned it. Then apply the overlay with the
130    // same caller-wins rule as the TMPDIR overlay above.
131    if !spec
132        .env
133        .contains_key(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV)
134    {
135        command.env_remove(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
136    }
137    for (key, value) in process_sandbox::deterministic_message_locale_env() {
138        if spec.env.contains_key(&key) {
139            continue;
140        }
141        command.env(key, value);
142    }
143
144    log_spawn_context(&command, spec.env_mode);
145
146    if spec.configure_process_group {
147        configure_background_process_group(&mut command);
148    }
149    let cleanup_token =
150        cleanup_token.unwrap_or_else(harn_vm::op_interrupt::new_process_cleanup_token);
151    command.env(
152        harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
153        &cleanup_token,
154    );
155
156    match &spec.output_capture {
157        OutputCapture::Inherit => {
158            command.stdout(Stdio::inherit());
159            command.stderr(Stdio::inherit());
160        }
161        OutputCapture::Pipe => {
162            command.stdout(Stdio::piped());
163            command.stderr(Stdio::piped());
164        }
165        OutputCapture::File {
166            stdout_path,
167            stderr_path,
168        } => {
169            let stdout = OpenOptions::new()
170                .write(true)
171                .truncate(true)
172                .open(stdout_path)
173                .map_err(|error| ProcessError::Spawn(format!("open stdout capture: {error}")))?;
174            let stderr = OpenOptions::new()
175                .write(true)
176                .truncate(true)
177                .open(stderr_path)
178                .map_err(|error| ProcessError::Spawn(format!("open stderr capture: {error}")))?;
179            command.stdout(Stdio::from(stdout));
180            command.stderr(Stdio::from(stderr));
181        }
182    }
183    command.stdin(match (&spec.output_capture, spec.use_stdin) {
184        (OutputCapture::Inherit, true) => Stdio::inherit(),
185        (_, true) => Stdio::piped(),
186        (_, false) => Stdio::null(),
187    });
188
189    Ok((command, cleanup_token))
190}
191
192/// Record only the non-secret facts needed to diagnose command-resolution
193/// failures. Arguments and the rest of the environment may contain credentials
194/// or user data, so this boundary intentionally logs neither.
195fn log_spawn_context(command: &Command, env_mode: EnvMode) {
196    let program = command.get_program().to_string_lossy();
197    let cwd = command
198        .get_current_dir()
199        .map(std::path::Path::to_path_buf)
200        .or_else(|| std::env::current_dir().ok());
201    let path = resolved_env_value(command, "PATH", env_mode)
202        .map(|value| value.to_string_lossy().into_owned());
203    tracing::debug!(
204        target: "harn_hostlib::process",
205        shell_or_program = %program,
206        cwd = %cwd.as_deref().map_or_else(|| "<unresolved>".into(), std::path::Path::to_string_lossy),
207        path = %path.as_deref().unwrap_or("<unset>"),
208        env_mode = ?env_mode,
209        "resolved command spawn context"
210    );
211}
212
213fn resolved_env_value(
214    command: &Command,
215    name: &str,
216    env_mode: EnvMode,
217) -> Option<std::ffi::OsString> {
218    for (key, value) in command.get_envs() {
219        if env_key_eq(key, name) {
220            return value.map(std::ffi::OsStr::to_os_string);
221        }
222    }
223    if env_mode == EnvMode::Replace {
224        None
225    } else {
226        std::env::var_os(name)
227    }
228}
229
230fn env_key_eq(key: &std::ffi::OsStr, expected: &str) -> bool {
231    #[cfg(windows)]
232    {
233        key.to_string_lossy().eq_ignore_ascii_case(expected)
234    }
235    #[cfg(not(windows))]
236    {
237        key == expected
238    }
239}
240
241fn map_spawn_error(error: io::Error) -> ProcessError {
242    if let Some(violation) = process_sandbox::process_spawn_error(&error) {
243        return ProcessError::SandboxSpawn(format!("{violation:?}"));
244    }
245    ProcessError::Spawn(error.to_string())
246}
247
248/// Replace the current Unix process through the same prepared-command path as
249/// normal hostlib spawns. A successful call never returns.
250#[cfg(unix)]
251pub fn replace_current_process(spec: SpawnSpec) -> Result<std::convert::Infallible, ProcessError> {
252    use std::os::unix::process::CommandExt;
253
254    super::handle::validate_process_spec(&spec)?;
255    let inherited_cleanup_token = std::env::var(harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV)
256        .ok()
257        .filter(|token| !token.is_empty());
258    let (mut command, _cleanup_token) = prepare_command(&spec, inherited_cleanup_token)?;
259    Err(map_spawn_error(command.exec()))
260}
261
262struct RealProcess {
263    pid: u32,
264    pgid: Option<u32>,
265    cleanup_token: String,
266    killer: Arc<dyn ProcessKiller>,
267    child: Option<Child>,
268    stdin: Option<ChildStdin>,
269    stdout: Option<ChildStdout>,
270    stderr: Option<ChildStderr>,
271    stdin_taken: bool,
272    stdout_taken: bool,
273    stderr_taken: bool,
274}
275
276impl RealProcess {
277    fn ensure_pipes_taken(&mut self) {
278        if let Some(child) = self.child.as_mut() {
279            if self.stdin.is_none() && !self.stdin_taken {
280                self.stdin = child.stdin.take();
281            }
282            if self.stdout.is_none() && !self.stdout_taken {
283                self.stdout = child.stdout.take();
284            }
285            if self.stderr.is_none() && !self.stderr_taken {
286                self.stderr = child.stderr.take();
287            }
288        }
289    }
290}
291
292impl ProcessHandle for RealProcess {
293    fn pid(&self) -> Option<u32> {
294        Some(self.pid)
295    }
296
297    fn process_group_id(&self) -> Option<u32> {
298        self.pgid
299    }
300
301    fn killer(&self) -> Arc<dyn ProcessKiller> {
302        Arc::clone(&self.killer)
303    }
304
305    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
306        self.ensure_pipes_taken();
307        self.stdin_taken = true;
308        self.stdin
309            .take()
310            .map(|s| Box::new(s) as Box<dyn Write + Send>)
311    }
312
313    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
314        self.ensure_pipes_taken();
315        self.stdout_taken = true;
316        self.stdout
317            .take()
318            .map(|s| Box::new(s) as Box<dyn Read + Send>)
319    }
320
321    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
322        self.ensure_pipes_taken();
323        self.stderr_taken = true;
324        self.stderr
325            .take()
326            .map(|s| Box::new(s) as Box<dyn Read + Send>)
327    }
328
329    fn wait_with_timeout(
330        &mut self,
331        timeout: Option<Duration>,
332        interrupt: &dyn Fn() -> bool,
333    ) -> io::Result<WaitOutcome> {
334        let killer = Arc::clone(&self.killer);
335        let Some(child) = self.child.as_mut() else {
336            return Err(io::Error::other("child already reaped"));
337        };
338        let deadline = timeout.map(|timeout| Instant::now() + timeout);
339        loop {
340            match child.try_wait()? {
341                Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
342                None => {
343                    if interrupt() {
344                        // Scope cancellation / deadline expiry: graceful
345                        // group termination (SIGTERM, grace, SIGKILL) shared
346                        // with the VM-side `process.*` builtins.
347                        let (_, report) =
348                            harn_vm::op_interrupt::terminate_child_group_with_cleanup_token_report(
349                                child,
350                                Some(&self.cleanup_token),
351                            );
352                        return Ok(WaitOutcome::Interrupted(report));
353                    }
354                    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
355                        // `killer.kill()` kills the process tree/group on
356                        // Unix. That path is a no-op on non-Unix targets, so
357                        // also kill the child handle directly
358                        // (TerminateProcess on Windows) to guarantee the
359                        // subsequent `child.wait()` cannot block forever on a
360                        // timed-out process.
361                        let mut report = killer.kill();
362                        let _ = child.kill();
363                        let _ = child.wait();
364                        report.refresh_survivor_status();
365                        return Ok(WaitOutcome::TimedOut(report));
366                    }
367                    let sleep = deadline
368                        .map(|deadline| deadline.saturating_duration_since(Instant::now()))
369                        .unwrap_or(Duration::MAX)
370                        .min(Duration::from_millis(20));
371                    thread::sleep(sleep);
372                }
373            }
374        }
375    }
376
377    fn wait(&mut self) -> io::Result<ExitStatus> {
378        let child = self
379            .child
380            .as_mut()
381            .ok_or_else(|| io::Error::other("child already reaped"))?;
382        let status = child.wait()?;
383        Ok(decode_status(status))
384    }
385}
386
387struct RealKiller {
388    pid: u32,
389    cleanup_token: String,
390}
391
392impl ProcessKiller for RealKiller {
393    fn kill(&self) -> ProcessCleanupReport {
394        let report = harn_vm::op_interrupt::signal_pid_tree_group_and_token_with_report(
395            self.pid,
396            Some(&self.cleanup_token),
397            9,
398        );
399        #[cfg(target_os = "windows")]
400        terminate_process(self.pid);
401        report
402    }
403}
404
405#[cfg(target_os = "windows")]
406fn terminate_process(pid: u32) {
407    use windows_sys::Win32::Foundation::CloseHandle;
408    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
409
410    let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
411    if handle.is_null() {
412        return;
413    }
414    unsafe {
415        TerminateProcess(handle, 1);
416        CloseHandle(handle);
417    }
418}
419
420#[cfg(unix)]
421fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
422    use std::os::unix::process::ExitStatusExt;
423    if let Some(code) = status.code() {
424        ExitStatus::from_code(code)
425    } else if let Some(sig) = status.signal() {
426        ExitStatus::from_signal(sig)
427    } else {
428        ExitStatus {
429            code: None,
430            signal: None,
431        }
432    }
433}
434
435#[cfg(not(unix))]
436fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
437    ExitStatus::from_code(status.code().unwrap_or(-1))
438}
439
440pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
441    #[cfg(unix)]
442    {
443        extern "C" {
444            fn getpgid(pid: i32) -> i32;
445        }
446        let pgid = unsafe { getpgid(pid as i32) };
447        if pgid > 0 {
448            Some(pgid as u32)
449        } else {
450            None
451        }
452    }
453    #[cfg(not(unix))]
454    {
455        Some(pid)
456    }
457}
458
459pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
460    #[cfg(unix)]
461    unsafe {
462        use std::os::unix::process::CommandExt;
463        command.pre_exec(|| {
464            extern "C" {
465                fn setpgid(pid: i32, pgid: i32) -> i32;
466            }
467            if setpgid(0, 0) == -1 {
468                return Err(std::io::Error::last_os_error());
469            }
470            Ok(())
471        });
472    }
473    #[cfg(not(unix))]
474    {
475        let _ = command;
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn resolved_path_prefers_the_child_override() {
485        let mut command = Command::new("shell");
486        command.env("PATH", "/resolved/toolchain/bin");
487
488        assert_eq!(
489            resolved_env_value(&command, "PATH", EnvMode::Patch),
490            Some(std::ffi::OsString::from("/resolved/toolchain/bin"))
491        );
492    }
493
494    #[test]
495    fn resolved_path_honors_an_explicit_removal() {
496        let mut command = Command::new("shell");
497        command.env_remove("PATH");
498
499        assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Patch), None);
500    }
501
502    #[test]
503    fn replace_mode_does_not_report_an_inherited_path() {
504        let command = Command::new("shell");
505
506        assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Replace), None);
507    }
508}