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        #[cfg(unix)]
32        if spec.owner_death == super::OwnerDeathPolicy::KillContainment {
33            if spec.use_stdin {
34                return Err(ProcessError::InvalidArgv(
35                    "owner-death containment reserves stdin for the liveness pipe".to_string(),
36                ));
37            }
38            if !matches!(spec.output_capture, OutputCapture::Pipe) {
39                return Err(ProcessError::InvalidArgv(
40                    "owner-death containment requires piped output".to_string(),
41                ));
42            }
43            if !spec.configure_process_group {
44                return Err(ProcessError::InvalidArgv(
45                    "owner-death containment requires an independent process group".to_string(),
46                ));
47            }
48            let cleanup_token = harn_vm::op_interrupt::new_process_cleanup_token();
49            let mut command = super::owner_death::prepare_guardian(&spec, cleanup_token.clone())?;
50            let mut child = match command.spawn() {
51                Ok(child) => child,
52                Err(error) => {
53                    harn_vm::op_interrupt::remove_process_owner_group_journal(&cleanup_token);
54                    return Err(map_spawn_error(error));
55                }
56            };
57            if let Err(error) =
58                harn_vm::op_interrupt::record_current_process_owner_group(child.id())
59            {
60                let _ = harn_vm::op_interrupt::signal_pid_tree_and_group_with_report(child.id(), 9);
61                let _ = child.wait();
62                harn_vm::op_interrupt::remove_process_owner_group_journal(&cleanup_token);
63                return Err(ProcessError::Spawn(format!(
64                    "record guardian owner group: {error}"
65                )));
66            }
67            let liveness = match child.stdin.take() {
68                Some(liveness) => liveness,
69                None => {
70                    let _ =
71                        harn_vm::op_interrupt::signal_pid_tree_and_group_with_report(child.id(), 9);
72                    let _ = child.wait();
73                    harn_vm::op_interrupt::remove_process_owner_group_journal(&cleanup_token);
74                    return Err(ProcessError::Spawn(
75                        "guardian liveness pipe was not created".to_string(),
76                    ));
77                }
78            };
79            let (stderr, guardian_pid, payload_pid) =
80                match super::owner_death::await_startup(&mut child) {
81                    Ok(startup) => startup,
82                    Err(error) => {
83                        let _ = harn_vm::op_interrupt::signal_pid_tree_and_group_with_report(
84                            child.id(),
85                            9,
86                        );
87                        let _ = child.wait();
88                        harn_vm::op_interrupt::remove_process_owner_group_journal(&cleanup_token);
89                        return Err(error);
90                    }
91                };
92            return Ok(real_process(
93                child,
94                cleanup_token,
95                Some(liveness),
96                Some(stderr),
97                Some(guardian_pid),
98                Some(payload_pid),
99                None,
100            ));
101        }
102
103        let (mut command, cleanup_token) = prepare_command(&spec, None)?;
104        #[cfg(target_os = "windows")]
105        let owner_job = if spec.owner_death == super::OwnerDeathPolicy::KillContainment
106            || spec.configure_process_group
107        {
108            let job = super::windows::KillOnCloseJob::new().map_err(|error| {
109                ProcessError::Spawn(format!("create process-tree Job Object: {error}"))
110            })?;
111            super::windows::configure_suspended(&mut command);
112            Some(Arc::new(job))
113        } else {
114            None
115        };
116        #[cfg(not(target_os = "windows"))]
117        let owner_job = None;
118        let mut child = command.spawn().map_err(map_spawn_error)?;
119        if let Err(error) = harn_vm::op_interrupt::record_current_process_owner_group(child.id()) {
120            let _ = harn_vm::op_interrupt::signal_pid_tree_and_group_with_report(child.id(), 9);
121            let _ = child.wait();
122            return Err(ProcessError::Spawn(format!(
123                "record process owner group: {error}"
124            )));
125        }
126
127        #[cfg(target_os = "windows")]
128        if let Some(job) = &owner_job {
129            if let Err(error) = job
130                .assign_process(child.id())
131                .and_then(|()| super::windows::resume_process(child.id()))
132            {
133                let mut child = child;
134                let _ = job.terminate();
135                let _ = child.kill();
136                let _ = child.wait();
137                return Err(ProcessError::Spawn(format!(
138                    "contain suspended worker in owner Job Object: {error}"
139                )));
140            }
141        }
142
143        Ok(real_process(
144            child,
145            cleanup_token,
146            None,
147            None,
148            None,
149            None,
150            owner_job,
151        ))
152    }
153}
154
155pub(crate) fn prepare_command(
156    spec: &SpawnSpec,
157    cleanup_token: Option<String>,
158) -> Result<(Command, String), ProcessError> {
159    if spec.program.is_empty() {
160        return Err(ProcessError::InvalidArgv(
161            "first element of argv must be a non-empty program name".to_string(),
162        ));
163    }
164
165    let mut command = process_sandbox::std_command_for(&spec.program, &spec.args)
166        .map_err(|e| ProcessError::SandboxSetup(format!("{e:?}")))?;
167
168    if let Some(cwd) = spec.cwd.as_ref() {
169        process_sandbox::enforce_process_cwd(cwd)
170            .map_err(|e| ProcessError::SandboxCwd(format!("{e:?}")))?;
171        command.current_dir(cwd);
172    }
173
174    match spec.env_mode {
175        // `Replace` starts from an empty environment, so nothing to strip.
176        EnvMode::Replace => {
177            command.env_clear();
178        }
179        // `InheritClean`/`Patch` inherit the full parent environment. Strip
180        // secret-bearing variables (provider `*_API_KEY`s, `GITHUB_TOKEN`,
181        // `HARN_CLOUD_API_KEY`, etc.) so build/test commands — and the model
182        // that reads their stdout as the tool result — never see them.
183        // Caller-supplied `env` below is applied afterward and is an
184        // explicit opt-in, so it is intentionally not filtered here.
185        EnvMode::InheritClean | EnvMode::Patch => {
186            for (key, _) in std::env::vars_os() {
187                if let Some(name) = key.to_str() {
188                    if super::handle::is_sensitive_env_name(name) {
189                        command.env_remove(&key);
190                    }
191                }
192            }
193        }
194    }
195    // Caller-requested inherited-env strips (e.g. a harness spawning a
196    // child harn/burin process that must not write into the parent's
197    // event-log or transcript dirs). Applied before `spec.env`, so an
198    // explicitly supplied override still wins.
199    for key in &spec.env_remove {
200        command.env_remove(key);
201    }
202    for (key, value) in &spec.env {
203        command.env(key, value);
204    }
205
206    // Give the child workspace-local temp, home, and toolchain-cache paths.
207    // Applied after `spec.env`; caller-set keys win. The values are workspace
208    // paths, not secrets, so this does not widen the scrub surface above.
209    for (key, value) in process_sandbox::active_workspace_process_env() {
210        if spec.env.contains_key(&key) {
211            continue;
212        }
213        command.env(key, value);
214    }
215
216    // Pin tool *message* output to a deterministic English/UTF-8 locale so
217    // downstream English-diagnostic matchers (deterministic syntax repair,
218    // error-signature grounding, completion/pass-fail classification) do not
219    // misfire for a non-Anglosphere user whose shell localizes compiler/test
220    // output. A user-inherited `LC_ALL` overrides `LC_MESSAGES`, so strip it
221    // first — unless the caller pinned it. Then apply the overlay with the
222    // same caller-wins rule as the TMPDIR overlay above.
223    if !spec
224        .env
225        .contains_key(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV)
226    {
227        command.env_remove(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
228    }
229    for (key, value) in process_sandbox::deterministic_message_locale_env() {
230        if spec.env.contains_key(&key) {
231            continue;
232        }
233        command.env(key, value);
234    }
235
236    log_spawn_context(&command, spec.env_mode);
237
238    if spec.configure_process_group {
239        configure_background_process_group(&mut command);
240    }
241    let cleanup_token =
242        cleanup_token.unwrap_or_else(harn_vm::op_interrupt::new_process_cleanup_token);
243    command.env(
244        harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
245        &cleanup_token,
246    );
247    harn_vm::op_interrupt::preserve_process_owner_token(&mut command);
248
249    match &spec.output_capture {
250        OutputCapture::Inherit => {
251            command.stdout(Stdio::inherit());
252            command.stderr(Stdio::inherit());
253        }
254        OutputCapture::Pipe => {
255            command.stdout(Stdio::piped());
256            command.stderr(Stdio::piped());
257        }
258        OutputCapture::File {
259            stdout_path,
260            stderr_path,
261        } => {
262            let stdout = OpenOptions::new()
263                .write(true)
264                .truncate(true)
265                .open(stdout_path)
266                .map_err(|error| ProcessError::Spawn(format!("open stdout capture: {error}")))?;
267            let stderr = OpenOptions::new()
268                .write(true)
269                .truncate(true)
270                .open(stderr_path)
271                .map_err(|error| ProcessError::Spawn(format!("open stderr capture: {error}")))?;
272            command.stdout(Stdio::from(stdout));
273            command.stderr(Stdio::from(stderr));
274        }
275    }
276    command.stdin(match (&spec.output_capture, spec.use_stdin) {
277        (OutputCapture::Inherit, true) => Stdio::inherit(),
278        (_, true) => Stdio::piped(),
279        (_, false) => Stdio::null(),
280    });
281
282    Ok((command, cleanup_token))
283}
284
285/// Record only the non-secret facts needed to diagnose command-resolution
286/// failures. Arguments and the rest of the environment may contain credentials
287/// or user data, so this boundary intentionally logs neither.
288fn log_spawn_context(command: &Command, env_mode: EnvMode) {
289    let program = command.get_program().to_string_lossy();
290    let cwd = command
291        .get_current_dir()
292        .map(std::path::Path::to_path_buf)
293        .or_else(|| std::env::current_dir().ok());
294    let path = resolved_env_value(command, "PATH", env_mode)
295        .map(|value| value.to_string_lossy().into_owned());
296    tracing::debug!(
297        target: "harn_hostlib::process",
298        shell_or_program = %program,
299        cwd = %cwd.as_deref().map_or_else(|| "<unresolved>".into(), std::path::Path::to_string_lossy),
300        path = %path.as_deref().unwrap_or("<unset>"),
301        env_mode = ?env_mode,
302        "resolved command spawn context"
303    );
304}
305
306fn resolved_env_value(
307    command: &Command,
308    name: &str,
309    env_mode: EnvMode,
310) -> Option<std::ffi::OsString> {
311    for (key, value) in command.get_envs() {
312        if env_key_eq(key, name) {
313            return value.map(std::ffi::OsStr::to_os_string);
314        }
315    }
316    if env_mode == EnvMode::Replace {
317        None
318    } else {
319        std::env::var_os(name)
320    }
321}
322
323fn env_key_eq(key: &std::ffi::OsStr, expected: &str) -> bool {
324    #[cfg(windows)]
325    {
326        key.to_string_lossy().eq_ignore_ascii_case(expected)
327    }
328    #[cfg(not(windows))]
329    {
330        key == expected
331    }
332}
333
334fn map_spawn_error(error: io::Error) -> ProcessError {
335    if let Some(violation) = process_sandbox::process_spawn_error(&error) {
336        return ProcessError::SandboxSpawn(format!("{violation:?}"));
337    }
338    ProcessError::SpawnIo {
339        kind: harn_vm::value::io_error_kind_str(&error),
340        message: error.to_string(),
341    }
342}
343
344/// Replace the current Unix process through the same prepared-command path as
345/// normal hostlib spawns. A successful call never returns.
346#[cfg(unix)]
347pub fn replace_current_process(spec: SpawnSpec) -> Result<std::convert::Infallible, ProcessError> {
348    use std::os::unix::process::CommandExt;
349
350    super::handle::validate_process_spec(&spec)?;
351    let inherited_cleanup_token = std::env::var(harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV)
352        .ok()
353        .filter(|token| !token.is_empty());
354    let (mut command, _cleanup_token) = prepare_command(&spec, inherited_cleanup_token)?;
355    Err(map_spawn_error(command.exec()))
356}
357
358struct RealProcess {
359    pid: u32,
360    pgid: Option<u32>,
361    #[cfg(not(target_os = "windows"))]
362    cleanup_token: String,
363    killer: Arc<dyn ProcessKiller>,
364    child: Option<Child>,
365    stdin: Option<ChildStdin>,
366    stdout: Option<ChildStdout>,
367    stderr: Option<ChildStderr>,
368    owner_liveness: Option<ChildStdin>,
369    stdin_taken: bool,
370    stdout_taken: bool,
371    stderr_taken: bool,
372}
373
374fn real_process(
375    child: Child,
376    cleanup_token: String,
377    owner_liveness: Option<ChildStdin>,
378    stderr: Option<ChildStderr>,
379    reported_pid: Option<u32>,
380    killer_pid: Option<u32>,
381    #[cfg(target_os = "windows")] owner_job: Option<Arc<super::windows::KillOnCloseJob>>,
382    #[cfg(not(target_os = "windows"))] _owner_job: Option<()>,
383) -> Box<dyn ProcessHandle> {
384    let pid = reported_pid.unwrap_or_else(|| child.id());
385    let pgid = child_process_group_id(pid);
386    let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller {
387        pid: killer_pid.unwrap_or(pid),
388        cleanup_token: cleanup_token.clone(),
389        #[cfg(target_os = "windows")]
390        owner_job,
391    });
392    Box::new(RealProcess {
393        pid,
394        pgid,
395        #[cfg(not(target_os = "windows"))]
396        cleanup_token,
397        killer,
398        child: Some(child),
399        stdin: None,
400        stdout: None,
401        stderr,
402        owner_liveness,
403        stdin_taken: false,
404        stdout_taken: false,
405        stderr_taken: false,
406    })
407}
408
409impl RealProcess {
410    fn ensure_pipes_taken(&mut self) {
411        if let Some(child) = self.child.as_mut() {
412            if self.owner_liveness.is_none() && self.stdin.is_none() && !self.stdin_taken {
413                self.stdin = child.stdin.take();
414            }
415            if self.stdout.is_none() && !self.stdout_taken {
416                self.stdout = child.stdout.take();
417            }
418            if self.stderr.is_none() && !self.stderr_taken {
419                self.stderr = child.stderr.take();
420            }
421        }
422    }
423}
424
425impl ProcessHandle for RealProcess {
426    fn pid(&self) -> Option<u32> {
427        Some(self.pid)
428    }
429
430    fn process_group_id(&self) -> Option<u32> {
431        self.pgid
432    }
433
434    fn killer(&self) -> Arc<dyn ProcessKiller> {
435        Arc::clone(&self.killer)
436    }
437
438    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
439        self.ensure_pipes_taken();
440        self.stdin_taken = true;
441        self.stdin
442            .take()
443            .map(|s| Box::new(s) as Box<dyn Write + Send>)
444    }
445
446    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
447        self.ensure_pipes_taken();
448        self.stdout_taken = true;
449        self.stdout
450            .take()
451            .map(|s| Box::new(s) as Box<dyn Read + Send>)
452    }
453
454    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
455        self.ensure_pipes_taken();
456        self.stderr_taken = true;
457        self.stderr
458            .take()
459            .map(|s| Box::new(s) as Box<dyn Read + Send>)
460    }
461
462    fn wait_with_timeout(
463        &mut self,
464        timeout: Option<Duration>,
465        interrupt: &dyn Fn() -> bool,
466    ) -> io::Result<WaitOutcome> {
467        let killer = Arc::clone(&self.killer);
468        let owner_death_contained = self.owner_liveness.is_some();
469        let Some(child) = self.child.as_mut() else {
470            return Err(io::Error::other("child already reaped"));
471        };
472        let deadline = timeout.map(|timeout| Instant::now() + timeout);
473        loop {
474            match child.try_wait()? {
475                Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
476                None => {
477                    if interrupt() {
478                        if owner_death_contained {
479                            let report = killer.kill();
480                            let _ = child.wait();
481                            return Ok(WaitOutcome::Interrupted(report));
482                        }
483                        #[cfg(target_os = "windows")]
484                        {
485                            // Ordinary Windows command trees are enrolled in
486                            // a Job Object before their first instruction. A
487                            // cooperative interrupt must terminate that job;
488                            // `Child::kill` reaches only the direct process.
489                            let mut report = killer.kill();
490                            let _ = child.kill();
491                            let _ = child.wait();
492                            report.refresh_survivor_status();
493                            return Ok(WaitOutcome::Interrupted(report));
494                        }
495                        // Scope cancellation / deadline expiry: graceful
496                        // group termination (SIGTERM, grace, SIGKILL) shared
497                        // with the VM-side `process.*` builtins.
498                        #[cfg(not(target_os = "windows"))]
499                        let (_, report) =
500                            harn_vm::op_interrupt::terminate_child_group_with_cleanup_token_report(
501                                child,
502                                Some(&self.cleanup_token),
503                            );
504                        #[cfg(not(target_os = "windows"))]
505                        return Ok(WaitOutcome::Interrupted(report));
506                    }
507                    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
508                        // `killer.kill()` kills the process tree/group on
509                        // Unix. That path is a no-op on non-Unix targets, so
510                        // also kill the child handle directly
511                        // (TerminateProcess on Windows) to guarantee the
512                        // subsequent `child.wait()` cannot block forever on a
513                        // timed-out process.
514                        let mut report = killer.kill();
515                        if !owner_death_contained {
516                            let _ = child.kill();
517                        }
518                        let _ = child.wait();
519                        report.refresh_survivor_status();
520                        return Ok(WaitOutcome::TimedOut(report));
521                    }
522                    let sleep = deadline
523                        .map(|deadline| deadline.saturating_duration_since(Instant::now()))
524                        .unwrap_or(Duration::MAX)
525                        .min(Duration::from_millis(20));
526                    thread::sleep(sleep);
527                }
528            }
529        }
530    }
531
532    fn wait(&mut self) -> io::Result<ExitStatus> {
533        let child = self
534            .child
535            .as_mut()
536            .ok_or_else(|| io::Error::other("child already reaped"))?;
537        let status = child.wait()?;
538        Ok(decode_status(status))
539    }
540}
541
542struct RealKiller {
543    pid: u32,
544    cleanup_token: String,
545    #[cfg(target_os = "windows")]
546    owner_job: Option<Arc<super::windows::KillOnCloseJob>>,
547}
548
549impl ProcessKiller for RealKiller {
550    fn kill(&self) -> ProcessCleanupReport {
551        let report = harn_vm::op_interrupt::signal_pid_tree_group_and_token_with_report(
552            self.pid,
553            Some(&self.cleanup_token),
554            9,
555        );
556        #[cfg(target_os = "windows")]
557        if let Some(job) = &self.owner_job {
558            let _ = job.terminate();
559        } else {
560            terminate_process(self.pid);
561        }
562        report
563    }
564}
565
566#[cfg(target_os = "windows")]
567fn terminate_process(pid: u32) {
568    use windows_sys::Win32::Foundation::CloseHandle;
569    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
570
571    let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
572    if handle.is_null() {
573        return;
574    }
575    unsafe {
576        TerminateProcess(handle, 1);
577        CloseHandle(handle);
578    }
579}
580
581#[cfg(unix)]
582fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
583    use std::os::unix::process::ExitStatusExt;
584    if let Some(code) = status.code() {
585        ExitStatus::from_code(code)
586    } else if let Some(sig) = status.signal() {
587        ExitStatus::from_signal(sig)
588    } else {
589        ExitStatus {
590            code: None,
591            signal: None,
592        }
593    }
594}
595
596#[cfg(not(unix))]
597fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
598    ExitStatus::from_code(status.code().unwrap_or(-1))
599}
600
601pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
602    #[cfg(unix)]
603    {
604        extern "C" {
605            fn getpgid(pid: i32) -> i32;
606        }
607        let pgid = unsafe { getpgid(pid as i32) };
608        if pgid > 0 {
609            Some(pgid as u32)
610        } else {
611            None
612        }
613    }
614    #[cfg(not(unix))]
615    {
616        Some(pid)
617    }
618}
619
620pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
621    #[cfg(unix)]
622    {
623        use std::os::unix::process::CommandExt;
624        command.process_group(0);
625    }
626    #[cfg(not(unix))]
627    {
628        let _ = command;
629    }
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635
636    #[test]
637    fn resolved_path_prefers_the_child_override() {
638        let mut command = Command::new("shell");
639        command.env("PATH", "/resolved/toolchain/bin");
640
641        assert_eq!(
642            resolved_env_value(&command, "PATH", EnvMode::Patch),
643            Some(std::ffi::OsString::from("/resolved/toolchain/bin"))
644        );
645    }
646
647    #[test]
648    fn resolved_path_honors_an_explicit_removal() {
649        let mut command = Command::new("shell");
650        command.env_remove("PATH");
651
652        assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Patch), None);
653    }
654
655    #[test]
656    fn replace_mode_does_not_report_an_inherited_path() {
657        let command = Command::new("shell");
658
659        assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Replace), None);
660    }
661}