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