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        let _ = cmd.spawn();
331    }
332    #[cfg(unix)]
333    {
334        use nix::sys::signal::{kill, Signal};
335        use nix::unistd::Pid;
336        // Process group: negative pid.
337        let pg = Pid::from_raw(-(pid as i32));
338        if kill(pg, Signal::SIGKILL).is_err() {
339            let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
340        }
341    }
342    #[cfg(not(any(windows, unix)))]
343    {
344        let _ = pid;
345    }
346}
347
348#[async_trait]
349impl FileSystem for OsExecutionEnv {
350    fn cwd(&self) -> &Path {
351        &self.cwd
352    }
353
354    async fn absolute_path(
355        &self,
356        path: &str,
357        cancel: Option<&CancellationToken>,
358    ) -> Result<PathBuf, FileError> {
359        check_cancel_file(cancel, Some(path))?;
360        Ok(Self::resolve_path(&self.cwd, path))
361    }
362
363    async fn join_path(
364        &self,
365        parts: &[&str],
366        cancel: Option<&CancellationToken>,
367    ) -> Result<PathBuf, FileError> {
368        check_cancel_file(cancel, None)?;
369        let mut out = PathBuf::new();
370        for p in parts {
371            out.push(p);
372        }
373        Ok(out)
374    }
375
376    async fn read_text_file(
377        &self,
378        path: &str,
379        cancel: Option<&CancellationToken>,
380    ) -> Result<String, FileError> {
381        check_cancel_file(cancel, Some(path))?;
382        let abs = Self::resolve_path(&self.cwd, path);
383        let bytes = read_cancellable(&abs, cancel).await?;
384        String::from_utf8(bytes).map_err(|e| {
385            FileError::new(FileErrorCode::Invalid, format!("invalid utf-8: {e}"))
386                .with_path(abs.to_string_lossy())
387        })
388    }
389
390    async fn read_text_lines(
391        &self,
392        path: &str,
393        max_lines: Option<usize>,
394        cancel: Option<&CancellationToken>,
395    ) -> Result<Vec<String>, FileError> {
396        check_cancel_file(cancel, Some(path))?;
397        if max_lines.map(|m| m == 0).unwrap_or(false) {
398            return Ok(Vec::new());
399        }
400        let text = self.read_text_file(path, cancel).await?;
401        let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
402        if text.ends_with('\n') {
403            if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
404                lines.pop();
405            }
406        }
407        if let Some(m) = max_lines {
408            lines.truncate(m);
409        }
410        Ok(lines)
411    }
412
413    async fn read_binary_file(
414        &self,
415        path: &str,
416        cancel: Option<&CancellationToken>,
417    ) -> Result<Vec<u8>, FileError> {
418        check_cancel_file(cancel, Some(path))?;
419        let abs = Self::resolve_path(&self.cwd, path);
420        read_cancellable(&abs, cancel).await
421    }
422
423    async fn write_file(
424        &self,
425        path: &str,
426        content: FileContent,
427        cancel: Option<&CancellationToken>,
428    ) -> Result<(), FileError> {
429        check_cancel_file(cancel, Some(path))?;
430        let abs = Self::resolve_path(&self.cwd, path);
431        // mkdir parent (recursive).
432        if let Some(parent) = abs.parent() {
433            if !parent.as_os_str().is_empty() {
434                if let Err(e) = tokio::fs::create_dir_all(parent).await {
435                    return Err(io_to_file_error(e, Some(&abs.to_string_lossy())));
436                }
437            }
438        }
439        check_cancel_file(cancel, Some(&abs.to_string_lossy()))?;
440        write_cancellable(&abs, content.as_bytes(), cancel).await
441    }
442
443    async fn append_file(
444        &self,
445        path: &str,
446        content: FileContent,
447        cancel: Option<&CancellationToken>,
448    ) -> Result<(), FileError> {
449        check_cancel_file(cancel, Some(path))?;
450        let abs = Self::resolve_path(&self.cwd, path);
451        if let Some(parent) = abs.parent() {
452            if !parent.as_os_str().is_empty() {
453                let _ = tokio::fs::create_dir_all(parent).await;
454            }
455        }
456        use tokio::io::AsyncWriteExt;
457        let res = match tokio::fs::OpenOptions::new()
458            .create(true)
459            .append(true)
460            .open(&abs)
461            .await
462        {
463            Ok(mut f) => f.write_all(content.as_bytes()).await,
464            Err(e) => return Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
465        };
466        res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
467    }
468
469    async fn rename_file(
470        &self,
471        source: &str,
472        dest: &str,
473        cancel: Option<&CancellationToken>,
474    ) -> Result<(), FileError> {
475        check_cancel_file(cancel, Some(source))?;
476        let src = Self::resolve_path(&self.cwd, source);
477        let dst = Self::resolve_path(&self.cwd, dest);
478        tokio::fs::rename(&src, &dst)
479            .await
480            .map_err(|e| io_to_file_error(e, Some(&src.to_string_lossy())))
481    }
482
483    async fn file_info(
484        &self,
485        path: &str,
486        cancel: Option<&CancellationToken>,
487    ) -> Result<FileInfo, FileError> {
488        check_cancel_file(cancel, Some(path))?;
489        let abs = Self::resolve_path(&self.cwd, path);
490        let meta = tokio::fs::symlink_metadata(&abs)
491            .await
492            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
493        let ft = meta.file_type();
494        let kind = if ft.is_file() {
495            FileKind::File
496        } else if ft.is_dir() {
497            FileKind::Directory
498        } else if ft.is_symlink() {
499            FileKind::Symlink
500        } else {
501            return Err(
502                FileError::new(FileErrorCode::Invalid, "Unsupported file type")
503                    .with_path(abs.to_string_lossy()),
504            );
505        };
506        let mtime_ms = meta
507            .modified()
508            .ok()
509            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
510            .map(|d| d.as_millis() as i64)
511            .unwrap_or(0);
512        Ok(FileInfo {
513            name: abs
514                .file_name()
515                .map(|n| n.to_string_lossy().into_owned())
516                .unwrap_or_else(|| abs.to_string_lossy().into_owned()),
517            path: abs.clone(),
518            kind,
519            size: meta.len(),
520            mtime_ms,
521        })
522    }
523
524    async fn list_dir(
525        &self,
526        path: &str,
527        cancel: Option<&CancellationToken>,
528    ) -> Result<Vec<FileInfo>, FileError> {
529        check_cancel_file(cancel, Some(path))?;
530        let abs = Self::resolve_path(&self.cwd, path);
531        let mut entries = tokio::fs::read_dir(&abs)
532            .await
533            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
534        let mut out = Vec::new();
535        while let Some(entry) = entries
536            .next_entry()
537            .await
538            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?
539        {
540            if let Some(t) = cancel {
541                if t.is_cancelled() {
542                    return Err(FileError::aborted().with_path(abs.to_string_lossy()));
543                }
544            }
545            let ep = entry.path();
546            let meta = match tokio::fs::symlink_metadata(&ep).await {
547                Ok(m) => m,
548                Err(e) => {
549                    // Skip unsupported types silently; propagate real stat errors.
550                    let code = crate::error::io_to_file_code(&e);
551                    if code == FileErrorCode::NotSupported {
552                        continue;
553                    }
554                    return Err(io_to_file_error(e, Some(&ep.to_string_lossy())));
555                }
556            };
557            let ft = meta.file_type();
558            let kind = if ft.is_file() {
559                FileKind::File
560            } else if ft.is_dir() {
561                FileKind::Directory
562            } else if ft.is_symlink() {
563                FileKind::Symlink
564            } else {
565                continue; // silently skip sockets/fifos/etc.
566            };
567            let mtime_ms = meta
568                .modified()
569                .ok()
570                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
571                .map(|d| d.as_millis() as i64)
572                .unwrap_or(0);
573            out.push(FileInfo {
574                name: entry.file_name().to_string_lossy().into_owned(),
575                path: ep,
576                kind,
577                size: meta.len(),
578                mtime_ms,
579            });
580        }
581        Ok(out)
582    }
583
584    async fn canonical_path(
585        &self,
586        path: &str,
587        cancel: Option<&CancellationToken>,
588    ) -> Result<PathBuf, FileError> {
589        check_cancel_file(cancel, Some(path))?;
590        let abs = Self::resolve_path(&self.cwd, path);
591        tokio::fs::canonicalize(&abs)
592            .await
593            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
594    }
595
596    async fn exists(
597        &self,
598        path: &str,
599        cancel: Option<&CancellationToken>,
600    ) -> Result<bool, FileError> {
601        check_cancel_file(cancel, Some(path))?;
602        let abs = Self::resolve_path(&self.cwd, path);
603        match tokio::fs::symlink_metadata(&abs).await {
604            Ok(_) => Ok(true),
605            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
606            Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
607        }
608    }
609
610    async fn create_dir(
611        &self,
612        path: &str,
613        recursive: bool,
614        cancel: Option<&CancellationToken>,
615    ) -> Result<(), FileError> {
616        check_cancel_file(cancel, Some(path))?;
617        let abs = Self::resolve_path(&self.cwd, path);
618        let res = if recursive {
619            tokio::fs::create_dir_all(&abs).await
620        } else {
621            tokio::fs::create_dir(&abs).await
622        };
623        res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
624    }
625
626    async fn remove(
627        &self,
628        path: &str,
629        recursive: bool,
630        force: bool,
631        cancel: Option<&CancellationToken>,
632    ) -> Result<(), FileError> {
633        check_cancel_file(cancel, Some(path))?;
634        let abs = Self::resolve_path(&self.cwd, path);
635        let res = if recursive {
636            tokio::fs::remove_dir_all(&abs).await
637        } else {
638            // Try file first, then empty dir.
639            match tokio::fs::remove_file(&abs).await {
640                Ok(()) => Ok(()),
641                Err(_) => tokio::fs::remove_dir(&abs).await,
642            }
643        };
644        match res {
645            Ok(()) => Ok(()),
646            Err(e) if e.kind() == std::io::ErrorKind::NotFound && force => Ok(()),
647            Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
648        }
649    }
650
651    async fn create_temp_dir(
652        &self,
653        prefix: Option<&str>,
654        cancel: Option<&CancellationToken>,
655    ) -> Result<PathBuf, FileError> {
656        check_cancel_file(cancel, None)?;
657        let prefix = prefix.unwrap_or("tmp-");
658        // Use tempfile's NamedTempFile-less builder for a dir.
659        let tmp =
660            tempfile::tempdir_in(std::env::temp_dir()).map_err(|e| io_to_file_error(e, None))?;
661        // tempfile picks a random name; rename to include the prefix.
662        let path = tmp.path().to_path_buf();
663        std::mem::forget(tmp); // keep the dir alive; caller manages cleanup.
664                               // Build a prefixed sibling.
665        let final_path = std::env::temp_dir().join(format!("{}{}", prefix, random_suffix()));
666        // Rename the tempdir into the prefixed path.
667        if let Err(e) = tokio::fs::rename(&path, &final_path).await {
668            // Fallback: just return the original random path.
669            let _ = e;
670            return Ok(path);
671        }
672        Ok(final_path)
673    }
674
675    async fn create_temp_file(
676        &self,
677        prefix: &str,
678        suffix: &str,
679        cancel: Option<&CancellationToken>,
680    ) -> Result<PathBuf, FileError> {
681        check_cancel_file(cancel, None)?;
682        // Create an isolated temp dir (matching the TS pattern of one dir per
683        // file), then write an empty file named `<prefix><random><suffix>`.
684        let dir = self.create_temp_dir(Some("tmp-"), cancel).await?;
685        let name = format!("{}{}{}", prefix, random_suffix(), suffix);
686        let path = dir.join(name);
687        tokio::fs::File::create(&path)
688            .await
689            .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
690        Ok(path)
691    }
692
693    async fn cleanup(&self) {
694        let pids: Vec<u32> = self.active_pids.lock().await.iter().copied().collect();
695        for pid in pids {
696            kill_process_tree(pid).await;
697        }
698        self.active_pids.lock().await.clear();
699    }
700}
701
702/// Read a file to bytes, honoring cooperative cancellation.
703async fn read_cancellable(
704    path: &Path,
705    cancel: Option<&CancellationToken>,
706) -> Result<Vec<u8>, FileError> {
707    let read = tokio::fs::read(path);
708    let res = match cancel {
709        Some(t) => tokio::select! {
710            r = read => r,
711            _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
712        },
713        None => read.await,
714    };
715    res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
716}
717
718/// Write bytes to a file (truncate), honoring cooperative cancellation.
719async fn write_cancellable(
720    path: &Path,
721    bytes: &[u8],
722    cancel: Option<&CancellationToken>,
723) -> Result<(), FileError> {
724    use tokio::io::AsyncWriteExt;
725    let f = tokio::fs::File::create(path)
726        .await
727        .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
728    let write = async {
729        let mut f = f;
730        f.write_all(bytes).await?;
731        f.flush().await?;
732        Ok::<(), std::io::Error>(())
733    };
734    let res = match cancel {
735        Some(t) => tokio::select! {
736            r = write => r,
737            _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
738        },
739        None => write.await,
740    };
741    res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
742}
743
744fn random_suffix() -> String {
745    // Short random suffix via uuid v4 (no Time—it's a UUID, not a timestamp).
746    uuid::Uuid::new_v4().simple().to_string()
747}
748
749#[async_trait]
750impl Shell for OsExecutionEnv {
751    async fn exec<'a>(
752        &'a self,
753        command: &str,
754        options: ShellExecOptions<'a>,
755    ) -> Result<ShellOutput, ExecutionError> {
756        // Pre-flight abort.
757        check_cancel_exec(options.cancel)?;
758        // Resolve timeout.
759        let timeout = match resolve_timeout(options.timeout) {
760            Ok(t) => t,
761            Err(e) => return Err(e),
762        };
763        // Resolve cwd.
764        let cwd = match &options.cwd {
765            Some(c) => Self::resolve_path(&self.cwd, &c.to_string_lossy()),
766            None => self.cwd.clone(),
767        };
768        // Shell config.
769        let shell_cfg = self.get_shell_config().await?;
770        // Verify cwd exists.
771        if !cwd.exists() {
772            return Err(ExecutionError::new(
773                ExecutionErrorCode::SpawnError,
774                format!(
775                    "Working directory does not exist: {}\nCannot execute bash commands.",
776                    cwd.display()
777                ),
778            ));
779        }
780
781        // Build the spawn.
782        let mut cmd = tokio::process::Command::new(&shell_cfg.shell);
783        cmd.current_dir(&cwd);
784        cmd.env_clear();
785        // Environment: inherit + shell_env + per-call.
786        if options.inherit_env {
787            for (k, v) in std::env::vars() {
788                cmd.env(k, v);
789            }
790        }
791        if let Some(base) = &self.shell_env {
792            for (k, v) in base {
793                cmd.env(k, v);
794            }
795        }
796        if let Some(extra) = &options.env {
797            for (k, v) in extra {
798                cmd.env(k, v);
799            }
800        }
801        // stdio.
802        cmd.stdin(std::process::Stdio::piped());
803        cmd.stdout(std::process::Stdio::piped());
804        cmd.stderr(std::process::Stdio::piped());
805        #[cfg(unix)]
806        {
807            use std::os::unix::process::CommandExt;
808            cmd.process_group(0); // child becomes its own group leader.
809        }
810        #[cfg(windows)]
811        {
812            use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
813            cmd.creation_flags(CREATE_NO_WINDOW);
814        }
815
816        // Args: stdin transport → `bash -s` (no command argv); argv → append.
817        let transport = shell_cfg.transport;
818        cmd.args(&shell_cfg.args);
819        if matches!(transport, CommandTransport::Argv) {
820            cmd.arg(command);
821        }
822
823        let mut child = match cmd.spawn() {
824            Ok(c) => c,
825            Err(e) => {
826                return Err(ExecutionError::new(
827                    ExecutionErrorCode::SpawnError,
828                    format!("failed to spawn shell: {e}"),
829                ));
830            }
831        };
832        let pid = child.id();
833        if let Some(pid) = pid {
834            self.active_pids.lock().await.insert(pid);
835        }
836
837        // Stdin transport: write command then close.
838        if matches!(transport, CommandTransport::Stdin) {
839            if let Some(mut stdin) = child.stdin.take() {
840                use tokio::io::AsyncWriteExt;
841                let _ = stdin.write_all(command.as_bytes()).await;
842                let _ = stdin.flush().await;
843                drop(stdin);
844            }
845        }
846
847        // Collect stdout/stderr via separate read tasks, firing callbacks.
848        let mut stdout_buf = String::new();
849        let mut stderr_buf = String::new();
850        let cancel = options.cancel;
851        let mut on_stdout = options.on_stdout;
852        let mut on_stderr = options.on_stderr;
853
854        let mut stdout = child.stdout.take();
855        let mut stderr = child.stderr.take();
856
857        let stdout_task = async {
858            if let Some(s) = stdout.as_mut() {
859                let mut buf = vec![0u8; 8192];
860                use tokio::io::AsyncReadExt;
861                loop {
862                    let n = match s.read(&mut buf).await {
863                        Ok(0) => break,
864                        Ok(n) => n,
865                        Err(_) => break,
866                    };
867                    let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
868                    if let Some(cb) = on_stdout.as_mut() {
869                        // Callback errors are swallowed (TS sets callbackError;
870                        // v1 simplifies — the callback contract is best-effort).
871                        cb(&chunk);
872                    }
873                    stdout_buf.push_str(&chunk);
874                }
875            }
876        };
877        let stderr_task = async {
878            if let Some(s) = stderr.as_mut() {
879                let mut buf = vec![0u8; 8192];
880                use tokio::io::AsyncReadExt;
881                loop {
882                    let n = match s.read(&mut buf).await {
883                        Ok(0) => break,
884                        Ok(n) => n,
885                        Err(_) => break,
886                    };
887                    let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
888                    if let Some(cb) = on_stderr.as_mut() {
889                        cb(&chunk);
890                    }
891                    stderr_buf.push_str(&chunk);
892                }
893            }
894        };
895
896        // Wait for the child, with timeout + cancellation.
897        let mut timed_out = false;
898        let exit_status = {
899            // We pin stdout/stderr take handles + the wait future, then drive
900            // them concurrently inside a single select! block. `child` is only
901            // touched again AFTER the join completes (kill + reap), so the
902            // borrow ends cleanly.
903            let status_fut = async {
904                match (timeout, cancel) {
905                    (Some(to), Some(token)) => {
906                        tokio::select! {
907                            status = child.wait() => status,
908                            _ = tokio::time::sleep(to) => {
909                                timed_out = true;
910                                if let Some(pid) = child.id() {
911                                    kill_process_tree(pid).await;
912                                }
913                                child.wait().await
914                            }
915                            _ = token.cancelled() => {
916                                if let Some(pid) = child.id() {
917                                    kill_process_tree(pid).await;
918                                }
919                                child.wait().await
920                            }
921                        }
922                    }
923                    (Some(to), None) => match tokio::time::timeout(to, child.wait()).await {
924                        Ok(status) => status,
925                        Err(_) => {
926                            timed_out = true;
927                            if let Some(pid) = child.id() {
928                                kill_process_tree(pid).await;
929                            }
930                            child.wait().await
931                        }
932                    },
933                    (None, Some(token)) => {
934                        tokio::select! {
935                            status = child.wait() => status,
936                            _ = token.cancelled() => {
937                                if let Some(pid) = child.id() {
938                                    kill_process_tree(pid).await;
939                                }
940                                child.wait().await
941                            }
942                        }
943                    }
944                    (None, None) => child.wait().await,
945                }
946            };
947            // Run stdio + status concurrently.
948            tokio::pin!(status_fut);
949            let (_, _, status_res) = tokio::join!(stdout_task, stderr_task, status_fut);
950            status_res
951        };
952
953        // Give trailing stdio a brief grace period (the streams are already
954        // EOF-driven, so this is mostly belt-and-suspenders for late writes).
955        if timed_out {
956            let _ = tokio::time::sleep(std::time::Duration::from_millis(EXIT_STDIO_GRACE_MS)).await;
957        }
958
959        // Remove from tracked pids.
960        if let Some(pid) = pid {
961            self.active_pids.lock().await.remove(&pid);
962        }
963
964        // Resolution priority: timedOut → cancelled → status.
965        let status = match exit_status {
966            Ok(s) => s,
967            Err(e) => {
968                return Err(ExecutionError::new(
969                    ExecutionErrorCode::SpawnError,
970                    format!("child wait failed: {e}"),
971                ));
972            }
973        };
974        if timed_out {
975            return Err(ExecutionError::timeout(
976                options.timeout.map(|f| f as u64).unwrap_or(0),
977            ));
978        }
979        if cancel.map(|t| t.is_cancelled()).unwrap_or(false) {
980            return Err(ExecutionError::aborted());
981        }
982        let exit_code = status.code().unwrap_or(0);
983        Ok(ShellOutput {
984            stdout: stdout_buf,
985            stderr: stderr_buf,
986            exit_code,
987        })
988    }
989
990    async fn cleanup(&self) {
991        FileSystem::cleanup(self).await
992    }
993}
994
995#[async_trait]
996impl ExecutionEnv for OsExecutionEnv {}
997
998#[async_trait]
999impl MutatingEnv for OsExecutionEnv {
1000    fn as_env(&self) -> &dyn ExecutionEnv {
1001        self
1002    }
1003    fn mutation_registry(&self) -> &MutationQueueRegistry {
1004        &self.registry
1005    }
1006}
1007
1008/// Validate a timeout (seconds). Mirrors `resolveTimeoutMs` semantics.
1009fn resolve_timeout(timeout: Option<f64>) -> Result<Option<std::time::Duration>, ExecutionError> {
1010    match timeout {
1011        None => Ok(None),
1012        Some(secs) => {
1013            if !secs.is_finite() || secs <= 0.0 {
1014                return Err(ExecutionError::new(
1015                    ExecutionErrorCode::Timeout,
1016                    "Invalid timeout: must be a finite number of seconds",
1017                ));
1018            }
1019            let ms = secs * 1000.0;
1020            if ms > MAX_TIMEOUT_MS as f64 {
1021                return Err(ExecutionError::new(
1022                    ExecutionErrorCode::Timeout,
1023                    format!(
1024                        "Invalid timeout: maximum is {} seconds",
1025                        MAX_TIMEOUT_SECONDS
1026                    ),
1027                ));
1028            }
1029            Ok(Some(std::time::Duration::from_secs_f64(secs)))
1030        }
1031    }
1032}