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