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}