car-engine 0.36.0

Core runtime engine for Common Agent Runtime
//! Cross-platform spawning for an external program named by a bare command
//! string — an MCP server's `command`, a subprocess tool's `command`, a
//! foreman verify command's program.
//!
//! On Windows, npm installs CLIs like `npx`/`npm`/`yarn`/`pnpm` as `.cmd`
//! batch shims (there is no `npx.exe`). `CreateProcess` — which `std`'s and
//! `tokio`'s `Command::new` use — cannot execute a batch file directly (it
//! fails with os error 193, "%1 is not a valid Win32 application"), and
//! `Command::new` only appends `.exe` when searching PATH, so it never even
//! finds `npx.cmd`. The canonical MCP launch (`npx -y @modelcontextprotocol/
//! server-…`) therefore fails outright on Windows. A shim must be run through
//! `cmd /C`, which does its own PATHEXT resolution.
//!
//! This is the bare-name analogue of `car_external_agents::detection::
//! base_command` (which routes an already-resolved `.cmd`/`.bat` *path*
//! through `cmd /C`). Here the input is a command *name*, so on Windows we
//! resolve it via PATH + PATHEXT to decide whether it is a shim before
//! deciding how to spawn it. Off Windows this is a plain `Command::new`.

use tokio::process::Command;

/// Build a `tokio::process::Command` for `program`, routing a Windows
/// `.cmd`/`.bat` shim through `cmd /C`. Off Windows — and for a real `.exe`
/// target or an unresolvable name — the program is spawned directly. The
/// caller appends args / cwd / env / stdio as usual.
pub fn program_command(program: &str) -> Command {
    #[cfg(windows)]
    {
        if let Some(shim) = windows_batch_shim(program) {
            let mut c = Command::new("cmd");
            c.arg("/C").arg(shim);
            // A `.cmd`/`.bat` shim (npx, npm, claude) resolves its real
            // interpreter — usually `node` — through PATH, so an over-long PATH
            // that cmd drops breaks the shim itself. See crate::win_env.
            if let Some(path) = crate::win_env::cmd_path_override() {
                c.env("PATH", path);
            }
            return c;
        }
    }
    Command::new(program)
}

/// True when `p`'s extension is a Windows batch extension (`.cmd`/`.bat`),
/// case-insensitively — the kind `CreateProcess` can't execute directly.
#[cfg(windows)]
fn has_batch_ext(p: &std::path::Path) -> bool {
    p.extension()
        .and_then(|e| e.to_str())
        .map(|e| {
            let e = e.to_ascii_lowercase();
            e == "cmd" || e == "bat"
        })
        .unwrap_or(false)
}

/// On Windows, decide whether `program` should be launched through `cmd /C`
/// because it is (or resolves to) a `.cmd`/`.bat` batch shim. Returns the
/// value to hand to `cmd /C` (the resolved path for a bare name, or the
/// original string when it already carries a path/extension), or `None` for a
/// real `.exe`, an extensionless native binary, or an unresolvable name.
#[cfg(windows)]
fn windows_batch_shim(program: &str) -> Option<std::path::PathBuf> {
    use std::path::Path;
    let p = Path::new(program);

    // Already an explicit path, or a name that already carries an extension:
    // judge it directly rather than PATH-searching. A batch extension routes
    // through `cmd /C` (which resolves a bare `foo.cmd` on PATH itself); a
    // real `.exe`/binary spawns directly.
    if p.extension().is_some() || p.components().count() > 1 {
        return has_batch_ext(p).then(|| p.to_path_buf());
    }

    // Bare name (e.g. "npx"): resolve the way the shell does — for each PATH
    // dir, try each PATHEXT extension in order; the first hit wins. The
    // extensionless file (a bash shim `CreateProcess` can't run) is skipped:
    // only an entry with a real executable extension counts. If the winning
    // entry is a `.cmd`/`.bat`, route through `cmd /C`; if it's a `.exe`,
    // spawn directly (`None`).
    let path = std::env::var_os("PATH")?;
    let pathext = std::env::var_os("PATHEXT").unwrap_or_else(|| ".COM;.EXE;.BAT;.CMD".into());
    let exts: Vec<String> = pathext
        .to_string_lossy()
        .split(';')
        .filter(|e| !e.is_empty())
        .map(|e| e.to_string())
        .collect();
    for dir in std::env::split_paths(&path) {
        for ext in &exts {
            let cand = dir.join(format!("{program}{ext}"));
            if cand.is_file() {
                return has_batch_ext(&cand).then_some(cand);
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn direct_program_is_passed_through() {
        // On every platform, a plain program name reaches Command unchanged
        // unless Windows resolves it to a shim (which "echo" is not).
        let c = program_command("some-plain-binary");
        let prog = c.as_std().get_program().to_string_lossy().to_string();
        #[cfg(windows)]
        assert_eq!(prog, "some-plain-binary"); // not a shim on PATH → direct
        #[cfg(not(windows))]
        assert_eq!(prog, "some-plain-binary");
    }

    #[cfg(windows)]
    #[test]
    fn batch_extensions_route_through_cmd() {
        // A name/path that already carries a batch extension is judged
        // directly (no PATH search) and routed through cmd /C.
        assert!(windows_batch_shim("foo.cmd").is_some());
        assert!(windows_batch_shim("foo.bat").is_some());
        assert!(windows_batch_shim(r"C:\tools\foo.CMD").is_some());
        // A real executable is spawned directly.
        assert!(windows_batch_shim("foo.exe").is_none());
        assert!(windows_batch_shim(r"C:\tools\foo.exe").is_none());
    }

    #[cfg(windows)]
    #[test]
    fn cmd_shim_wraps_program() {
        let c = program_command("foo.cmd");
        assert_eq!(c.as_std().get_program().to_string_lossy(), "cmd");
        let args: Vec<_> = c
            .as_std()
            .get_args()
            .map(|a| a.to_string_lossy().to_string())
            .collect();
        assert_eq!(args, vec!["/C".to_string(), "foo.cmd".to_string()]);
    }
}