Skip to main content

agentmux_common/
cli.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4#[cfg(windows)]
5use crate::win32::NoWindow;
6
7/// Resolved target of a Windows `.cmd` npm shim.
8#[cfg(windows)]
9enum ResolvedShim {
10    /// Shim invokes a Node.js script; run via `node <path>`.
11    NodeScript(String),
12    /// Shim invokes a native executable directly; run via `<path>`.
13    /// Seen on @anthropic-ai/claude-code v2+, which ships a prebuilt
14    /// `claude.exe` instead of a JS entry point.
15    Executable(String),
16}
17
18/// Create a Command for a CLI binary.
19///
20/// On Windows, npm-generated `.cmd` batch wrappers cannot be reliably spawned
21/// via `cmd.exe /C` when stdio is piped — arguments get dropped, output is lost,
22/// and the underlying CLI never executes. Instead, we parse the `.cmd` file to
23/// extract the real entry point and invoke it directly (either `node <script>`
24/// for JS shims or the target `.exe` directly for native-binary shims).
25pub fn make_cli_cmd(cli_path: &str) -> tokio::process::Command {
26    let cmd = build_cli_cmd(cli_path);
27    // CREATE_NO_WINDOW: the host + launcher are GUI-subsystem and srv is
28    // launched windowless, so a console-subsystem child (node, claude.exe,
29    // cmd.exe) spawned without this flag forces Windows to allocate a fresh
30    // console window — the "terminal flash" seen during dev, most visibly
31    // from the ambient Haiku calls that fire as the user types
32    // (server/app_api/session.rs) and the per-check `--version`/auth probes.
33    // Applied at this single chokepoint so every `make_cli_cmd` caller is
34    // covered; idempotent for the agent-CLI callers (persistent/subprocess/
35    // acp) that already set it manually.
36    #[cfg(windows)]
37    let cmd = {
38        let mut cmd = cmd;
39        cmd.no_window();
40        cmd
41    };
42    cmd
43}
44
45/// Resolve `cli_path` to a runnable `Command` (the `.cmd`/`.bat` shim parsing
46/// on Windows). `make_cli_cmd` wraps this to apply `CREATE_NO_WINDOW`.
47fn build_cli_cmd(cli_path: &str) -> tokio::process::Command {
48    // `None` means the `.cmd` shim didn't match either known npm shape — fall
49    // back to spawning the raw path directly. For a `tokio::process::Command`
50    // (piped stdio, no PTY) this has the pre-existing, already-documented
51    // failure mode of npm `.cmd` wrappers (args/output can get lost through
52    // `cmd.exe`'s implicit association) rather than the ConPTY indefinite
53    // hang — see `resolve_cli_spawn_target`'s doc for why that hang is
54    // PTY-specific and why this fallback must NOT be `cmd.exe /C` (that's
55    // exactly as broken here as the raw path, so there's nothing to gain by
56    // routing through it explicitly).
57    let (program, args) = resolve_cli_spawn_target(cli_path)
58        .unwrap_or_else(|| (cli_path.to_string(), Vec::new()));
59    let mut cmd = tokio::process::Command::new(&program);
60    cmd.args(&args);
61    cmd
62}
63
64/// Resolve `cli_path` to the actual `(program, args)` spawn target — the
65/// `.cmd`/`.bat` shim parsing on Windows, factored out of `build_cli_cmd` so
66/// non-`tokio::process::Command` spawners (e.g. `portable_pty::CommandBuilder`,
67/// which `run_cli_login_pty` uses) can reuse the same resolution instead of
68/// spawning the raw shim path directly. Spawning a `.cmd` file straight
69/// through `CommandBuilder` forces Windows through `cmd.exe /c`, and that
70/// layer hangs indefinitely under a real ConPTY (confirmed live: 0% CPU, zero
71/// output, target dir never created) — this is the fix for that class of bug,
72/// not just a refactor for its own sake.
73///
74/// Returns `None` when `cli_path` is a `.cmd`/`.bat` shim that doesn't match
75/// either known npm shape (Node-script or native-exe). A previous version
76/// fell back to `("cmd.exe", ["/C", cli_path])` here, but that's the exact
77/// invocation this function exists to avoid — under a ConPTY it hangs just
78/// as indefinitely as the raw shim path did, silently reintroducing the bug
79/// for any unrecognized shim shape (reagent P2 on PR review). Callers must
80/// decide how to handle an unresolvable shim themselves: `build_cli_cmd`
81/// (piped stdio, no ConPTY) falls back to the raw path, which loses some
82/// stdio fidelity but doesn't hang; `run_cli_login_pty` (real ConPTY) must
83/// fail fast instead of attempting a doomed spawn.
84pub fn resolve_cli_spawn_target(cli_path: &str) -> Option<(String, Vec<String>)> {
85    #[cfg(windows)]
86    if cli_path.ends_with(".cmd") || cli_path.ends_with(".bat") {
87        return match parse_cmd_wrapper(cli_path) {
88            Some(ResolvedShim::NodeScript(entry_script)) => {
89                tracing::debug!(cmd = %cli_path, script = %entry_script, "resolved .cmd → node");
90                Some(("node".to_string(), vec![entry_script]))
91            }
92            Some(ResolvedShim::Executable(exe_path)) => {
93                tracing::debug!(cmd = %cli_path, exe = %exe_path, "resolved .cmd → native .exe");
94                Some((exe_path, Vec::new()))
95            }
96            None => {
97                tracing::warn!(cmd = %cli_path, "could not parse .cmd wrapper — no safe spawn target");
98                None
99            }
100        };
101    }
102    Some((cli_path.to_string(), Vec::new()))
103}
104
105/// Parse an npm-generated `.cmd` wrapper to extract the real entry point.
106///
107/// npm `.cmd` wrappers contain a line like one of:
108///   `"%_prog%"  "%dp0%\..\@anthropic-ai\claude-code\cli.js" %*`   (JS shim)
109///   `"%dp0%\..\@anthropic-ai\claude-code\bin\claude.exe"   %*`    (native .exe shim)
110/// where `%dp0%` is the directory containing the `.cmd` file itself. We extract
111/// the relative path after `%dp0%\`, resolve it to an absolute path, and tag it
112/// as either a Node script (`.js/.mjs/.cjs`) or a native executable (`.exe`).
113#[cfg(windows)]
114fn parse_cmd_wrapper(cmd_path: &str) -> Option<ResolvedShim> {
115    let content = std::fs::read_to_string(cmd_path).ok()?;
116    let cmd_dir = std::path::Path::new(cmd_path).parent()?;
117
118    for line in content.lines() {
119        let line_trimmed = line.trim();
120        if !line_trimmed.contains("%dp0%") || !line_trimmed.contains("%*") {
121            continue;
122        }
123        if let Some(dp0_idx) = line_trimmed.find("%dp0%\\") {
124            let after_dp0 = &line_trimmed[dp0_idx + 6..]; // skip "%dp0%\"
125            let end = after_dp0.find('"')
126                .or_else(|| after_dp0.find(" %*"))
127                .unwrap_or(after_dp0.len());
128            let relative_path = &after_dp0[..end];
129            let is_node_script = relative_path.ends_with(".js")
130                || relative_path.ends_with(".mjs")
131                || relative_path.ends_with(".cjs");
132            let is_exe = relative_path.ends_with(".exe");
133            if !is_node_script && !is_exe {
134                continue;
135            }
136            let resolved = cmd_dir.join(relative_path);
137            let path_str = match resolved.canonicalize() {
138                Ok(canonical) => {
139                    let mut s = canonical.to_string_lossy().to_string();
140                    // Windows canonicalize() returns \\?\C:\... (UNC extended path)
141                    // which Node.js (and some native binaries) can't handle — strip.
142                    if s.starts_with(r"\\?\") {
143                        s = s[4..].to_string();
144                    }
145                    s
146                }
147                Err(_) => resolved.to_string_lossy().to_string(),
148            };
149            return Some(if is_node_script {
150                ResolvedShim::NodeScript(path_str)
151            } else {
152                ResolvedShim::Executable(path_str)
153            });
154        }
155    }
156    None
157}
158
159#[cfg(all(test, windows))]
160mod tests {
161    use super::*;
162
163    /// Real Claude Code v2+ `.cmd` content (`agentmux-instances/.../claude/
164    /// node_modules/.bin/claude.cmd`, verified live) — ships a prebuilt
165    /// native `claude.exe`, no Node entry point. This exact shape is what
166    /// made `run_cli_login_pty` hang under ConPTY when spawned raw.
167    const CLAUDE_EXE_SHIM: &str = concat!(
168        "@ECHO off\r\n",
169        "GOTO start\r\n",
170        ":find_dp0\r\n",
171        "SET dp0=%~dp0\r\n",
172        "EXIT /b\r\n",
173        ":start\r\n",
174        "SETLOCAL\r\n",
175        "CALL :find_dp0\r\n",
176        "\"%dp0%\\..\\@anthropic-ai\\claude-code\\bin\\claude.exe\"   %*\r\n",
177    );
178
179    /// Representative JS-entry-point npm shim shape (e.g. OpenClaw), the
180    /// other real shape `resolve_cli_spawn_target` must handle.
181    const NODE_SCRIPT_SHIM: &str = concat!(
182        "@ECHO off\r\n",
183        "GOTO start\r\n",
184        ":find_dp0\r\n",
185        "SET dp0=%~dp0\r\n",
186        "EXIT /b\r\n",
187        ":start\r\n",
188        "SETLOCAL\r\n",
189        "\"%_prog%\"  \"%dp0%\\..\\openclaw\\cli.js\" %*\r\n",
190    );
191
192    /// Lay out a `<root>/node_modules/.bin/<name>.cmd` shim pointing at
193    /// `<root>/node_modules/<pkg_rel>` — the real npm install shape
194    /// `parse_cmd_wrapper` expects. Target files are NOT created; canonicalize
195    /// falls back to the non-canonical path (see `parse_cmd_wrapper`'s `Err`
196    /// arm), so a bare `.cmd` fixture is enough — no need to fake the CLI
197    /// binary/script itself.
198    fn write_shim(dir: &std::path::Path, name: &str, content: &str) -> String {
199        let bin_dir = dir.join("node_modules").join(".bin");
200        std::fs::create_dir_all(&bin_dir).unwrap();
201        let cmd_path = bin_dir.join(format!("{name}.cmd"));
202        std::fs::write(&cmd_path, content).unwrap();
203        cmd_path.to_string_lossy().to_string()
204    }
205
206    #[test]
207    fn resolves_native_exe_shim_directly_bypassing_cmd_exe() {
208        let dir = tempfile::tempdir().unwrap();
209        let cmd_path = write_shim(dir.path(), "claude", CLAUDE_EXE_SHIM);
210
211        let (program, args) = resolve_cli_spawn_target(&cmd_path).expect("parseable shim must resolve");
212
213        assert!(
214            program.ends_with("claude.exe") || program.ends_with("claude-code\\bin\\claude.exe"),
215            "expected the resolved native exe, got: {program}"
216        );
217        assert!(args.is_empty(), "exe shim must not prefix extra args, got: {args:?}");
218        assert_ne!(program, "cmd.exe", "must not fall back to spawning cmd.exe /C for a parseable shim");
219    }
220
221    #[test]
222    fn resolves_node_script_shim_to_node_plus_script_arg() {
223        let dir = tempfile::tempdir().unwrap();
224        let cmd_path = write_shim(dir.path(), "openclaw", NODE_SCRIPT_SHIM);
225
226        let (program, args) = resolve_cli_spawn_target(&cmd_path).expect("parseable shim must resolve");
227
228        assert_eq!(program, "node");
229        assert_eq!(args.len(), 1, "expected exactly the script path as the sole arg: {args:?}");
230        assert!(args[0].ends_with("cli.js"), "expected the resolved script path, got: {args:?}");
231    }
232
233    #[test]
234    fn non_cmd_path_passes_through_unchanged() {
235        let (program, args) =
236            resolve_cli_spawn_target(r"C:\some\native\tool.exe").expect("non-.cmd path always resolves");
237        assert_eq!(program, r"C:\some\native\tool.exe");
238        assert!(args.is_empty());
239    }
240
241    /// An unparseable `.cmd` shim must resolve to `None`, not a `cmd.exe /C`
242    /// fallback — that fallback is exactly the invocation this resolver
243    /// exists to avoid, and hangs just as indefinitely under a real ConPTY
244    /// as the raw shim path did (reagent P2 on PR #2422 review). Callers
245    /// decide their own fallback: `build_cli_cmd` uses the raw path (piped
246    /// stdio, no hang risk); `run_cli_login_pty` fails fast instead.
247    #[test]
248    fn unparseable_cmd_resolves_to_none_not_cmd_exe_c() {
249        let dir = tempfile::tempdir().unwrap();
250        let cmd_path = write_shim(dir.path(), "weird", "@ECHO off\r\necho nothing useful here\r\n");
251
252        assert_eq!(
253            resolve_cli_spawn_target(&cmd_path),
254            None,
255            "must not silently fall back to the broken cmd.exe /C invocation"
256        );
257    }
258}