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}