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/// Environment variables the **host** sets for its own runtime plumbing, which
68/// must not be handed to a shell it spawns.
69///
70/// A spawned shell is a child process, so anything inherited reaches whatever
71/// that shell runs — including another `rpi`. `RPI_SESSION_ID` names the session
72/// *this process* serves, which is meaningless (and actively misleading) in a
73/// descendant: the child would either report the parent's session or, seeing the
74/// variable already present, refuse to publish its own. Both are wrong answers,
75/// and neither is visible from the child.
76///
77/// This is only the host's own half. An *extension* can publish variables with
78/// the same hazard, and it declares them itself; [`is_host_private_env`] is the
79/// function that answers the full question. Use it rather than reading this
80/// slice, or an extension's channel will be missed — which is precisely the bug
81/// that let `LANGFUSE_PI_PARENT_*` through.
82pub const HOST_PRIVATE_ENV: &[&str] = &[rpi_plugin_sdk::SESSION_ID_ENV];
83
84/// Whether `name` must not be inherited by a spawned shell.
85///
86/// Two sources, because the variables come from two places:
87///
88/// - [`HOST_PRIVATE_ENV`] — the host's own plumbing.
89/// - [`rpi_plugin_sdk::SUBAGENT_PARENT_ENV`] — the family an *extension*
90///   declares for subagent nesting. An inherited copy makes a plain `rpi` started
91///   from inside `bash` adopt its ancestor's trace, hang its root off the
92///   ancestor's root span, and call itself `Subagent Turn` instead of opening its
93///   own trace. A launcher that sets these on the child *it* starts is the
94///   supported nesting path, so this is about not *inheriting* them, not about
95///   forbidding the names.
96///
97/// The extension family is consulted **as the SDK's slice**, never as names
98/// restated here. Restating them is what the previous version did, and it is
99/// how the channel re-leaked when the plugin renamed them: the list went on
100/// matching a name nobody set. Going through the constant means a rename is a
101/// compile error, and a newly added family member is excluded automatically.
102pub fn is_host_private_env(name: &str) -> bool {
103    HOST_PRIVATE_ENV.contains(&name) || rpi_plugin_sdk::SUBAGENT_PARENT_ENV.contains(&name)
104}
105
106/// Real-OS `ExecutionEnv`. Clone shares the tracked-pid set + registry.
107pub struct OsExecutionEnv {
108    cwd: PathBuf,
109    shell_path: Option<PathBuf>,
110    shell_env: Option<HashMap<String, String>>,
111    active_pids: Arc<Mutex<HashSet<u32>>>,
112    registry: Arc<MutationQueueRegistry>,
113}
114
115impl OsExecutionEnv {
116    pub fn new() -> Self {
117        Self {
118            cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
119            shell_path: None,
120            shell_env: None,
121            active_pids: Arc::new(Mutex::new(HashSet::new())),
122            registry: Arc::new(MutationQueueRegistry::new()),
123        }
124    }
125
126    pub fn with_cwd(cwd: PathBuf) -> Self {
127        Self {
128            cwd,
129            shell_path: None,
130            shell_env: None,
131            active_pids: Arc::new(Mutex::new(HashSet::new())),
132            registry: Arc::new(MutationQueueRegistry::new()),
133        }
134    }
135
136    pub fn with_shell_path(mut self, shell_path: PathBuf) -> Self {
137        self.shell_path = Some(shell_path);
138        self
139    }
140
141    pub fn with_shell_env(mut self, env: HashMap<String, String>) -> Self {
142        self.shell_env = Some(env);
143        self
144    }
145
146    /// Resolve a path (tilde, file://, absolute/relative) against `cwd`. Pure.
147    fn resolve_path(cwd: &Path, path: &str) -> PathBuf {
148        let mut p = path.to_string();
149        // Tilde expansion (literal only).
150        if p == "~" {
151            p = home_dir_string();
152        } else if let Some(rest) = p.strip_prefix("~/") {
153            p = format!("{}/{}", home_dir_string(), rest);
154        } else if cfg!(windows) {
155            if let Some(rest) = p.strip_prefix("~\\") {
156                p = format!("{}\\{}", home_dir_string(), rest);
157            }
158        }
159        // file:// URL → path. Parse failure silently keeps the literal.
160        if p.starts_with("file://") {
161            if let Some(local) = file_url_to_path(&p) {
162                p = local;
163            }
164        }
165        let pb = PathBuf::from(&p);
166        if pb.is_absolute() {
167            normalize_absolute(&pb)
168        } else {
169            normalize_absolute(&cwd.join(pb))
170        }
171    }
172
173    /// Detect + return the shell config, or `ShellUnavailable`. Mirrors
174    /// `getShellConfig`.
175    async fn get_shell_config(&self) -> Result<ShellConfig, ExecutionError> {
176        // Explicit override.
177        if let Some(sp) = &self.shell_path {
178            if tokio::fs::metadata(sp).await.is_ok() {
179                return Ok(bash_shell_config(sp.clone()));
180            }
181            return Err(ExecutionError::new(
182                ExecutionErrorCode::ShellUnavailable,
183                format!("Custom shell path not found: {}", sp.display()),
184            ));
185        }
186        // Windows probe.
187        #[cfg(windows)]
188        {
189            if let Some(cfg) = windows_probe_bash().await {
190                return Ok(cfg);
191            }
192            return Err(ExecutionError::new(
193                ExecutionErrorCode::ShellUnavailable,
194                "bash not found. Install Git for Windows / MSYS2 / Cygwin, or set shellPath.",
195            ));
196        }
197        // POSIX.
198        #[cfg(not(windows))]
199        {
200            if tokio::fs::metadata("/bin/bash").await.is_ok() {
201                return Ok(ShellConfig {
202                    shell: PathBuf::from("/bin/bash"),
203                    args: vec!["-c".to_string()],
204                    transport: CommandTransport::Argv,
205                });
206            }
207            // `which bash` fallback.
208            if let Some(found) =
209                run_command("which", &["bash"], std::time::Duration::from_millis(5000)).await
210            {
211                let trimmed = found.trim();
212                if !trimmed.is_empty() && tokio::fs::metadata(trimmed).await.is_ok() {
213                    return Ok(ShellConfig {
214                        shell: PathBuf::from(trimmed),
215                        args: vec!["-c".to_string()],
216                        transport: CommandTransport::Argv,
217                    });
218                }
219            }
220            // sh fallback.
221            return Ok(ShellConfig {
222                shell: PathBuf::from("sh"),
223                args: vec!["-c".to_string()],
224                transport: CommandTransport::Argv,
225            });
226        }
227    }
228}
229
230impl Default for OsExecutionEnv {
231    fn default() -> Self {
232        Self::new()
233    }
234}
235
236fn home_dir_string() -> String {
237    dirs::home_dir()
238        .map(|p| p.to_string_lossy().into_owned())
239        .unwrap_or_default()
240}
241
242fn normalize_absolute(p: &Path) -> PathBuf {
243    // std::path doesn't expose ".."/"." collapse without canonicalize (which
244    // hits the FS). Rebuild component-by-component.
245    let mut out = PathBuf::new();
246    for comp in p.components() {
247        match comp {
248            std::path::Component::ParentDir => {
249                out.pop();
250            }
251            std::path::Component::CurDir => {}
252            other => out.push(other),
253        }
254    }
255    out
256}
257
258fn file_url_to_path(url: &str) -> Option<String> {
259    // Minimal file:// → path conversion. Handles `file:///abs/path` and
260    // Windows `file:///C:/...`. Parse failure → None (caller keeps literal).
261    let rest = url.strip_prefix("file://")?;
262    if let Some(drive_rest) = rest.strip_prefix('/') {
263        // `/C:/...` (Windows drive) or `/abs/path`.
264        if drive_rest.len() >= 2 && drive_rest.as_bytes()[1] == b':' {
265            return Some(drive_rest.to_string());
266        }
267        return Some(format!("/{}", drive_rest));
268    }
269    Some(rest.to_string())
270}
271
272fn bash_shell_config(shell: PathBuf) -> ShellConfig {
273    if is_legacy_wsl_bash_path(&shell) {
274        ShellConfig {
275            shell,
276            args: vec!["-s".to_string()],
277            transport: CommandTransport::Stdin,
278        }
279    } else {
280        ShellConfig {
281            shell,
282            args: vec!["-c".to_string()],
283            transport: CommandTransport::Argv,
284        }
285    }
286}
287
288fn is_legacy_wsl_bash_path(p: &Path) -> bool {
289    let s = p.to_string_lossy().replace('/', "\\").to_lowercase();
290    let re = regex::Regex::new(r"^[a-z]:\\windows\\(?:system32|sysnative)\\bash\.exe$").unwrap();
291    re.is_match(&s)
292}
293
294#[cfg(windows)]
295async fn windows_probe_bash() -> Option<ShellConfig> {
296    let pf = std::env::var("ProgramFiles").ok();
297    let pf86 = std::env::var("ProgramFiles(x86)").ok();
298    let mut candidates: Vec<PathBuf> = Vec::new();
299    if let Some(p) = pf {
300        candidates.push(PathBuf::from(p).join("Git").join("bin").join("bash.exe"));
301    }
302    if let Some(p) = pf86 {
303        candidates.push(PathBuf::from(p).join("Git").join("bin").join("bash.exe"));
304    }
305    for c in &candidates {
306        if tokio::fs::metadata(c).await.is_ok() {
307            return Some(bash_shell_config(c.clone()));
308        }
309    }
310    // `where bash.exe`.
311    if let Some(found) = run_command(
312        "where",
313        &["bash.exe"],
314        std::time::Duration::from_millis(5000),
315    )
316    .await
317    {
318        let first = found.lines().next().unwrap_or("").trim().to_string();
319        if !first.is_empty() && tokio::fs::metadata(&first).await.is_ok() {
320            return Some(bash_shell_config(PathBuf::from(first)));
321        }
322    }
323    None
324}
325
326/// Run a quick command, capture stdout, with a timeout. Mirrors `runCommand`
327/// (used only by shell detection). Returns `None` on spawn failure / timeout /
328/// empty stdout.
329async fn run_command(cmd: &str, args: &[&str], timeout: std::time::Duration) -> Option<String> {
330    let mut command = tokio::process::Command::new(cmd);
331    command.args(args);
332    command.stdout(std::process::Stdio::piped());
333    command.stderr(std::process::Stdio::null());
334    command.stdin(std::process::Stdio::null());
335    #[cfg(windows)]
336    {
337        // Hide the console window for the probe.
338        use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
339        command.creation_flags(CREATE_NO_WINDOW);
340    }
341    let child = command.spawn().ok()?;
342    let out = match tokio::time::timeout(timeout, child.wait_with_output()).await {
343        Ok(Ok(o)) => o,
344        _ => return None,
345    };
346    if !out.status.success() {
347        return None;
348    }
349    let s = String::from_utf8_lossy(&out.stdout).into_owned();
350    if s.trim().is_empty() {
351        None
352    } else {
353        Some(s)
354    }
355}
356
357/// Kill a process tree. Windows: `taskkill /F /T /PID`. POSIX: `kill -PG` with
358/// fallback to `kill -PID`. Best-effort, errors swallowed.
359async fn kill_process_tree(pid: u32) {
360    #[cfg(windows)]
361    {
362        use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
363        let mut cmd = tokio::process::Command::new("taskkill");
364        cmd.args(&["/F", "/T", "/PID", &pid.to_string()]);
365        cmd.creation_flags(CREATE_NO_WINDOW);
366        cmd.stdin(std::process::Stdio::null());
367        cmd.stdout(std::process::Stdio::null());
368        cmd.stderr(std::process::Stdio::null());
369        // Wait for taskkill itself to finish. Spawning it fire-and-forget lets
370        // the shell's `child.wait()` race the tree termination, and a listener
371        // can keep its inherited stdout/stderr handles open long enough to
372        // deadlock the capture join after Ctrl+C.
373        let _ = cmd.status().await;
374    }
375    #[cfg(unix)]
376    {
377        use nix::sys::signal::{kill, Signal};
378        use nix::unistd::Pid;
379        // Process group: negative pid.
380        let pg = Pid::from_raw(-(pid as i32));
381        if kill(pg, Signal::SIGKILL).is_err() {
382            let _ = kill(Pid::from_raw(pid as i32), Signal::SIGKILL);
383        }
384    }
385    #[cfg(not(any(windows, unix)))]
386    {
387        let _ = pid;
388    }
389}
390
391#[async_trait]
392impl FileSystem for OsExecutionEnv {
393    fn cwd(&self) -> &Path {
394        &self.cwd
395    }
396
397    async fn absolute_path(
398        &self,
399        path: &str,
400        cancel: Option<&CancellationToken>,
401    ) -> Result<PathBuf, FileError> {
402        check_cancel_file(cancel, Some(path))?;
403        Ok(Self::resolve_path(&self.cwd, path))
404    }
405
406    async fn join_path(
407        &self,
408        parts: &[&str],
409        cancel: Option<&CancellationToken>,
410    ) -> Result<PathBuf, FileError> {
411        check_cancel_file(cancel, None)?;
412        let mut out = PathBuf::new();
413        for p in parts {
414            out.push(p);
415        }
416        Ok(out)
417    }
418
419    async fn read_text_file(
420        &self,
421        path: &str,
422        cancel: Option<&CancellationToken>,
423    ) -> Result<String, FileError> {
424        check_cancel_file(cancel, Some(path))?;
425        let abs = Self::resolve_path(&self.cwd, path);
426        let bytes = read_cancellable(&abs, cancel).await?;
427        String::from_utf8(bytes).map_err(|e| {
428            FileError::new(FileErrorCode::Invalid, format!("invalid utf-8: {e}"))
429                .with_path(abs.to_string_lossy())
430        })
431    }
432
433    async fn read_text_lines(
434        &self,
435        path: &str,
436        max_lines: Option<usize>,
437        cancel: Option<&CancellationToken>,
438    ) -> Result<Vec<String>, FileError> {
439        check_cancel_file(cancel, Some(path))?;
440        if max_lines.map(|m| m == 0).unwrap_or(false) {
441            return Ok(Vec::new());
442        }
443        let text = self.read_text_file(path, cancel).await?;
444        let mut lines: Vec<String> = text.split('\n').map(|s| s.to_string()).collect();
445        if text.ends_with('\n') {
446            if lines.last().map(|s| s.is_empty()).unwrap_or(false) {
447                lines.pop();
448            }
449        }
450        if let Some(m) = max_lines {
451            lines.truncate(m);
452        }
453        Ok(lines)
454    }
455
456    async fn read_binary_file(
457        &self,
458        path: &str,
459        cancel: Option<&CancellationToken>,
460    ) -> Result<Vec<u8>, FileError> {
461        check_cancel_file(cancel, Some(path))?;
462        let abs = Self::resolve_path(&self.cwd, path);
463        read_cancellable(&abs, cancel).await
464    }
465
466    async fn write_file(
467        &self,
468        path: &str,
469        content: FileContent,
470        cancel: Option<&CancellationToken>,
471    ) -> Result<(), FileError> {
472        check_cancel_file(cancel, Some(path))?;
473        let abs = Self::resolve_path(&self.cwd, path);
474        // mkdir parent (recursive).
475        if let Some(parent) = abs.parent() {
476            if !parent.as_os_str().is_empty() {
477                if let Err(e) = tokio::fs::create_dir_all(parent).await {
478                    return Err(io_to_file_error(e, Some(&abs.to_string_lossy())));
479                }
480            }
481        }
482        check_cancel_file(cancel, Some(&abs.to_string_lossy()))?;
483        write_cancellable(&abs, content.as_bytes(), cancel).await
484    }
485
486    async fn append_file(
487        &self,
488        path: &str,
489        content: FileContent,
490        cancel: Option<&CancellationToken>,
491    ) -> Result<(), FileError> {
492        check_cancel_file(cancel, Some(path))?;
493        let abs = Self::resolve_path(&self.cwd, path);
494        if let Some(parent) = abs.parent() {
495            if !parent.as_os_str().is_empty() {
496                let _ = tokio::fs::create_dir_all(parent).await;
497            }
498        }
499        use tokio::io::AsyncWriteExt;
500        let res = match tokio::fs::OpenOptions::new()
501            .create(true)
502            .append(true)
503            .open(&abs)
504            .await
505        {
506            Ok(mut f) => {
507                // Tokio may acknowledge a buffered write before the blocking
508                // filesystem operation completes. Await it before returning so
509                // an immediate read/metadata call observes the appended bytes.
510                match f.write_all(content.as_bytes()).await {
511                    Ok(()) => f.flush().await,
512                    Err(error) => Err(error),
513                }
514            }
515            Err(e) => return Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
516        };
517        res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
518    }
519
520    async fn rename_file(
521        &self,
522        source: &str,
523        dest: &str,
524        cancel: Option<&CancellationToken>,
525    ) -> Result<(), FileError> {
526        check_cancel_file(cancel, Some(source))?;
527        let src = Self::resolve_path(&self.cwd, source);
528        let dst = Self::resolve_path(&self.cwd, dest);
529        tokio::fs::rename(&src, &dst)
530            .await
531            .map_err(|e| io_to_file_error(e, Some(&src.to_string_lossy())))
532    }
533
534    async fn file_info(
535        &self,
536        path: &str,
537        cancel: Option<&CancellationToken>,
538    ) -> Result<FileInfo, FileError> {
539        check_cancel_file(cancel, Some(path))?;
540        let abs = Self::resolve_path(&self.cwd, path);
541        let meta = tokio::fs::symlink_metadata(&abs)
542            .await
543            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
544        let ft = meta.file_type();
545        let kind = if ft.is_file() {
546            FileKind::File
547        } else if ft.is_dir() {
548            FileKind::Directory
549        } else if ft.is_symlink() {
550            FileKind::Symlink
551        } else {
552            return Err(
553                FileError::new(FileErrorCode::Invalid, "Unsupported file type")
554                    .with_path(abs.to_string_lossy()),
555            );
556        };
557        let mtime_ms = meta
558            .modified()
559            .ok()
560            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
561            .map(|d| d.as_millis() as i64)
562            .unwrap_or(0);
563        Ok(FileInfo {
564            name: abs
565                .file_name()
566                .map(|n| n.to_string_lossy().into_owned())
567                .unwrap_or_else(|| abs.to_string_lossy().into_owned()),
568            path: abs.clone(),
569            kind,
570            size: meta.len(),
571            mtime_ms,
572        })
573    }
574
575    async fn list_dir(
576        &self,
577        path: &str,
578        cancel: Option<&CancellationToken>,
579    ) -> Result<Vec<FileInfo>, FileError> {
580        check_cancel_file(cancel, Some(path))?;
581        let abs = Self::resolve_path(&self.cwd, path);
582        let mut entries = tokio::fs::read_dir(&abs)
583            .await
584            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?;
585        let mut out = Vec::new();
586        while let Some(entry) = entries
587            .next_entry()
588            .await
589            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))?
590        {
591            if let Some(t) = cancel {
592                if t.is_cancelled() {
593                    return Err(FileError::aborted().with_path(abs.to_string_lossy()));
594                }
595            }
596            let ep = entry.path();
597            let meta = match tokio::fs::symlink_metadata(&ep).await {
598                Ok(m) => m,
599                Err(e) => {
600                    // Skip unsupported types silently; propagate real stat errors.
601                    let code = crate::error::io_to_file_code(&e);
602                    if code == FileErrorCode::NotSupported {
603                        continue;
604                    }
605                    return Err(io_to_file_error(e, Some(&ep.to_string_lossy())));
606                }
607            };
608            let ft = meta.file_type();
609            let kind = if ft.is_file() {
610                FileKind::File
611            } else if ft.is_dir() {
612                FileKind::Directory
613            } else if ft.is_symlink() {
614                FileKind::Symlink
615            } else {
616                continue; // silently skip sockets/fifos/etc.
617            };
618            let mtime_ms = meta
619                .modified()
620                .ok()
621                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
622                .map(|d| d.as_millis() as i64)
623                .unwrap_or(0);
624            out.push(FileInfo {
625                name: entry.file_name().to_string_lossy().into_owned(),
626                path: ep,
627                kind,
628                size: meta.len(),
629                mtime_ms,
630            });
631        }
632        Ok(out)
633    }
634
635    async fn canonical_path(
636        &self,
637        path: &str,
638        cancel: Option<&CancellationToken>,
639    ) -> Result<PathBuf, FileError> {
640        check_cancel_file(cancel, Some(path))?;
641        let abs = Self::resolve_path(&self.cwd, path);
642        tokio::fs::canonicalize(&abs)
643            .await
644            .map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
645    }
646
647    async fn exists(
648        &self,
649        path: &str,
650        cancel: Option<&CancellationToken>,
651    ) -> Result<bool, FileError> {
652        check_cancel_file(cancel, Some(path))?;
653        let abs = Self::resolve_path(&self.cwd, path);
654        match tokio::fs::symlink_metadata(&abs).await {
655            Ok(_) => Ok(true),
656            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
657            Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
658        }
659    }
660
661    async fn create_dir(
662        &self,
663        path: &str,
664        recursive: bool,
665        cancel: Option<&CancellationToken>,
666    ) -> Result<(), FileError> {
667        check_cancel_file(cancel, Some(path))?;
668        let abs = Self::resolve_path(&self.cwd, path);
669        let res = if recursive {
670            tokio::fs::create_dir_all(&abs).await
671        } else {
672            tokio::fs::create_dir(&abs).await
673        };
674        res.map_err(|e| io_to_file_error(e, Some(&abs.to_string_lossy())))
675    }
676
677    async fn remove(
678        &self,
679        path: &str,
680        recursive: bool,
681        force: bool,
682        cancel: Option<&CancellationToken>,
683    ) -> Result<(), FileError> {
684        check_cancel_file(cancel, Some(path))?;
685        let abs = Self::resolve_path(&self.cwd, path);
686        let res = if recursive {
687            tokio::fs::remove_dir_all(&abs).await
688        } else {
689            // Try file first, then empty dir.
690            match tokio::fs::remove_file(&abs).await {
691                Ok(()) => Ok(()),
692                Err(_) => tokio::fs::remove_dir(&abs).await,
693            }
694        };
695        match res {
696            Ok(()) => Ok(()),
697            Err(e) if e.kind() == std::io::ErrorKind::NotFound && force => Ok(()),
698            Err(e) => Err(io_to_file_error(e, Some(&abs.to_string_lossy()))),
699        }
700    }
701
702    async fn create_temp_dir(
703        &self,
704        prefix: Option<&str>,
705        cancel: Option<&CancellationToken>,
706    ) -> Result<PathBuf, FileError> {
707        check_cancel_file(cancel, None)?;
708        let prefix = prefix.unwrap_or("tmp-");
709        // Use tempfile's NamedTempFile-less builder for a dir.
710        let tmp =
711            tempfile::tempdir_in(std::env::temp_dir()).map_err(|e| io_to_file_error(e, None))?;
712        // tempfile picks a random name; rename to include the prefix.
713        let path = tmp.path().to_path_buf();
714        std::mem::forget(tmp); // keep the dir alive; caller manages cleanup.
715                               // Build a prefixed sibling.
716        let final_path = std::env::temp_dir().join(format!("{}{}", prefix, random_suffix()));
717        // Rename the tempdir into the prefixed path.
718        if let Err(e) = tokio::fs::rename(&path, &final_path).await {
719            // Fallback: just return the original random path.
720            let _ = e;
721            return Ok(path);
722        }
723        Ok(final_path)
724    }
725
726    async fn create_temp_file(
727        &self,
728        prefix: &str,
729        suffix: &str,
730        cancel: Option<&CancellationToken>,
731    ) -> Result<PathBuf, FileError> {
732        check_cancel_file(cancel, None)?;
733        // Create an isolated temp dir (matching the TS pattern of one dir per
734        // file), then write an empty file named `<prefix><random><suffix>`.
735        let dir = self.create_temp_dir(Some("tmp-"), cancel).await?;
736        let name = format!("{}{}{}", prefix, random_suffix(), suffix);
737        let path = dir.join(name);
738        tokio::fs::File::create(&path)
739            .await
740            .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
741        Ok(path)
742    }
743
744    async fn cleanup(&self) {
745        let pids: Vec<u32> = self.active_pids.lock().await.iter().copied().collect();
746        for pid in pids {
747            kill_process_tree(pid).await;
748        }
749        self.active_pids.lock().await.clear();
750    }
751}
752
753/// Read a file to bytes, honoring cooperative cancellation.
754async fn read_cancellable(
755    path: &Path,
756    cancel: Option<&CancellationToken>,
757) -> Result<Vec<u8>, FileError> {
758    let read = tokio::fs::read(path);
759    let res = match cancel {
760        Some(t) => tokio::select! {
761            r = read => r,
762            _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
763        },
764        None => read.await,
765    };
766    res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
767}
768
769/// Write bytes to a file (truncate), honoring cooperative cancellation.
770async fn write_cancellable(
771    path: &Path,
772    bytes: &[u8],
773    cancel: Option<&CancellationToken>,
774) -> Result<(), FileError> {
775    use tokio::io::AsyncWriteExt;
776    let f = tokio::fs::File::create(path)
777        .await
778        .map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))?;
779    let write = async {
780        let mut f = f;
781        f.write_all(bytes).await?;
782        f.flush().await?;
783        Ok::<(), std::io::Error>(())
784    };
785    let res = match cancel {
786        Some(t) => tokio::select! {
787            r = write => r,
788            _ = t.cancelled() => return Err(FileError::aborted().with_path(path.to_string_lossy())),
789        },
790        None => write.await,
791    };
792    res.map_err(|e| io_to_file_error(e, Some(&path.to_string_lossy())))
793}
794
795fn random_suffix() -> String {
796    // Short random suffix via uuid v4 (no Time—it's a UUID, not a timestamp).
797    uuid::Uuid::new_v4().simple().to_string()
798}
799
800#[async_trait]
801impl Shell for OsExecutionEnv {
802    async fn exec<'a>(
803        &'a self,
804        command: &str,
805        options: ShellExecOptions<'a>,
806    ) -> Result<ShellOutput, ExecutionError> {
807        // Pre-flight abort.
808        check_cancel_exec(options.cancel)?;
809        // Resolve timeout.
810        let timeout = match resolve_timeout(options.timeout) {
811            Ok(t) => t,
812            Err(e) => return Err(e),
813        };
814        // Resolve cwd.
815        let cwd = match &options.cwd {
816            Some(c) => Self::resolve_path(&self.cwd, &c.to_string_lossy()),
817            None => self.cwd.clone(),
818        };
819        // Shell config. An explicit override is used by native shell tools
820        // such as PowerShell; otherwise preserve the default Bash behavior.
821        let shell_cfg = match &options.shell {
822            Some(override_shell) => ShellConfig {
823                shell: override_shell.program.clone(),
824                args: override_shell.args.clone(),
825                transport: CommandTransport::Argv,
826            },
827            None => self.get_shell_config().await?,
828        };
829        // Verify cwd exists.
830        if !cwd.exists() {
831            return Err(ExecutionError::new(
832                ExecutionErrorCode::SpawnError,
833                format!(
834                    "Working directory does not exist: {}\nCannot execute bash commands.",
835                    cwd.display()
836                ),
837            ));
838        }
839
840        // Build the spawn.
841        let mut cmd = tokio::process::Command::new(&shell_cfg.shell);
842        cmd.current_dir(&cwd);
843        cmd.env_clear();
844        // Environment: inherit + shell_env + per-call. The host's own private
845        // plumbing (`HOST_PRIVATE_ENV`) is skipped: it describes *this* process,
846        // not the shell or anything the shell runs, and inheriting it makes a
847        // nested `rpi` misreport its session. A caller that genuinely wants a
848        // value passed down can still set it explicitly through `env`/`shell_env`
849        // below (those are applied after this loop and win).
850        if options.inherit_env {
851            for (k, v) in std::env::vars() {
852                if is_host_private_env(&k) {
853                    continue;
854                }
855                cmd.env(k, v);
856            }
857        }
858        if let Some(base) = &self.shell_env {
859            for (k, v) in base {
860                cmd.env(k, v);
861            }
862        }
863        if let Some(extra) = &options.env {
864            for (k, v) in extra {
865                cmd.env(k, v);
866            }
867        }
868        // stdio.
869        cmd.stdin(std::process::Stdio::piped());
870        cmd.stdout(std::process::Stdio::piped());
871        cmd.stderr(std::process::Stdio::piped());
872        #[cfg(unix)]
873        {
874            use std::os::unix::process::CommandExt;
875            cmd.process_group(0); // child becomes its own group leader.
876        }
877        #[cfg(windows)]
878        {
879            use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW;
880            cmd.creation_flags(CREATE_NO_WINDOW);
881        }
882
883        // Args: stdin transport → `bash -s` (no command argv); argv → append.
884        let transport = shell_cfg.transport;
885        cmd.args(&shell_cfg.args);
886        if matches!(transport, CommandTransport::Argv) {
887            cmd.arg(command);
888        }
889
890        let mut child = match cmd.spawn() {
891            Ok(c) => c,
892            Err(e) => {
893                return Err(ExecutionError::new(
894                    ExecutionErrorCode::SpawnError,
895                    format!("failed to spawn shell: {e}"),
896                ));
897            }
898        };
899        let pid = child.id();
900        if let Some(pid) = pid {
901            self.active_pids.lock().await.insert(pid);
902        }
903
904        // Stdin transport: write command then close. Closing happens on BOTH
905        // transports, and deliberately: anything that prompts (`npx` asking to
906        // install, `npm init`, a `read` in a script) must see EOF and fail fast,
907        // not block on a pipe that nothing will ever write to or close. tokio's
908        // `Child::wait` also drops stdin as a deadlock guard, but relying on that
909        // would make the guarantee a side effect of an implementation detail.
910        let mut stdin = child.stdin.take();
911        if matches!(transport, CommandTransport::Stdin) {
912            if let Some(stdin) = stdin.as_mut() {
913                use tokio::io::AsyncWriteExt;
914                let _ = stdin.write_all(command.as_bytes()).await;
915                let _ = stdin.flush().await;
916            }
917        }
918        drop(stdin);
919
920        // Collect stdout/stderr via separate read tasks, firing callbacks.
921        let mut stdout_buf = String::new();
922        let mut stderr_buf = String::new();
923        let cancel = options.cancel;
924        let mut on_stdout = options.on_stdout;
925        let mut on_stderr = options.on_stderr;
926
927        let mut stdout = child.stdout.take();
928        let mut stderr = child.stderr.take();
929
930        let stdout_task = async {
931            if let Some(s) = stdout.as_mut() {
932                let mut buf = vec![0u8; 8192];
933                use tokio::io::AsyncReadExt;
934                loop {
935                    let read = s.read(&mut buf);
936                    let read_result = match cancel {
937                        Some(token) => tokio::select! {
938                            result = read => result,
939                            _ = token.cancelled() => break,
940                        },
941                        None => read.await,
942                    };
943                    let n = match read_result {
944                        Ok(0) => break,
945                        Ok(n) => n,
946                        Err(_) => break,
947                    };
948                    let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
949                    if let Some(cb) = on_stdout.as_mut() {
950                        // Callback errors are swallowed (TS sets callbackError;
951                        // v1 simplifies — the callback contract is best-effort).
952                        cb(&chunk);
953                    }
954                    stdout_buf.push_str(&chunk);
955                }
956            }
957        };
958        let stderr_task = async {
959            if let Some(s) = stderr.as_mut() {
960                let mut buf = vec![0u8; 8192];
961                use tokio::io::AsyncReadExt;
962                loop {
963                    let read = s.read(&mut buf);
964                    let read_result = match cancel {
965                        Some(token) => tokio::select! {
966                            result = read => result,
967                            _ = token.cancelled() => break,
968                        },
969                        None => read.await,
970                    };
971                    let n = match read_result {
972                        Ok(0) => break,
973                        Ok(n) => n,
974                        Err(_) => break,
975                    };
976                    let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
977                    if let Some(cb) = on_stderr.as_mut() {
978                        cb(&chunk);
979                    }
980                    stderr_buf.push_str(&chunk);
981                }
982            }
983        };
984
985        // Wait for the child, with timeout + cancellation.
986        let mut timed_out = false;
987        let exit_status = {
988            // We pin stdout/stderr take handles + the wait future, then drive
989            // them concurrently inside a single select! block. `child` is only
990            // touched again AFTER the join completes (kill + reap), so the
991            // borrow ends cleanly.
992            let status_fut = async {
993                match (timeout, cancel) {
994                    (Some(to), Some(token)) => {
995                        tokio::select! {
996                            status = child.wait() => status,
997                            _ = tokio::time::sleep(to) => {
998                                timed_out = true;
999                                if let Some(pid) = child.id() {
1000                                    kill_process_tree(pid).await;
1001                                }
1002                                child.wait().await
1003                            }
1004                            _ = token.cancelled() => {
1005                                if let Some(pid) = child.id() {
1006                                    kill_process_tree(pid).await;
1007                                }
1008                                child.wait().await
1009                            }
1010                        }
1011                    }
1012                    (Some(to), None) => match tokio::time::timeout(to, child.wait()).await {
1013                        Ok(status) => status,
1014                        Err(_) => {
1015                            timed_out = true;
1016                            if let Some(pid) = child.id() {
1017                                kill_process_tree(pid).await;
1018                            }
1019                            child.wait().await
1020                        }
1021                    },
1022                    (None, Some(token)) => {
1023                        tokio::select! {
1024                            status = child.wait() => status,
1025                            _ = token.cancelled() => {
1026                                if let Some(pid) = child.id() {
1027                                    kill_process_tree(pid).await;
1028                                }
1029                                child.wait().await
1030                            }
1031                        }
1032                    }
1033                    (None, None) => child.wait().await,
1034                }
1035            };
1036            // Run stdio + status concurrently — but the CHILD is the driver:
1037            // once it is reaped the command is over, and the call has to end even
1038            // if something else still holds the stdout/stderr pipes open.
1039            //
1040            // This is the "`npm run` 卡住" shape. A script that starts a watcher,
1041            // a dev-server or a background daemon leaves a grandchild running with
1042            // the inherited pipes, so reading to EOF waits for a process the
1043            // command no longer owns. No timeout rescues that: the timeout wraps
1044            // `child.wait()` and the child already exited successfully.
1045            tokio::pin!(status_fut, stdout_task, stderr_task);
1046            let status_res = (&mut status_fut).await;
1047            // Drain what is already in the pipe — a completed write is already in
1048            // the pipe buffer, so this is not a race — then stop, without waiting
1049            // for an EOF that may never come. A command whose own process is gone
1050            // should not be able to hold the call open.
1051            let _ = tokio::time::timeout(
1052                std::time::Duration::from_millis(EXIT_STDIO_GRACE_MS),
1053                async {
1054                    let _ = tokio::join!(&mut stdout_task, &mut stderr_task);
1055                },
1056            )
1057            .await;
1058            status_res
1059        };
1060
1061        // Give trailing stdio a brief grace period. Only the timed-out and
1062        // cancelled paths still owe a wait: on the normal path the drain above
1063        // already capped it.
1064        if timed_out {
1065            let _ = tokio::time::sleep(std::time::Duration::from_millis(EXIT_STDIO_GRACE_MS)).await;
1066        }
1067
1068        // Remove from tracked pids.
1069        if let Some(pid) = pid {
1070            self.active_pids.lock().await.remove(&pid);
1071        }
1072
1073        // Resolution priority: timedOut → cancelled → status.
1074        let status = match exit_status {
1075            Ok(s) => s,
1076            Err(e) => {
1077                return Err(ExecutionError::new(
1078                    ExecutionErrorCode::SpawnError,
1079                    format!("child wait failed: {e}"),
1080                ));
1081            }
1082        };
1083        if timed_out {
1084            return Err(ExecutionError::timeout(
1085                options.timeout.map(|f| f as u64).unwrap_or(0),
1086            ));
1087        }
1088        if cancel.map(|t| t.is_cancelled()).unwrap_or(false) {
1089            return Err(ExecutionError::aborted());
1090        }
1091        let exit_code = status.code().unwrap_or(0);
1092        Ok(ShellOutput {
1093            stdout: stdout_buf,
1094            stderr: stderr_buf,
1095            exit_code,
1096        })
1097    }
1098
1099    async fn cleanup(&self) {
1100        FileSystem::cleanup(self).await
1101    }
1102}
1103
1104#[async_trait]
1105impl ExecutionEnv for OsExecutionEnv {}
1106
1107#[async_trait]
1108impl MutatingEnv for OsExecutionEnv {
1109    fn as_env(&self) -> &dyn ExecutionEnv {
1110        self
1111    }
1112    fn mutation_registry(&self) -> &MutationQueueRegistry {
1113        &self.registry
1114    }
1115}
1116
1117/// Validate a timeout (seconds). Mirrors `resolveTimeoutMs` semantics.
1118fn resolve_timeout(timeout: Option<f64>) -> Result<Option<std::time::Duration>, ExecutionError> {
1119    match timeout {
1120        None => Ok(None),
1121        Some(secs) => {
1122            if !secs.is_finite() || secs <= 0.0 {
1123                return Err(ExecutionError::new(
1124                    ExecutionErrorCode::Timeout,
1125                    "Invalid timeout: must be a finite number of seconds",
1126                ));
1127            }
1128            let ms = secs * 1000.0;
1129            if ms > MAX_TIMEOUT_MS as f64 {
1130                return Err(ExecutionError::new(
1131                    ExecutionErrorCode::Timeout,
1132                    format!(
1133                        "Invalid timeout: maximum is {} seconds",
1134                        MAX_TIMEOUT_SECONDS
1135                    ),
1136                ));
1137            }
1138            Ok(Some(std::time::Duration::from_secs_f64(secs)))
1139        }
1140    }
1141}