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