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