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    let mut command = program_command_uncleared(program);
28    // The program may be git, or run git (a foreman verify command such as
29    // `git diff --exit-code`, a subprocess tool, an MCP server): it must act on
30    // the repository it runs in, not one the daemon's environment names. A value
31    // the caller sets on the command afterwards still wins (car#4115).
32    car_winenv::clear_git_repository_redirects(command.as_std_mut());
33    command
34}
35
36fn program_command_uncleared(program: &str) -> Command {
37    #[cfg(windows)]
38    {
39        if let Some(shim) = windows_batch_shim(program) {
40            let mut c = Command::new("cmd");
41            c.arg("/C").arg(shim);
42            // A `.cmd`/`.bat` shim (npx, npm, claude) resolves its real
43            // interpreter — usually `node` — through PATH, so an over-long PATH
44            // that cmd drops breaks the shim itself. See crate::win_env.
45            if let Some(path) = crate::win_env::cmd_path_override() {
46                c.env("PATH", path);
47            }
48            return c;
49        }
50    }
51    Command::new(program)
52}
53
54/// True when `p`'s extension is a Windows batch extension (`.cmd`/`.bat`),
55/// case-insensitively — the kind `CreateProcess` can't execute directly.
56#[cfg(windows)]
57fn has_batch_ext(p: &std::path::Path) -> bool {
58    p.extension()
59        .and_then(|e| e.to_str())
60        .map(|e| {
61            let e = e.to_ascii_lowercase();
62            e == "cmd" || e == "bat"
63        })
64        .unwrap_or(false)
65}
66
67/// On Windows, decide whether `program` should be launched through `cmd /C`
68/// because it is (or resolves to) a `.cmd`/`.bat` batch shim. Returns the
69/// value to hand to `cmd /C` (the resolved path for a bare name, or the
70/// original string when it already carries a path/extension), or `None` for a
71/// real `.exe`, an extensionless native binary, or an unresolvable name.
72#[cfg(windows)]
73fn windows_batch_shim(program: &str) -> Option<std::path::PathBuf> {
74    use std::path::Path;
75    let p = Path::new(program);
76
77    // Already an explicit path, or a name that already carries an extension:
78    // judge it directly rather than PATH-searching. A batch extension routes
79    // through `cmd /C` (which resolves a bare `foo.cmd` on PATH itself); a
80    // real `.exe`/binary spawns directly.
81    if p.extension().is_some() || p.components().count() > 1 {
82        return has_batch_ext(p).then(|| p.to_path_buf());
83    }
84
85    // Bare name (e.g. "npx"): resolve the way the shell does — for each PATH
86    // dir, try each PATHEXT extension in order; the first hit wins. The
87    // extensionless file (a bash shim `CreateProcess` can't run) is skipped:
88    // only an entry with a real executable extension counts. If the winning
89    // entry is a `.cmd`/`.bat`, route through `cmd /C`; if it's a `.exe`,
90    // spawn directly (`None`).
91    let path = std::env::var_os("PATH")?;
92    let pathext = std::env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
93    let exts: Vec<String> = pathext
94        .to_string_lossy()
95        .split(';')
96        .filter(|e| !e.is_empty())
97        .map(|e| e.to_string())
98        .collect();
99    for dir in std::env::split_paths(&path) {
100        for ext in &exts {
101            let cand = dir.join(format!("{program}{ext}"));
102            if cand.is_file() {
103                return has_batch_ext(&cand).then_some(cand);
104            }
105        }
106    }
107    None
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn removes_every_redirect(command: &Command) {
115        for name in car_winenv::GIT_REPOSITORY_REDIRECTS {
116            assert!(
117                command
118                    .as_std()
119                    .get_envs()
120                    .any(|(key, value)| key == name && value.is_none()),
121                "{name} is not removed from the child's environment"
122            );
123        }
124    }
125
126    #[test]
127    fn a_program_command_does_not_inherit_repository_redirects() {
128        removes_every_redirect(&program_command("git"));
129    }
130
131    #[test]
132    fn direct_program_is_passed_through() {
133        // On every platform, a plain program name reaches Command unchanged
134        // unless Windows resolves it to a shim (which "echo" is not).
135        let c = program_command("some-plain-binary");
136        let prog = c.as_std().get_program().to_string_lossy().to_string();
137        #[cfg(windows)]
138        assert_eq!(prog, "some-plain-binary"); // not a shim on PATH → direct
139        #[cfg(not(windows))]
140        assert_eq!(prog, "some-plain-binary");
141    }
142
143    #[cfg(windows)]
144    #[test]
145    fn batch_extensions_route_through_cmd() {
146        // A name/path that already carries a batch extension is judged
147        // directly (no PATH search) and routed through cmd /C.
148        assert!(windows_batch_shim("foo.cmd").is_some());
149        assert!(windows_batch_shim("foo.bat").is_some());
150        assert!(windows_batch_shim(r"C:\tools\foo.CMD").is_some());
151        // A real executable is spawned directly.
152        assert!(windows_batch_shim("foo.exe").is_none());
153        assert!(windows_batch_shim(r"C:\tools\foo.exe").is_none());
154    }
155
156    #[cfg(windows)]
157    #[test]
158    fn cmd_shim_wraps_program() {
159        let c = program_command("foo.cmd");
160        assert_eq!(c.as_std().get_program().to_string_lossy(), "cmd");
161        let args: Vec<_> = c
162            .as_std()
163            .get_args()
164            .map(|a| a.to_string_lossy().to_string())
165            .collect();
166        assert_eq!(args, vec!["/C".to_string(), "foo.cmd".to_string()]);
167    }
168}