Skip to main content

rpi_tools/
os_env.rs

1//! Mirrors `packages/agent/src/harness/env/nodejs.ts` — `NodeExecutionEnv` →
2//! `OsExecutionEnv`. Real-filesystem + real-process backend via `tokio::fs` +
3//! `tokio::process`.
4//!
5//! Path resolution (\[`resolvePath`\]): tilde (`~`/`~/`, `~\` on Windows),
6//! `file://` URL (parse failure silently keeps the literal string), absolute
7//! → `canonicalize`-style normalize, relative → `cwd.join`. No env-var or
8//! `~user` expansion. No symlink resolution (that's `canonical_path`).
9//!
10//! Shell detection (\[`getShellConfig`\]): explicit override → Windows
11//! git-bash probe (`ProgramFiles\Git\bin\bash.exe`, `ProgramFiles(x86)\…`) →
12//! `where bash.exe` → POSIX `/bin/bash` → `which bash` → `/bin/sh` fallback
13//! (POSIX only). **No cmd.exe fallback** on Windows — failure is
14//! `ExecutionError::ShellUnavailable`. Legacy WSL bash (`System32\bash.exe`)
15//! uses `bash -s` stdin transport to avoid argv parse bugs.
16//!
17//! Kill tree: Windows `taskkill /F /T /PID`; POSIX `kill -PG` (process group,
18//! since the child is spawned as its own group leader) with fallback to
19//! `kill -PID`. Best-effort, errors swallowed.
20//!
21//! `exec` abort+timeout bracketing (plan §5.4): the spawn is `tokio::select!`-ed
22//! against the cancellation token and `tokio::time::timeout`. Resolution
23//! priority: callbackError → timeout → aborted → exit-code-from-success.
24//! `cleanup()` kills tracked child pids best-effort.
25
26use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use async_trait::async_trait;
31use tokio::sync::Mutex;
32use tokio_util::sync::CancellationToken;
33
34use crate::env::{
35    check_cancel_exec, check_cancel_file, ExecutionEnv, FileContent, FileInfo, FileKind,
36    FileSystem, Shell, ShellExecOptions, ShellOutput,
37};
38use crate::error::{
39    io_to_file_error, ExecutionError, ExecutionErrorCode, FileError, FileErrorCode,
40};
41use crate::file_mutation_queue::{MutatingEnv, MutationQueueRegistry};
42
43/// Max timeout in ms (Int32 max). Mirrors `MAX_TIMEOUT_MS`.
44const MAX_TIMEOUT_MS: u64 = 2_147_483_647;
45/// Max timeout in seconds. Mirrors `MAX_TIMEOUT_SECONDS`.
46const MAX_TIMEOUT_SECONDS: f64 = 2_147_483.647;
47/// Grace period after child exit for trailing stdio. Mirrors
48/// `EXIT_STDIO_GRACE_MS`.
49const EXIT_STDIO_GRACE_MS: u64 = 100;
50
51/// How the command is fed to the shell. Mirrors `commandTransport`.
52#[derive(Clone, Copy)]
53enum CommandTransport {
54    /// Append the command as the last argv element (`bash -c <cmd>`).
55    Argv,
56    /// Pipe the command to the shell's stdin (`bash -s`).
57    Stdin,
58}
59
60/// Resolved shell config. Mirrors `ShellConfig`.
61struct ShellConfig {
62    shell: PathBuf,
63    args: Vec<String>,
64    transport: CommandTransport,
65}
66
67/// Real-OS `ExecutionEnv`. Clone shares the tracked-pid set + registry.
68pub struct OsExecutionEnv {
69    cwd: PathBuf,
70    shell_path: Option<PathBuf>,
71    shell_env: Option<HashMap<String, String>>,
72    active_pids: Arc<Mutex<HashSet<u32>>>,
73    registry: Arc<MutationQueueRegistry>,
74}
75
76impl OsExecutionEnv {
77    pub fn new() -> Self {
78        Self {
79            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
80            shell_path: None,
81            shell_env: None,
82            active_pids: Arc::new(Mutex::new(HashSet::new())),
83            registry: Arc::new(MutationQueueRegistry::new()),
84        }
85    }
86
87    pub fn with_cwd(cwd: PathBuf) -> Self {
88        Self {
89            cwd,
90            shell_path: None,
91            shell_env: None,
92            active_pids: Arc::new(Mutex::new(HashSet::new())),
93            registry: Arc::new(MutationQueueRegistry::new()),
94        }
95    }
96
97    pub fn with_shell_path(mut self, shell_path: PathBuf) -> Self {
98        self.shell_path = Some(shell_path);
99        self
100    }
101
102    pub fn with_shell_env(mut self, env: HashMap<String, String>) -> Self {
103        self.shell_env = Some(env);
104        self
105    }
106
107    /// Resolve a path (tilde, file://, absolute/relative) against `cwd`. Pure.
108    fn resolve_path(cwd: &Path, path: &str) -> PathBuf {
109        let mut p = path.to_string();
110        // Tilde expansion (literal only).
111        if p == "~" {
112            p = home_dir_string();
113        } else if let Some(rest) = p.strip_prefix("~/") {
114            p = format!("{}/{}", home_dir_string(), rest);
115        } else if cfg!(windows) {
116            if let Some(rest) = p.strip_prefix("~\\") {
117                p = format!("{}\\{}", home_dir_string(), rest);
118            }
119        }
120        // file:// URL → path. Parse failure silently keeps the literal.
121        if p.starts_with("file://") {
122            if let Some(local) = file_url_to_path(&p) {
123                p = local;
124            }
125        }
126        let pb = PathBuf::from(&p);
127        if pb.is_absolute() {
128            normalize_absolute(&pb)
129        } else {
130            normalize_absolute(&cwd.join(pb))
131        }
132    }
133
134    /// Detect + return the shell config, or `ShellUnavailable`. Mirrors
135    /// `getShellConfig`.
136    async fn get_shell_config(&self) -> Result<ShellConfig, ExecutionError> {
137        // Explicit override.
138        if let Some(sp) = &self.shell_path {
139            if tokio::fs::metadata(sp).await.is_ok() {
140                return Ok(bash_shell_config(sp.clone()));
141            }
142            return Err(ExecutionError::new(
143                ExecutionErrorCode::ShellUnavailable,
144                format!("Custom shell path not found: {}", sp.display()),
145            ));
146        }
147        // Windows probe.
148        #[cfg(windows)]
149        {
150            if let Some(cfg) = windows_probe_bash().await {
151                return Ok(cfg);
152            }
153            return Err(ExecutionError::new(
154                ExecutionErrorCode::ShellUnavailable,
155                "bash not found. Install Git for Windows / MSYS2 / Cygwin, or set shellPath.",
156            ));
157        }
158        // POSIX.
159        #[cfg(not(windows))]
160        {
161            if tokio::fs::metadata("/bin/bash").await.is_ok() {
162                return Ok(ShellConfig {
163                    shell: PathBuf::from("/bin/bash"),
164                    args: vec!["-c".to_string()],
165                    transport: CommandTransport::Argv,
166                });
167            }
168            // `which bash` fallback.
169            if let Some(found) =
170                run_command("which", &["bash"], std::time::Duration::from_millis(5000)).await
171            {
172                let trimmed = found.trim();
173                if !trimmed.is_empty() && tokio::fs::metadata(trimmed).await.is_ok() {
174                    return Ok(ShellConfig {
175                        shell: PathBuf::from(trimmed),
176                        args: vec!["-c".to_string()],
177                        transport: CommandTransport::Argv,
178                    });
179                }
180            }
181            // sh fallback.
182            return Ok(ShellConfig {
183                shell: PathBuf::from("sh"),
184                args: vec!["-c".to_string()],
185                transport: CommandTransport::Argv,
186            });
187        }
188    }
189}
190
191impl Default for OsExecutionEnv {
192    fn default() -> Self {
193        Self::new()
194    }
195}
196
197fn home_dir_string() -> String {
198    dirs::home_dir()
199        .map(|p| p.to_string_lossy().into_owned())
200        .unwrap_or_default()
201}
202
203fn normalize_absolute(p: &Path) -> PathBuf {
204    // std::path doesn't expose ".."/"." collapse without canonicalize (which
205    // hits the FS). Rebuild component-by-component.
206    let mut out = PathBuf::new();
207    for comp in p.components() {
208        match comp {
209            std::path::Component::ParentDir => {
210                out.pop();
211            }
212            std::path::Component::CurDir => {}
213            other => out.push(other),
214        }
215    }
216    out
217}
218
219fn file_url_to_path(url: &str) -> Option<String> {
220    // Minimal file:// → path conversion. Handles `file:///abs/path` and
221    // Windows `file:///C:/...`. Parse failure → None (caller keeps literal).
222    let rest = url.strip_prefix("file://")?;
223    if let Some(drive_rest) = rest.strip_prefix('/') {
224        // `/C:/...` (Windows drive) or `/abs/path`.
225        if drive_rest.len() >= 2 && drive_rest.as_bytes()[1] == b':' {
226            return Some(drive_rest.to_string());
227        }
228        return Some(format!("/{}", drive_rest));
229    }
230    Some(rest.to_string())
231}
232
233fn bash_shell_config(shell: PathBuf) -> ShellConfig {
234    if is_legacy_wsl_bash_path(&shell) {
235        ShellConfig {
236            shell,
237            args: vec!["-s".to_string()],
238            transport: CommandTransport::Stdin,
239        }
240    } else {
241        ShellConfig {
242            shell,
243            args: vec!["-c".to_string()],
244            transport: CommandTransport::Argv,
245        }
246    }
247}
248
249fn is_legacy_wsl_bash_path(p: &Path) -> bool {
250    let s = p.to_string_lossy().replace('/', "\\").to_lowercase();
251    let re = regex::Regex::new(r"^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$").unwrap();
252    re.is_match(&s)
253}
254
255#[cfg(windows)]
256async fn windows_probe_bash() -> Option<ShellConfig> {
257    let pf = std::env::var("ProgramFiles").ok();
258    let pf86 = std::env::var("ProgramFiles(x86)").ok();
259    let mut candidates: Vec<PathBuf> = Vec::new();
260    if let Some(p) = pf {
261        candidates.push(PathBuf::from(p).join("Git").join("bin").join("bash.exe"));
262    }
263    if let Some(p) = pf86 {
264        candidates.push(PathBuf::from(p).join("Git").join("bin").join("bash.exe"));
265    }
266    for c in &candidates {
267        if tokio::fs::metadata(c).await.is_ok() {
268            return Some(bash_shell_config(c.clone()));
269        }
270    }
271    // `where bash.exe`.
272    if let Some(found) = run_command(
273        "where",
274        &["bash.exe"],
275        std::time::Duration::from_millis(5000),
276    )
277    .await
278    {
279        let first = found.lines().next().unwrap_or("").trim().to_string();
280        if !first.is_empty() && tokio::fs::metadata(&first).await.is_ok() {
281            return Some(bash_shell_config(PathBuf::from(first)));
282        }
283    }
284    None
285}
286
287/// Run a quick command, capture stdout, with a timeout. Mirrors `runCommand`
288/// (used only by shell detection). Returns `None` on spawn failure / timeout /
289/// empty stdout.
290async fn run_command(cmd: &str, args: &[&str], timeout: std::time::Duration) -> Option<String> {
291    let mut command = tokio::process::Command::new(cmd);
292    command.args(args);
293    command.stdout(std::process::Stdio::piped());
294    command.stderr(std::process::Stdio::null());
295    command.stdin(std::process::Stdio::null());
296    #[cfg(windows)]
297    {
298        // Hide the console window for the probe.
299        use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
300        command.creation_flags(CREATE_NO_WINDOW);
301    }
302    let child = command.spawn().ok()?;
303    let out = match tokio::time::timeout(timeout, child.wait_with_output()).await {
304        Ok(Ok(o)) => o,
305        _ => return None,
306    };
307    if !out.status.success() {
308        return None;
309    }
310    let s = String::from_utf8_lossy(&out.stdout).into_owned();
311    if s.trim().is_empty() {
312        None
313    } else {
314        Some(s)
315    }
316}
317
318/// Kill a process tree. Windows: `taskkill /F /T /PID`. POSIX: `kill -PG` with
319/// fallback to `kill -PID`. Best-effort, errors swallowed.
320async fn kill_process_tree(pid: u32) {
321    #[cfg(windows)]
322    {
323        use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
324        let mut cmd = tokio::process::Command::new("taskkill");
325        cmd.args(&["/F", "/T", "/PID", &pid.to_string()]);
326        cmd.creation_flags(CREATE_NO_WINDOW);
327        cmd.stdin(std::process::Stdio::null());
328        cmd.stdout(std::process::Stdio::null());
329        cmd.stderr(std::process::Stdio::null());
330        // Wait for taskkill itself to finish. Spawning it fire-and-forget lets
331        // the shell's `child.wait()` race the tree termination, and a listener
332        // can keep its inherited stdout/stderr handles open long enough to
333        // deadlock the capture join after Ctrl+C.
334        let _ = cmd.status().await;
335    }
336    #[cfg(unix)]
337    {
338        use nix::sys::signal::{kill, Signal};
339        use nix::unistd::Pid;
340        // Process group: negative pid.
341        let pg = Pid::from_raw(-(pid as i32));
342        if kill(pg, Signal::SIGKILL).is_err() {
343            let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
344        }
345    }
346    #[cfg(not(any(windows, unix)))]
347    {
348        let _ = pid;
349    }
350}
351
352#[async_trait]
353impl FileSystem for OsExecutionEnv {
354    fn cwd(&self) -> &Path {
355        &self.cwd
356    }
357
358    async fn absolute_path(
359        &self,
360        path: &str,
361        cancel: Option<&CancellationToken>,
362    ) -> Result<PathBuf, FileError> {
363        check_cancel_file(cancel, Some(path))?;
364        Ok(Self::resolve_path(&self.cwd, path))
365    }
366
367    async fn join_path(
368        &self,
369        parts: &[&str],
370        cancel: Option<&CancellationToken>,
371    ) -> Result<PathBuf, FileError> {
372        check_cancel_file(cancel, None)?;
373        let mut out = PathBuf::new();
374        for p in parts {
375            out.push(p);
376        }
377        Ok(out)
378    }
379
380    async fn read_text_file(
381        &self,
382        path: &str,
383        cancel: Option<&CancellationToken>,
384    ) -> Result<String, FileError> {
385        check_cancel_file(cancel, Some(path))?;
386        let abs = Self::resolve_path(&self.cwd, path);
387        let bytes = read_cancellable(&abs, cancel).await?;
388        String::from_utf8(bytes).map_err(|e| {
389            FileError::new(FileErrorCode::Invalid, format!("invalid utf-8: {e}"))
390                .with_path(abs.to_string_lossy())
391        })
392    }
393
394    async fn read_text_lines(
395        &self,
396        path: &str,
397        max_lines: Option<usize>,
398        cancel: Option<&CancellationToken>,
399    ) -> Result<Vec<String>, FileError> {
400        check_cancel_file(cancel, Some(path))?;
401        if max_lines.map(|m| m == 0).unwrap_or(false) {
402            return Ok(Vec::new());
403        }
404        let text = self.read_text_file(path, cancel).await?;
405        let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
406        if text.ends_with('\n') {
407            if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
408                lines.pop();
409            }
410        }
411        if let Some(m) = max_lines {
412            lines.truncate(m);
413        }
414        Ok(lines)
415    }
416
417    async fn read_binary_file(
418        &self,
419        path: &str,
420        cancel: Option<&CancellationToken>,
421    ) -> Result<Vec<u8>, FileError> {
422        check_cancel_file(cancel, Some(path))?;
423        let abs = Self::resolve_path(&self.cwd, path);
424        read_cancellable(&abs, cancel).await
425    }
426
427    async fn write_file(
428        &self,
429        path: &str,
430        content: FileContent,
431        cancel: Option<&CancellationToken>,
432    ) -> Result<(), FileError> {
433        check_cancel_file(cancel, Some(path))?;
434        let abs = Self::resolve_path(&self.cwd, path);
435        // mkdir parent (recursive).
436        if let Some(parent) = abs.parent() {
437            if !parent.as_os_str().is_empty() {
438                if let Err(e) = tokio::fs::create_dir_all(parent).await {
439                    return Err(io_to_file_error(e, Some(&abs.to_string_lossy())));
440                }
441            }
442        }
443        check_cancel_file(cancel, Some(&abs.to_string_lossy()))?;
444        write_cancellable(&abs, content.as_bytes(), cancel).await
445    }
446
447    async fn append_file(
448        &self,
449        path: &str,
450        content: FileContent,
451        cancel: Option<&CancellationToken>,
452    ) -> Result<(), FileError> {
453        check_cancel_file(cancel, Some(path))?;
454        let abs = Self::resolve_path(&self.cwd, path);
455        if let Some(parent) = abs.parent() {
456            if !parent.as_os_str().is_empty() {
457                let _ = tokio::fs::create_dir_all(parent).await;
458            }
459        }
460        use tokio::io::AsyncWriteExt;
461        let res = match tokio::fs::OpenOptions::new()
462            .create(true)
463            .append(true)
464            .open(&abs)
465            .await
466        {
467            Ok(mut f) => f.write_all(content.as_bytes()).await,
468            Err(e) => return Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
469        };
470        res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
471    }
472
473    async fn rename_file(
474        &self,
475        source: &str,
476        dest: &str,
477        cancel: Option<&CancellationToken>,
478    ) -> Result<(), FileError> {
479        check_cancel_file(cancel, Some(source))?;
480        let src = Self::resolve_path(&self.cwd, source);
481        let dst = Self::resolve_path(&self.cwd, dest);
482        tokio::fs::rename(&src, &dst)
483            .await
484            .map_err(|e| io_to_file_error(e, Some(&src.to_string_lossy())))
485    }
486
487    async fn file_info(
488        &self,
489        path: &str,
490        cancel: Option<&CancellationToken>,
491    ) -> Result<FileInfo, FileError> {
492        check_cancel_file(cancel, Some(path))?;
493        let abs = Self::resolve_path(&self.cwd, path);
494        let meta = tokio::fs::symlink_metadata(&abs)
495            .await
496            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
497        let ft = meta.file_type();
498        let kind = if ft.is_file() {
499            FileKind::File
500        } else if ft.is_dir() {
501            FileKind::Directory
502        } else if ft.is_symlink() {
503            FileKind::Symlink
504        } else {
505            return Err(
506                FileError::new(FileErrorCode::Invalid, "Unsupported file type")
507                    .with_path(abs.to_string_lossy()),
508            );
509        };
510        let mtime_ms = meta
511            .modified()
512            .ok()
513            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
514            .map(|d| d.as_millis() as i64)
515            .unwrap_or(0);
516        Ok(FileInfo {
517            name: abs
518                .file_name()
519                .map(|n| n.to_string_lossy().into_owned())
520                .unwrap_or_else(|| abs.to_string_lossy().into_owned()),
521            path: abs.clone(),
522            kind,
523            size: meta.len(),
524            mtime_ms,
525        })
526    }
527
528    async fn list_dir(
529        &self,
530        path: &str,
531        cancel: Option<&CancellationToken>,
532    ) -> Result<Vec<FileInfo>, FileError> {
533        check_cancel_file(cancel, Some(path))?;
534        let abs = Self::resolve_path(&self.cwd, path);
535        let mut entries = tokio::fs::read_dir(&abs)
536            .await
537            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
538        let mut out = Vec::new();
539        while let Some(entry) = entries
540            .next_entry()
541            .await
542            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?
543        {
544            if let Some(t) = cancel {
545                if t.is_cancelled() {
546                    return Err(FileError::aborted().with_path(abs.to_string_lossy()));
547                }
548            }
549            let ep = entry.path();
550            let meta = match tokio::fs::symlink_metadata(&ep).await {
551                Ok(m) => m,
552                Err(e) => {
553                    // Skip unsupported types silently; propagate real stat errors.
554                    let code = crate::error::io_to_file_code(&e);
555                    if code == FileErrorCode::NotSupported {
556                        continue;
557                    }
558                    return Err(io_to_file_error(e, Some(&ep.to_string_lossy())));
559                }
560            };
561            let ft = meta.file_type();
562            let kind = if ft.is_file() {
563                FileKind::File
564            } else if ft.is_dir() {
565                FileKind::Directory
566            } else if ft.is_symlink() {
567                FileKind::Symlink
568            } else {
569                continue; // silently skip sockets/fifos/etc.
570            };
571            let mtime_ms = meta
572                .modified()
573                .ok()
574                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
575                .map(|d| d.as_millis() as i64)
576                .unwrap_or(0);
577            out.push(FileInfo {
578                name: entry.file_name().to_string_lossy().into_owned(),
579                path: ep,
580                kind,
581                size: meta.len(),
582                mtime_ms,
583            });
584        }
585        Ok(out)
586    }
587
588    async fn canonical_path(
589        &self,
590        path: &str,
591        cancel: Option<&CancellationToken>,
592    ) -> Result<PathBuf, FileError> {
593        check_cancel_file(cancel, Some(path))?;
594        let abs = Self::resolve_path(&self.cwd, path);
595        tokio::fs::canonicalize(&abs)
596            .await
597            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
598    }
599
600    async fn exists(
601        &self,
602        path: &str,
603        cancel: Option<&CancellationToken>,
604    ) -> Result<bool, FileError> {
605        check_cancel_file(cancel, Some(path))?;
606        let abs = Self::resolve_path(&self.cwd, path);
607        match tokio::fs::symlink_metadata(&abs).await {
608            Ok(_) => Ok(true),
609            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
610            Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
611        }
612    }
613
614    async fn create_dir(
615        &self,
616        path: &str,
617        recursive: bool,
618        cancel: Option<&CancellationToken>,
619    ) -> Result<(), FileError> {
620        check_cancel_file(cancel, Some(path))?;
621        let abs = Self::resolve_path(&self.cwd, path);
622        let res = if recursive {
623            tokio::fs::create_dir_all(&abs).await
624        } else {
625            tokio::fs::create_dir(&abs).await
626        };
627        res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
628    }
629
630    async fn remove(
631        &self,
632        path: &str,
633        recursive: bool,
634        force: bool,
635        cancel: Option<&CancellationToken>,
636    ) -> Result<(), FileError> {
637        check_cancel_file(cancel, Some(path))?;
638        let abs = Self::resolve_path(&self.cwd, path);
639        let res = if recursive {
640            tokio::fs::remove_dir_all(&abs).await
641        } else {
642            // Try file first, then empty dir.
643            match tokio::fs::remove_file(&abs).await {
644                Ok(()) => Ok(()),
645                Err(_) => tokio::fs::remove_dir(&abs).await,
646            }
647        };
648        match res {
649            Ok(()) => Ok(()),
650            Err(e) if e.kind() == std::io::ErrorKind::NotFound && force => Ok(()),
651            Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
652        }
653    }
654
655    async fn create_temp_dir(
656        &self,
657        prefix: Option<&str>,
658        cancel: Option<&CancellationToken>,
659    ) -> Result<PathBuf, FileError> {
660        check_cancel_file(cancel, None)?;
661        let prefix = prefix.unwrap_or("tmp-");
662        // Use tempfile's NamedTempFile-less builder for a dir.
663        let tmp =
664            tempfile::tempdir_in(std::env::temp_dir()).map_err(|e| io_to_file_error(e, None))?;
665        // tempfile picks a random name; rename to include the prefix.
666        let path = tmp.path().to_path_buf();
667        std::mem::forget(tmp); // keep the dir alive; caller manages cleanup.
668                               // Build a prefixed sibling.
669        let final_path = std::env::temp_dir().join(format!("{}{}", prefix, random_suffix()));
670        // Rename the tempdir into the prefixed path.
671        if let Err(e) = tokio::fs::rename(&path, &final_path).await {
672            // Fallback: just return the original random path.
673            let _ = e;
674            return Ok(path);
675        }
676        Ok(final_path)
677    }
678
679    async fn create_temp_file(
680        &self,
681        prefix: &str,
682        suffix: &str,
683        cancel: Option<&CancellationToken>,
684    ) -> Result<PathBuf, FileError> {
685        check_cancel_file(cancel, None)?;
686        // Create an isolated temp dir (matching the TS pattern of one dir per
687        // file), then write an empty file named `<prefix><random><suffix>`.
688        let dir = self.create_temp_dir(Some("tmp-"), cancel).await?;
689        let name = format!("{}{}{}", prefix, random_suffix(), suffix);
690        let path = dir.join(name);
691        tokio::fs::File::create(&path)
692            .await
693            .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
694        Ok(path)
695    }
696
697    async fn cleanup(&self) {
698        let pids: Vec<u32> = self.active_pids.lock().await.iter().copied().collect();
699        for pid in pids {
700            kill_process_tree(pid).await;
701        }
702        self.active_pids.lock().await.clear();
703    }
704}
705
706/// Read a file to bytes, honoring cooperative cancellation.
707async fn read_cancellable(
708    path: &Path,
709    cancel: Option<&CancellationToken>,
710) -> Result<Vec<u8>, FileError> {
711    let read = tokio::fs::read(path);
712    let res = match cancel {
713        Some(t) => tokio::select! {
714            r = read => r,
715            _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
716        },
717        None => read.await,
718    };
719    res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
720}
721
722/// Write bytes to a file (truncate), honoring cooperative cancellation.
723async fn write_cancellable(
724    path: &Path,
725    bytes: &[u8],
726    cancel: Option<&CancellationToken>,
727) -> Result<(), FileError> {
728    use tokio::io::AsyncWriteExt;
729    let f = tokio::fs::File::create(path)
730        .await
731        .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
732    let write = async {
733        let mut f = f;
734        f.write_all(bytes).await?;
735        f.flush().await?;
736        Ok::<(), std::io::Error>(())
737    };
738    let res = match cancel {
739        Some(t) => tokio::select! {
740            r = write => r,
741            _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
742        },
743        None => write.await,
744    };
745    res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
746}
747
748fn random_suffix() -> String {
749    // Short random suffix via uuid v4 (no Time—it's a UUID, not a timestamp).
750    uuid::Uuid::new_v4().simple().to_string()
751}
752
753#[async_trait]
754impl Shell for OsExecutionEnv {
755    async fn exec<'a>(
756        &'a self,
757        command: &str,
758        options: ShellExecOptions<'a>,
759    ) -> Result<ShellOutput, ExecutionError> {
760        // Pre-flight abort.
761        check_cancel_exec(options.cancel)?;
762        // Resolve timeout.
763        let timeout = match resolve_timeout(options.timeout) {
764            Ok(t) => t,
765            Err(e) => return Err(e),
766        };
767        // Resolve cwd.
768        let cwd = match &options.cwd {
769            Some(c) => Self::resolve_path(&self.cwd, &c.to_string_lossy()),
770            None => self.cwd.clone(),
771        };
772        // Shell config.
773        let shell_cfg = self.get_shell_config().await?;
774        // Verify cwd exists.
775        if !cwd.exists() {
776            return Err(ExecutionError::new(
777                ExecutionErrorCode::SpawnError,
778                format!(
779                    "Working directory does not exist: {}\nCannot execute bash commands.",
780                    cwd.display()
781                ),
782            ));
783        }
784
785        // Build the spawn.
786        let mut cmd = tokio::process::Command::new(&shell_cfg.shell);
787        cmd.current_dir(&cwd);
788        cmd.env_clear();
789        // Environment: inherit + shell_env + per-call.
790        if options.inherit_env {
791            for (k, v) in std::env::vars() {
792                cmd.env(k, v);
793            }
794        }
795        if let Some(base) = &self.shell_env {
796            for (k, v) in base {
797                cmd.env(k, v);
798            }
799        }
800        if let Some(extra) = &options.env {
801            for (k, v) in extra {
802                cmd.env(k, v);
803            }
804        }
805        // stdio.
806        cmd.stdin(std::process::Stdio::piped());
807        cmd.stdout(std::process::Stdio::piped());
808        cmd.stderr(std::process::Stdio::piped());
809        #[cfg(unix)]
810        {
811            use std::os::unix::process::CommandExt;
812            cmd.process_group(0); // child becomes its own group leader.
813        }
814        #[cfg(windows)]
815        {
816            use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
817            cmd.creation_flags(CREATE_NO_WINDOW);
818        }
819
820        // Args: stdin transport → `bash -s` (no command argv); argv → append.
821        let transport = shell_cfg.transport;
822        cmd.args(&shell_cfg.args);
823        if matches!(transport, CommandTransport::Argv) {
824            cmd.arg(command);
825        }
826
827        let mut child = match cmd.spawn() {
828            Ok(c) => c,
829            Err(e) => {
830                return Err(ExecutionError::new(
831                    ExecutionErrorCode::SpawnError,
832                    format!("failed to spawn shell: {e}"),
833                ));
834            }
835        };
836        let pid = child.id();
837        if let Some(pid) = pid {
838            self.active_pids.lock().await.insert(pid);
839        }
840
841        // Stdin transport: write command then close.
842        if matches!(transport, CommandTransport::Stdin) {
843            if let Some(mut stdin) = child.stdin.take() {
844                use tokio::io::AsyncWriteExt;
845                let _ = stdin.write_all(command.as_bytes()).await;
846                let _ = stdin.flush().await;
847                drop(stdin);
848            }
849        }
850
851        // Collect stdout/stderr via separate read tasks, firing callbacks.
852        let mut stdout_buf = String::new();
853        let mut stderr_buf = String::new();
854        let cancel = options.cancel;
855        let mut on_stdout = options.on_stdout;
856        let mut on_stderr = options.on_stderr;
857
858        let mut stdout = child.stdout.take();
859        let mut stderr = child.stderr.take();
860
861        let stdout_task = async {
862            if let Some(s) = stdout.as_mut() {
863                let mut buf = vec![0u8; 8192];
864                use tokio::io::AsyncReadExt;
865                loop {
866                    let read = s.read(&mut buf);
867                    let read_result = match cancel {
868                        Some(token) => tokio::select! {
869                            result = read => result,
870                            _ = token.cancelled() => break,
871                        },
872                        None => read.await,
873                    };
874                    let n = match read_result {
875                        Ok(0) => break,
876                        Ok(n) => n,
877                        Err(_) => break,
878                    };
879                    let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
880                    if let Some(cb) = on_stdout.as_mut() {
881                        // Callback errors are swallowed (TS sets callbackError;
882                        // v1 simplifies — the callback contract is best-effort).
883                        cb(&chunk);
884                    }
885                    stdout_buf.push_str(&chunk);
886                }
887            }
888        };
889        let stderr_task = async {
890            if let Some(s) = stderr.as_mut() {
891                let mut buf = vec![0u8; 8192];
892                use tokio::io::AsyncReadExt;
893                loop {
894                    let read = s.read(&mut buf);
895                    let read_result = match cancel {
896                        Some(token) => tokio::select! {
897                            result = read => result,
898                            _ = token.cancelled() => break,
899                        },
900                        None => read.await,
901                    };
902                    let n = match read_result {
903                        Ok(0) => break,
904                        Ok(n) => n,
905                        Err(_) => break,
906                    };
907                    let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
908                    if let Some(cb) = on_stderr.as_mut() {
909                        cb(&chunk);
910                    }
911                    stderr_buf.push_str(&chunk);
912                }
913            }
914        };
915
916        // Wait for the child, with timeout + cancellation.
917        let mut timed_out = false;
918        let exit_status = {
919            // We pin stdout/stderr take handles + the wait future, then drive
920            // them concurrently inside a single select! block. `child` is only
921            // touched again AFTER the join completes (kill + reap), so the
922            // borrow ends cleanly.
923            let status_fut = async {
924                match (timeout, cancel) {
925                    (Some(to), Some(token)) => {
926                        tokio::select! {
927                            status = child.wait() => status,
928                            _ = tokio::time::sleep(to) => {
929                                timed_out = true;
930                                if let Some(pid) = child.id() {
931                                    kill_process_tree(pid).await;
932                                }
933                                child.wait().await
934                            }
935                            _ = token.cancelled() => {
936                                if let Some(pid) = child.id() {
937                                    kill_process_tree(pid).await;
938                                }
939                                child.wait().await
940                            }
941                        }
942                    }
943                    (Some(to), None) => match tokio::time::timeout(to, child.wait()).await {
944                        Ok(status) => status,
945                        Err(_) => {
946                            timed_out = true;
947                            if let Some(pid) = child.id() {
948                                kill_process_tree(pid).await;
949                            }
950                            child.wait().await
951                        }
952                    },
953                    (None, Some(token)) => {
954                        tokio::select! {
955                            status = child.wait() => status,
956                            _ = token.cancelled() => {
957                                if let Some(pid) = child.id() {
958                                    kill_process_tree(pid).await;
959                                }
960                                child.wait().await
961                            }
962                        }
963                    }
964                    (None, None) => child.wait().await,
965                }
966            };
967            // Run stdio + status concurrently.
968            tokio::pin!(status_fut);
969            let (_, _, status_res) = tokio::join!(stdout_task, stderr_task, status_fut);
970            status_res
971        };
972
973        // Give trailing stdio a brief grace period (the streams are already
974        // EOF-driven, so this is mostly belt-and-suspenders for late writes).
975        if timed_out {
976            let _ = tokio::time::sleep(std::time::Duration::from_millis(EXIT_STDIO_GRACE_MS)).await;
977        }
978
979        // Remove from tracked pids.
980        if let Some(pid) = pid {
981            self.active_pids.lock().await.remove(&pid);
982        }
983
984        // Resolution priority: timedOut → cancelled → status.
985        let status = match exit_status {
986            Ok(s) => s,
987            Err(e) => {
988                return Err(ExecutionError::new(
989                    ExecutionErrorCode::SpawnError,
990                    format!("child wait failed: {e}"),
991                ));
992            }
993        };
994        if timed_out {
995            return Err(ExecutionError::timeout(
996                options.timeout.map(|f| f as u64).unwrap_or(0),
997            ));
998        }
999        if cancel.map(|t| t.is_cancelled()).unwrap_or(false) {
1000            return Err(ExecutionError::aborted());
1001        }
1002        let exit_code = status.code().unwrap_or(0);
1003        Ok(ShellOutput {
1004            stdout: stdout_buf,
1005            stderr: stderr_buf,
1006            exit_code,
1007        })
1008    }
1009
1010    async fn cleanup(&self) {
1011        FileSystem::cleanup(self).await
1012    }
1013}
1014
1015#[async_trait]
1016impl ExecutionEnv for OsExecutionEnv {}
1017
1018#[async_trait]
1019impl MutatingEnv for OsExecutionEnv {
1020    fn as_env(&self) -> &dyn ExecutionEnv {
1021        self
1022    }
1023    fn mutation_registry(&self) -> &MutationQueueRegistry {
1024        &self.registry
1025    }
1026}
1027
1028/// Validate a timeout (seconds). Mirrors `resolveTimeoutMs` semantics.
1029fn resolve_timeout(timeout: Option<f64>) -> Result<Option<std::time::Duration>, ExecutionError> {
1030    match timeout {
1031        None => Ok(None),
1032        Some(secs) => {
1033            if !secs.is_finite() || secs <= 0.0 {
1034                return Err(ExecutionError::new(
1035                    ExecutionErrorCode::Timeout,
1036                    "Invalid timeout: must be a finite number of seconds",
1037                ));
1038            }
1039            let ms = secs * 1000.0;
1040            if ms > MAX_TIMEOUT_MS as f64 {
1041                return Err(ExecutionError::new(
1042                    ExecutionErrorCode::Timeout,
1043                    format!(
1044                        "Invalid timeout: maximum is {} seconds",
1045                        MAX_TIMEOUT_SECONDS
1046                    ),
1047                ));
1048            }
1049            Ok(Some(std::time::Duration::from_secs_f64(secs)))
1050        }
1051    }
1052}