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        let (mut command, cleanup_token) = prepare_command(&spec, None)?;
32        let child = command.spawn().map_err(map_spawn_error)?;
33
34        let pid = child.id();
35        let pgid = child_process_group_id(pid);
36        let killer: Arc<dyn ProcessKiller> = Arc::new(RealKiller {
37            pid,
38            cleanup_token: cleanup_token.clone(),
39        });
40
41        Ok(Box::new(RealProcess {
42            pid,
43            pgid,
44            cleanup_token,
45            killer,
46            child: Some(child),
47            stdin: None,
48            stdout: None,
49            stderr: None,
50            stdin_taken: false,
51            stdout_taken: false,
52            stderr_taken: false,
53        }))
54    }
55}
56
57fn prepare_command(
58    spec: &SpawnSpec,
59    cleanup_token: Option<String>,
60) -> Result<(Command, String), ProcessError> {
61    if spec.program.is_empty() {
62        return Err(ProcessError::InvalidArgv(
63            "first element of argv must be a non-empty program name".to_string(),
64        ));
65    }
66
67    let mut command = process_sandbox::std_command_for(&spec.program, &spec.args)
68        .map_err(|e| ProcessError::SandboxSetup(format!("{e:?}")))?;
69
70    if let Some(cwd) = spec.cwd.as_ref() {
71        process_sandbox::enforce_process_cwd(cwd)
72            .map_err(|e| ProcessError::SandboxCwd(format!("{e:?}")))?;
73        command.current_dir(cwd);
74    }
75
76    match spec.env_mode {
77        // `Replace` starts from an empty environment, so nothing to strip.
78        EnvMode::Replace => {
79            command.env_clear();
80        }
81        // `InheritClean`/`Patch` inherit the full parent environment. Strip
82        // secret-bearing variables (provider `*_API_KEY`s, `GITHUB_TOKEN`,
83        // `HARN_CLOUD_API_KEY`, etc.) so build/test commands — and the model
84        // that reads their stdout as the tool result — never see them.
85        // Caller-supplied `env` below is applied afterward and is an
86        // explicit opt-in, so it is intentionally not filtered here.
87        EnvMode::InheritClean | EnvMode::Patch => {
88            for (key, _) in std::env::vars_os() {
89                if let Some(name) = key.to_str() {
90                    if super::handle::is_sensitive_env_name(name) {
91                        command.env_remove(&key);
92                    }
93                }
94            }
95        }
96    }
97    // Caller-requested inherited-env strips (e.g. a harness spawning a
98    // child harn/burin process that must not write into the parent's
99    // event-log or transcript dirs). Applied before `spec.env`, so an
100    // explicitly supplied override still wins.
101    for key in &spec.env_remove {
102        command.env_remove(key);
103    }
104    for (key, value) in &spec.env {
105        command.env(key, value);
106    }
107
108    // Point the child's temp dir at a sandbox-writable, workspace-local
109    // location so compiler linkers (rustc/cc/ld, Go, Swift, …) and other
110    // toolchains that honor TMPDIR/TMP/TEMP don't false-fail trying to write
111    // intermediates to the unwritable system /tmp under a restricted
112    // sandbox profile. Applied after the caller's `spec.env` so an explicit
113    // caller-set TMPDIR wins; only keys the caller did not set receive the
114    // overlay. No-op when the active profile is unrestricted or no writable
115    // workspace root is available. TMPDIR/TMP/TEMP are workspace paths, not
116    // secrets, so this does not widen the env-secret-scrub surface above.
117    for (key, value) in process_sandbox::active_workspace_tmpdir_env() {
118        if spec.env.contains_key(&key) {
119            continue;
120        }
121        command.env(key, value);
122    }
123
124    // Pin tool *message* output to a deterministic English/UTF-8 locale so
125    // downstream English-diagnostic matchers (deterministic syntax repair,
126    // error-signature grounding, completion/pass-fail classification) do not
127    // misfire for a non-Anglosphere user whose shell localizes compiler/test
128    // output. A user-inherited `LC_ALL` overrides `LC_MESSAGES`, so strip it
129    // first — unless the caller pinned it. Then apply the overlay with the
130    // same caller-wins rule as the TMPDIR overlay above.
131    if !spec
132        .env
133        .contains_key(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV)
134    {
135        command.env_remove(process_sandbox::MESSAGE_LOCALE_OVERRIDE_ENV);
136    }
137    for (key, value) in process_sandbox::deterministic_message_locale_env() {
138        if spec.env.contains_key(&key) {
139            continue;
140        }
141        command.env(key, value);
142    }
143
144    if spec.configure_process_group {
145        configure_background_process_group(&mut command);
146    }
147    let cleanup_token =
148        cleanup_token.unwrap_or_else(harn_vm::op_interrupt::new_process_cleanup_token);
149    command.env(
150        harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
151        &cleanup_token,
152    );
153
154    match &spec.output_capture {
155        OutputCapture::Inherit => {
156            command.stdout(Stdio::inherit());
157            command.stderr(Stdio::inherit());
158        }
159        OutputCapture::Pipe => {
160            command.stdout(Stdio::piped());
161            command.stderr(Stdio::piped());
162        }
163        OutputCapture::File {
164            stdout_path,
165            stderr_path,
166        } => {
167            let stdout = OpenOptions::new()
168                .write(true)
169                .truncate(true)
170                .open(stdout_path)
171                .map_err(|error| ProcessError::Spawn(format!("open stdout capture: {error}")))?;
172            let stderr = OpenOptions::new()
173                .write(true)
174                .truncate(true)
175                .open(stderr_path)
176                .map_err(|error| ProcessError::Spawn(format!("open stderr capture: {error}")))?;
177            command.stdout(Stdio::from(stdout));
178            command.stderr(Stdio::from(stderr));
179        }
180    }
181    command.stdin(match (&spec.output_capture, spec.use_stdin) {
182        (OutputCapture::Inherit, true) => Stdio::inherit(),
183        (_, true) => Stdio::piped(),
184        (_, false) => Stdio::null(),
185    });
186
187    Ok((command, cleanup_token))
188}
189
190fn map_spawn_error(error: io::Error) -> ProcessError {
191    if let Some(violation) = process_sandbox::process_spawn_error(&error) {
192        return ProcessError::SandboxSpawn(format!("{violation:?}"));
193    }
194    ProcessError::Spawn(error.to_string())
195}
196
197/// Replace the current Unix process through the same prepared-command path as
198/// normal hostlib spawns. A successful call never returns.
199#[cfg(unix)]
200pub fn replace_current_process(spec: SpawnSpec) -> Result<std::convert::Infallible, ProcessError> {
201    use std::os::unix::process::CommandExt;
202
203    super::handle::validate_process_spec(&spec)?;
204    let inherited_cleanup_token = std::env::var(harn_vm::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV)
205        .ok()
206        .filter(|token| !token.is_empty());
207    let (mut command, _cleanup_token) = prepare_command(&spec, inherited_cleanup_token)?;
208    Err(map_spawn_error(command.exec()))
209}
210
211struct RealProcess {
212    pid: u32,
213    pgid: Option<u32>,
214    cleanup_token: String,
215    killer: Arc<dyn ProcessKiller>,
216    child: Option<Child>,
217    stdin: Option<ChildStdin>,
218    stdout: Option<ChildStdout>,
219    stderr: Option<ChildStderr>,
220    stdin_taken: bool,
221    stdout_taken: bool,
222    stderr_taken: bool,
223}
224
225impl RealProcess {
226    fn ensure_pipes_taken(&mut self) {
227        if let Some(child) = self.child.as_mut() {
228            if self.stdin.is_none() && !self.stdin_taken {
229                self.stdin = child.stdin.take();
230            }
231            if self.stdout.is_none() && !self.stdout_taken {
232                self.stdout = child.stdout.take();
233            }
234            if self.stderr.is_none() && !self.stderr_taken {
235                self.stderr = child.stderr.take();
236            }
237        }
238    }
239}
240
241impl ProcessHandle for RealProcess {
242    fn pid(&self) -> Option<u32> {
243        Some(self.pid)
244    }
245
246    fn process_group_id(&self) -> Option<u32> {
247        self.pgid
248    }
249
250    fn killer(&self) -> Arc<dyn ProcessKiller> {
251        Arc::clone(&self.killer)
252    }
253
254    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>> {
255        self.ensure_pipes_taken();
256        self.stdin_taken = true;
257        self.stdin
258            .take()
259            .map(|s| Box::new(s) as Box<dyn Write + Send>)
260    }
261
262    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>> {
263        self.ensure_pipes_taken();
264        self.stdout_taken = true;
265        self.stdout
266            .take()
267            .map(|s| Box::new(s) as Box<dyn Read + Send>)
268    }
269
270    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>> {
271        self.ensure_pipes_taken();
272        self.stderr_taken = true;
273        self.stderr
274            .take()
275            .map(|s| Box::new(s) as Box<dyn Read + Send>)
276    }
277
278    fn wait_with_timeout(
279        &mut self,
280        timeout: Option<Duration>,
281        interrupt: &dyn Fn() -> bool,
282    ) -> io::Result<WaitOutcome> {
283        let killer = Arc::clone(&self.killer);
284        let Some(child) = self.child.as_mut() else {
285            return Err(io::Error::other("child already reaped"));
286        };
287        let deadline = timeout.map(|timeout| Instant::now() + timeout);
288        loop {
289            match child.try_wait()? {
290                Some(status) => return Ok(WaitOutcome::Exited(decode_status(status))),
291                None => {
292                    if interrupt() {
293                        // Scope cancellation / deadline expiry: graceful
294                        // group termination (SIGTERM, grace, SIGKILL) shared
295                        // with the VM-side `process.*` builtins.
296                        let (_, report) =
297                            harn_vm::op_interrupt::terminate_child_group_with_cleanup_token_report(
298                                child,
299                                Some(&self.cleanup_token),
300                            );
301                        return Ok(WaitOutcome::Interrupted(report));
302                    }
303                    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
304                        // `killer.kill()` kills the process tree/group on
305                        // Unix. That path is a no-op on non-Unix targets, so
306                        // also kill the child handle directly
307                        // (TerminateProcess on Windows) to guarantee the
308                        // subsequent `child.wait()` cannot block forever on a
309                        // timed-out process.
310                        let mut report = killer.kill();
311                        let _ = child.kill();
312                        let _ = child.wait();
313                        report.refresh_survivor_status();
314                        return Ok(WaitOutcome::TimedOut(report));
315                    }
316                    let sleep = deadline
317                        .map(|deadline| deadline.saturating_duration_since(Instant::now()))
318                        .unwrap_or(Duration::MAX)
319                        .min(Duration::from_millis(20));
320                    thread::sleep(sleep);
321                }
322            }
323        }
324    }
325
326    fn wait(&mut self) -> io::Result<ExitStatus> {
327        let child = self
328            .child
329            .as_mut()
330            .ok_or_else(|| io::Error::other("child already reaped"))?;
331        let status = child.wait()?;
332        Ok(decode_status(status))
333    }
334}
335
336struct RealKiller {
337    pid: u32,
338    cleanup_token: String,
339}
340
341impl ProcessKiller for RealKiller {
342    fn kill(&self) -> ProcessCleanupReport {
343        let report = harn_vm::op_interrupt::signal_pid_tree_group_and_token_with_report(
344            self.pid,
345            Some(&self.cleanup_token),
346            9,
347        );
348        #[cfg(target_os = "windows")]
349        terminate_process(self.pid);
350        report
351    }
352}
353
354#[cfg(target_os = "windows")]
355fn terminate_process(pid: u32) {
356    use windows_sys::Win32::Foundation::CloseHandle;
357    use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE};
358
359    let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) };
360    if handle.is_null() {
361        return;
362    }
363    unsafe {
364        TerminateProcess(handle, 1);
365        CloseHandle(handle);
366    }
367}
368
369#[cfg(unix)]
370fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
371    use std::os::unix::process::ExitStatusExt;
372    if let Some(code) = status.code() {
373        ExitStatus::from_code(code)
374    } else if let Some(sig) = status.signal() {
375        ExitStatus::from_signal(sig)
376    } else {
377        ExitStatus {
378            code: None,
379            signal: None,
380        }
381    }
382}
383
384#[cfg(not(unix))]
385fn decode_status(status: std::process::ExitStatus) -> ExitStatus {
386    ExitStatus::from_code(status.code().unwrap_or(-1))
387}
388
389pub(crate) fn child_process_group_id(pid: u32) -> Option<u32> {
390    #[cfg(unix)]
391    {
392        extern "C" {
393            fn getpgid(pid: i32) -> i32;
394        }
395        let pgid = unsafe { getpgid(pid as i32) };
396        if pgid > 0 {
397            Some(pgid as u32)
398        } else {
399            None
400        }
401    }
402    #[cfg(not(unix))]
403    {
404        Some(pid)
405    }
406}
407
408pub(crate) fn configure_background_process_group(command: &mut std::process::Command) {
409    #[cfg(unix)]
410    unsafe {
411        use std::os::unix::process::CommandExt;
412        command.pre_exec(|| {
413            extern "C" {
414                fn setpgid(pid: i32, pgid: i32) -> i32;
415            }
416            if setpgid(0, 0) == -1 {
417                return Err(std::io::Error::last_os_error());
418            }
419            Ok(())
420        });
421    }
422    #[cfg(not(unix))]
423    {
424        let _ = command;
425    }
426}