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::Spawn(error.to_string())
303}
304
305/// Replace the current Unix process through the same prepared-command path as
306/// normal hostlib spawns. A successful call never returns.
307#[cfg(unix)]
308pub fn replace_current_process(spec: SpawnSpec) -> Result<std::convert::Infallible, ProcessError> {
309    use std::os::unix::process::CommandExt;
310
311    super::handle::validate_process_spec(&spec)?;
312    let inherited_cleanup_token = std::env::var(harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV)
313        .ok()
314        .filter(|token| !token.is_empty());
315    let (mut command, _cleanup_token) = prepare_command(&spec, inherited_cleanup_token)?;
316    Err(map_spawn_error(command.exec()))
317}
318
319struct RealProcess {
320    pid: u32,
321    pgid: Option<u32>,
322    cleanup_token: String,
323    killer: Arc<dyn ProcessKiller>,
324    child: Option<Child>,
325    stdin: Option<ChildStdin>,
326    stdout: Option<ChildStdout>,
327    stderr: Option<ChildStderr>,
328    owner_liveness: Option<ChildStdin>,
329    stdin_taken: bool,
330    stdout_taken: bool,
331    stderr_taken: bool,
332}
333
334fn real_process(
335    child: Child,
336    cleanup_token: String,
337    owner_liveness: Option<ChildStdin>,
338    stderr: Option<ChildStderr>,
339    reported_pid: Option<u32>,
340    killer_pid: Option<u32>,
341    #[cfg(target_os = "windows")] owner_job: Option<Arc<super::windows::KillOnCloseJob>>,
342    #[cfg(not(target_os = "windows"))] _owner_job: Option<()>,
343) -> Box<dyn ProcessHandle> {
344    let pid = reported_pid.unwrap_or_else(|| child.id());
345    let pgid = child_process_group_id(pid);
346    let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller {
347        pid: killer_pid.unwrap_or(pid),
348        cleanup_token: cleanup_token.clone(),
349        #[cfg(target_os = "windows")]
350        owner_job,
351    });
352    Box::new(RealProcess {
353        pid,
354        pgid,
355        cleanup_token,
356        killer,
357        child: Some(child),
358        stdin: None,
359        stdout: None,
360        stderr,
361        owner_liveness,
362        stdin_taken: false,
363        stdout_taken: false,
364        stderr_taken: false,
365    })
366}
367
368impl RealProcess {
369    fn ensure_pipes_taken(&mut self) {
370        if let Some(child) = self.child.as_mut() {
371            if self.owner_liveness.is_none() && self.stdin.is_none() && !self.stdin_taken {
372                self.stdin = child.stdin.take();
373            }
374            if self.stdout.is_none() && !self.stdout_taken {
375                self.stdout = child.stdout.take();
376            }
377            if self.stderr.is_none() && !self.stderr_taken {
378                self.stderr = child.stderr.take();
379            }
380        }
381    }
382}
383
384impl ProcessHandle for RealProcess {
385    fn pid(&self) -> Option<u32> {
386        Some(self.pid)
387    }
388
389    fn process_group_id(&self) -> Option<u32> {
390        self.pgid
391    }
392
393    fn killer(&self) -> Arc<dyn ProcessKiller> {
394        Arc::clone(&self.killer)
395    }
396
397    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
398        self.ensure_pipes_taken();
399        self.stdin_taken = true;
400        self.stdin
401            .take()
402            .map(|s| Box::new(s) as Box<dyn Write + Send>)
403    }
404
405    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
406        self.ensure_pipes_taken();
407        self.stdout_taken = true;
408        self.stdout
409            .take()
410            .map(|s| Box::new(s) as Box<dyn Read + Send>)
411    }
412
413    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
414        self.ensure_pipes_taken();
415        self.stderr_taken = true;
416        self.stderr
417            .take()
418            .map(|s| Box::new(s) as Box<dyn Read + Send>)
419    }
420
421    fn wait_with_timeout(
422        &mut self,
423        timeout: Option<Duration>,
424        interrupt: &dyn Fn() -> bool,
425    ) -> io::Result<WaitOutcome> {
426        let killer = Arc::clone(&self.killer);
427        let owner_death_contained = self.owner_liveness.is_some();
428        let Some(child) = self.child.as_mut() else {
429            return Err(io::Error::other("child already reaped"));
430        };
431        let deadline = timeout.map(|timeout| Instant::now() + timeout);
432        loop {
433            match child.try_wait()? {
434                Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
435                None => {
436                    if interrupt() {
437                        if owner_death_contained {
438                            let report = killer.kill();
439                            let _ = child.wait();
440                            return Ok(WaitOutcome::Interrupted(report));
441                        }
442                        // Scope cancellation / deadline expiry: graceful
443                        // group termination (SIGTERM, grace, SIGKILL) shared
444                        // with the VM-side `process.*` builtins.
445                        let (_, report) =
446                            harn_vm::op_interrupt::terminate_child_group_with_cleanup_token_report(
447                                child,
448                                Some(&self.cleanup_token),
449                            );
450                        return Ok(WaitOutcome::Interrupted(report));
451                    }
452                    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
453                        // `killer.kill()` kills the process tree/group on
454                        // Unix. That path is a no-op on non-Unix targets, so
455                        // also kill the child handle directly
456                        // (TerminateProcess on Windows) to guarantee the
457                        // subsequent `child.wait()` cannot block forever on a
458                        // timed-out process.
459                        let mut report = killer.kill();
460                        if !owner_death_contained {
461                            let _ = child.kill();
462                        }
463                        let _ = child.wait();
464                        report.refresh_survivor_status();
465                        return Ok(WaitOutcome::TimedOut(report));
466                    }
467                    let sleep = deadline
468                        .map(|deadline| deadline.saturating_duration_since(Instant::now()))
469                        .unwrap_or(Duration::MAX)
470                        .min(Duration::from_millis(20));
471                    thread::sleep(sleep);
472                }
473            }
474        }
475    }
476
477    fn wait(&mut self) -> io::Result<ExitStatus> {
478        let child = self
479            .child
480            .as_mut()
481            .ok_or_else(|| io::Error::other("child already reaped"))?;
482        let status = child.wait()?;
483        Ok(decode_status(status))
484    }
485}
486
487struct RealKiller {
488    pid: u32,
489    cleanup_token: String,
490    #[cfg(target_os = "windows")]
491    owner_job: Option<Arc<super::windows::KillOnCloseJob>>,
492}
493
494impl ProcessKiller for RealKiller {
495    fn kill(&self) -> ProcessCleanupReport {
496        let report = harn_vm::op_interrupt::signal_pid_tree_group_and_token_with_report(
497            self.pid,
498            Some(&self.cleanup_token),
499            9,
500        );
501        #[cfg(target_os = "windows")]
502        if let Some(job) = &self.owner_job {
503            let _ = job.terminate();
504        } else {
505            terminate_process(self.pid);
506        }
507        report
508    }
509}
510
511#[cfg(target_os = "windows")]
512fn terminate_process(pid: u32) {
513    use windows_sys::Win32::Foundation::CloseHandle;
514    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
515
516    let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
517    if handle.is_null() {
518        return;
519    }
520    unsafe {
521        TerminateProcess(handle, 1);
522        CloseHandle(handle);
523    }
524}
525
526#[cfg(unix)]
527fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
528    use std::os::unix::process::ExitStatusExt;
529    if let Some(code) = status.code() {
530        ExitStatus::from_code(code)
531    } else if let Some(sig) = status.signal() {
532        ExitStatus::from_signal(sig)
533    } else {
534        ExitStatus {
535            code: None,
536            signal: None,
537        }
538    }
539}
540
541#[cfg(not(unix))]
542fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
543    ExitStatus::from_code(status.code().unwrap_or(-1))
544}
545
546pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
547    #[cfg(unix)]
548    {
549        extern "C" {
550            fn getpgid(pid: i32) -> i32;
551        }
552        let pgid = unsafe { getpgid(pid as i32) };
553        if pgid > 0 {
554            Some(pgid as u32)
555        } else {
556            None
557        }
558    }
559    #[cfg(not(unix))]
560    {
561        Some(pid)
562    }
563}
564
565pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
566    #[cfg(unix)]
567    {
568        use std::os::unix::process::CommandExt;
569        command.process_group(0);
570    }
571    #[cfg(not(unix))]
572    {
573        let _ = command;
574    }
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580
581    #[test]
582    fn resolved_path_prefers_the_child_override() {
583        let mut command = Command::new("shell");
584        command.env("PATH", "/resolved/toolchain/bin");
585
586        assert_eq!(
587            resolved_env_value(&command, "PATH", EnvMode::Patch),
588            Some(std::ffi::OsString::from("/resolved/toolchain/bin"))
589        );
590    }
591
592    #[test]
593    fn resolved_path_honors_an_explicit_removal() {
594        let mut command = Command::new("shell");
595        command.env_remove("PATH");
596
597        assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Patch), None);
598    }
599
600    #[test]
601    fn replace_mode_does_not_report_an_inherited_path() {
602        let command = Command::new("shell");
603
604        assert_eq!(resolved_env_value(&command, "PATH", EnvMode::Replace), None);
605    }
606}