Skip to main content

car_engine/
spawn.rs

1//! Cross-platform spawning for an external program named by a bare command
2//! string — an MCP server's `command`, a subprocess tool's `command`, a
3//! foreman verify command's program.
4//!
5//! On Windows, npm installs CLIs like `npx`/`npm`/`yarn`/`pnpm` as `.cmd`
6//! batch shims (there is no `npx.exe`). `CreateProcess` — which `std`'s and
7//! `tokio`'s `Command::new` use — cannot execute a batch file directly (it
8//! fails with os error 193, "%1 is not a valid Win32 application"), and
9//! `Command::new` only appends `.exe` when searching PATH, so it never even
10//! finds `npx.cmd`. The canonical MCP launch (`npx -y @modelcontextprotocol/
11//! server-…`) therefore fails outright on Windows. A shim must be run through
12//! `cmd /C`, which does its own PATHEXT resolution.
13//!
14//! This is the bare-name analogue of `car_external_agents::detection::
15//! base_command` (which routes an already-resolved `.cmd`/`.bat` *path*
16//! through `cmd /C`). Here the input is a command *name*, so on Windows we
17//! resolve it via PATH + PATHEXT to decide whether it is a shim before
18//! deciding how to spawn it. Off Windows this is a plain `Command::new`.
19
20use tokio::process::Command;
21
22/// Build a `tokio::process::Command` for `program`, routing a Windows
23/// `.cmd`/`.bat` shim through `cmd /C`. Off Windows — and for a real `.exe`
24/// target or an unresolvable name — the program is spawned directly. The
25/// caller appends args / cwd / env / stdio as usual.
26pub fn program_command(program: &str) -> Command {
27    #[cfg(windows)]
28    {
29        if let Some(shim) = windows_batch_shim(program) {
30            let mut c = Command::new("cmd");
31            c.arg("/C").arg(shim);
32            // A `.cmd`/`.bat` shim (npx, npm, claude) resolves its real
33            // interpreter — usually `node` — through PATH, so an over-long PATH
34            // that cmd drops breaks the shim itself. See crate::win_env.
35            if let Some(path) = crate::win_env::cmd_path_override() {
36                c.env("PATH", path);
37            }
38            return c;
39        }
40    }
41    Command::new(program)
42}
43
44/// True when `p`'s extension is a Windows batch extension (`.cmd`/`.bat`),
45/// case-insensitively — the kind `CreateProcess` can't execute directly.
46#[cfg(windows)]
47fn has_batch_ext(p: &std::path::Path) -> bool {
48    p.extension()
49        .and_then(|e| e.to_str())
50        .map(|e| {
51            let e = e.to_ascii_lowercase();
52            e == "cmd" || e == "bat"
53        })
54        .unwrap_or(false)
55}
56
57/// On Windows, decide whether `program` should be launched through `cmd /C`
58/// because it is (or resolves to) a `.cmd`/`.bat` batch shim. Returns the
59/// value to hand to `cmd /C` (the resolved path for a bare name, or the
60/// original string when it already carries a path/extension), or `None` for a
61/// real `.exe`, an extensionless native binary, or an unresolvable name.
62#[cfg(windows)]
63fn windows_batch_shim(program: &str) -> Option<std::path::PathBuf> {
64    use std::path::Path;
65    let p = Path::new(program);
66
67    // Already an explicit path, or a name that already carries an extension:
68    // judge it directly rather than PATH-searching. A batch extension routes
69    // through `cmd /C` (which resolves a bare `foo.cmd` on PATH itself); a
70    // real `.exe`/binary spawns directly.
71    if p.extension().is_some() || p.components().count() > 1 {
72        return has_batch_ext(p).then(|| p.to_path_buf());
73    }
74
75    // Bare name (e.g. "npx"): resolve the way the shell does — for each PATH
76    // dir, try each PATHEXT extension in order; the first hit wins. The
77    // extensionless file (a bash shim `CreateProcess` can't run) is skipped:
78    // only an entry with a real executable extension counts. If the winning
79    // entry is a `.cmd`/`.bat`, route through `cmd /C`; if it's a `.exe`,
80    // spawn directly (`None`).
81    let path = std::env::var_os("PATH")?;
82    let pathext = std::env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
83    let exts: Vec<String> = pathext
84        .to_string_lossy()
85        .split(';')
86        .filter(|e| !e.is_empty())
87        .map(|e| e.to_string())
88        .collect();
89    for dir in std::env::split_paths(&path) {
90        for ext in &exts {
91            let cand = dir.join(format!("{program}{ext}"));
92            if cand.is_file() {
93                return has_batch_ext(&cand).then_some(cand);
94            }
95        }
96    }
97    None
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn direct_program_is_passed_through() {
106        // On every platform, a plain program name reaches Command unchanged
107        // unless Windows resolves it to a shim (which "echo" is not).
108        let c = program_command("some-plain-binary");
109        let prog = c.as_std().get_program().to_string_lossy().to_string();
110        #[cfg(windows)]
111        assert_eq!(prog, "some-plain-binary"); // not a shim on PATH → direct
112        #[cfg(not(windows))]
113        assert_eq!(prog, "some-plain-binary");
114    }
115
116    #[cfg(windows)]
117    #[test]
118    fn batch_extensions_route_through_cmd() {
119        // A name/path that already carries a batch extension is judged
120        // directly (no PATH search) and routed through cmd /C.
121        assert!(windows_batch_shim("foo.cmd").is_some());
122        assert!(windows_batch_shim("foo.bat").is_some());
123        assert!(windows_batch_shim(r"C:\tools\foo.CMD").is_some());
124        // A real executable is spawned directly.
125        assert!(windows_batch_shim("foo.exe").is_none());
126        assert!(windows_batch_shim(r"C:\tools\foo.exe").is_none());
127    }
128
129    #[cfg(windows)]
130    #[test]
131    fn cmd_shim_wraps_program() {
132        let c = program_command("foo.cmd");
133        assert_eq!(c.as_std().get_program().to_string_lossy(), "cmd");
134        let args: Vec<_> = c
135            .as_std()
136            .get_args()
137            .map(|a| a.to_string_lossy().to_string())
138            .collect();
139        assert_eq!(args, vec!["/C".to_string(), "foo.cmd".to_string()]);
140    }
141}