fix(win): agents/ batch arg escape (.cmd → cmd /C wrapping, v0.1.8-beta-5 hotfix)#294
Conversation
Rust 1.77+ 에서 `std::process::Command` 가 `.cmd`/`.bat` 파일을 직접 spawn
시 batch arg escape 강화 (CVE-2024-24576) 로 raw 특수문자 포함 인자를
`InvalidInput` reject ("batch file arguments are invalid"). npm 글로벌
CLI (gemini / codex / claude / opencode) 는 Windows 에서 `.cmd` wrapper
형태라 회귀 발생.
PR #278 의 onboarding 영역 패턴 (`commands/project_onboarding.rs:587~613`)
을 `agents/win_spawn.rs` 로 추출, agents/ 영역에서 SSOT 로 재사용.
- 시그니처: `wrap_windows_script(cmd_path: &str, args: &[&str]) -> Command`
- `cfg(target_os = "windows")` 분기 — macOS / Linux 변경 0
- `.cmd` / `.bat` case-insensitive (`to_ascii_lowercase`) — `.BAT` 대문자도 cover
- 호출 site 에서 `.no_console() / .stderr() / .stdout() / .current_dir() / .spawn()` chain 그대로 유지
T6 unit test 4 케이스 (`.exe` / no-extension / `.cmd` / `.BAT`) 포함.
Refs: docs/plans/windowsAgentCmdSpawnFixPlan_2026-05-20.md (T1, T6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
외부 사용자 보고 (메신저, 2026-05-20): Failed to spawn gemini (C:\Users\bery5\AppData\Roaming\npm\gemini.cmd): batch file arguments are invalid `agents/gemini.rs` 의 `run()` (line ~30) + `stream_run()` (line ~156) 두 spawn site 에서 `Command::new(&gemini_cmd)` 를 `wrap_windows_script(...)` 로 교체. Windows 에서 gemini_cmd 가 npm `.cmd` wrapper 면 `cmd /C` 분기, 그 외 (Unix 또는 node script 직접 호출) 는 동작 변경 0. 후속 chain (`.no_console()` / `.arg()` / `.stdout()` / `.stderr()` / `.current_dir()` / `.spawn()`) 은 그대로 유지. Refs: docs/plans/windowsAgentCmdSpawnFixPlan_2026-05-20.md (T2) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`agents/codex.rs` 의 `run()` (line ~58) + `stream_run()` (line ~236) 두 spawn site 에서 `Command::new(&codex_cmd)` 를 `wrap_windows_script(...)` 로 교체. Windows 에서 codex_cmd 가 npm `.cmd` wrapper 면 `cmd /C` 분기, 그 외 (Unix 또는 node script 직접 호출) 는 동작 변경 0. stdin pipe (codex 가 `-` 로 prompt 를 stdin 으로 받는 패턴) 와 stdout/ stderr drain thread 는 그대로 유지 — `cmd /C` wrapper 가 child node.exe 로 pipe 를 forward 하는 동작은 PR #278 의 onboarding 영역에서 이미 검증. Refs: docs/plans/windowsAgentCmdSpawnFixPlan_2026-05-20.md (T3) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`agents/opencode.rs` 의 `build_command(&PathBuf)` 가 기존 `resolve::build_command(&Path)` (PathBuf 만 받고 args 미지원) 를 호출 하던 것을 `agents::win_spawn::wrap_windows_script` SSOT 로 교체. - opencode 는 현재 UI 미연결이지만 동일 회귀 방지 (선제적 fix) - 모든 agents/ spawn site (`gemini.rs` / `codex.rs` / `opencode.rs`) 가 `wrap_windows_script` 단일 함수 통과 → 회귀 가드 grep 단일화 - 기존 `resolve::build_command()` 는 호출 site 0 → 제거 (helper 통합) Refs: docs/plans/windowsAgentCmdSpawnFixPlan_2026-05-20.md (T4) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`agents/claude.rs:370` (`stream_run_once`) + `:647` (`run`) 의
`Command::new("claude")` (bare name) 를 `wrap_windows_script("claude", &[])`
SSOT helper 통과 형태로 통일.
## Audit 결과
- `resolve_claude_binary()` (`commands/model_discovery.rs:196`) 는 `where claude`
결과의 first match path 를 반환. Windows + Anthropic Claude Code npm
글로벌 (`@anthropic-ai/claude-code`) 설치 시 `%APPDATA%\npm\claude.cmd`
형태이므로 잠재 회귀 영역.
- 단 `agents/claude.rs` 의 현재 호출 site 는 bare name `"claude"` 를 그대로
`Command::new` 에 넘기는 형태 → Rust PATHEXT resolve 가 `.cmd` 를 잡으면
batch arg escape 회귀 가능성 잠복.
## 본 PR 의 적용
- bare name `"claude"` 를 helper 통과 형태로 교체 → 현재는 `.cmd`/`.bat`
분기 도달 X (동작 변경 0)
- 후속 PR 에서 `resolve_claude_binary()` 결과의 full path 를 인자로 넘기는
형태로 전환하면 자동 `cmd /C` 분기 활성 (별 PR `claudeWindowsResolvePlan`
scope, 본 PR 범위 외)
- 회귀 가드 grep 단일화: `agents/` 영역의 모든 spawn site 가
`wrap_windows_script` 통과
Refs: docs/plans/windowsAgentCmdSpawnFixPlan_2026-05-20.md (T5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request extracts the Windows .cmd/.bat script spawning logic into a single helper function, wrap_windows_script, in a new module win_spawn.rs. It updates various agent modules (claude, codex, gemini, and opencode) to use this helper, and removes the deprecated build_command from resolve.rs. The review feedback highlights a critical potential bug regarding cmd.exe quote-stripping when paths or arguments contain spaces, and suggests refactoring the helper to accept AsRef<Path> to avoid unnecessary string allocations.
| if lower.ends_with(".cmd") || lower.ends_with(".bat") { | ||
| let mut c = Command::new("cmd"); | ||
| c.arg("/C").arg(cmd_path); |
There was a problem hiding this comment.
⚠️ Potential Bug: cmd.exe Quote-Stripping with Spaces in Paths and Arguments
There is a well-known and extremely subtle behavior in cmd.exe regarding how it parses quotes after /C.
According to cmd.exe's internal rules, if /C is used, it will strip the first and last quote of the command line if any of the following conditions are met:
- There are more than two quote characters on the entire command line.
- There are special characters like
&<>()@^|inside the quotes.
How this causes failures:
If a user has a space in their Windows username (e.g., C:\Users\John Doe\AppData\Roaming\npm\gemini.cmd), Rust will automatically quote the executable path:
"C:\Users\John Doe\AppData\Roaming\npm\gemini.cmd"
If all other arguments have no spaces (and thus no quotes), there are exactly two quotes on the command line, and cmd.exe preserves them. It runs successfully.
However, if any argument also contains spaces or special characters (e.g., a prompt or a path like -p "hello world"), Rust will quote that argument as well. Now, the command line has four quotes:
cmd /C "C:\Users\John Doe\AppData\Roaming\npm\gemini.cmd" -p "hello world"
Because there are more than two quotes, cmd.exe will strip the first quote (before C:\) and the last quote (after world). The resulting command line executed by cmd.exe becomes:
C:\Users\John Doe\AppData\Roaming\npm\gemini.cmd" -p "hello world
This will fail with:
'C:\Users\John' is not recognized as an internal or external command...
Recommendations:
- Short-term / Mitigation: Consider warning the team or documenting this limitation.
- Long-term / Robust Fix: To completely bypass
cmd.exe's quote-stripping behavior, we can wrap the entire command string in an extra set of outer quotes and pass it viastd::os::windows::process::CommandExt::raw_arg. However, this requireswrap_windows_scriptto accept all arguments at once so it can build the entire command line, rather than allowing callers to chain.arg()afterwards.
| pub fn wrap_windows_script(cmd_path: &str, args: &[&str]) -> Command { | ||
| #[cfg(target_os = "windows")] | ||
| { | ||
| let lower = cmd_path.to_ascii_lowercase(); | ||
| if lower.ends_with(".cmd") || lower.ends_with(".bat") { | ||
| let mut c = Command::new("cmd"); | ||
| c.arg("/C").arg(cmd_path); | ||
| for a in args { | ||
| c.arg(a); | ||
| } | ||
| return c; | ||
| } | ||
| } | ||
| let mut c = Command::new(cmd_path); | ||
| for a in args { | ||
| c.arg(a); | ||
| } | ||
| c | ||
| } |
There was a problem hiding this comment.
Using string manipulation (to_ascii_lowercase and ends_with) on the entire path string is less idiomatic in Rust and can be inefficient because it allocates a new String for the entire path.
We can refactor wrap_windows_script to be generic over AsRef<std::path::Path>. This allows us to use Path::extension to cleanly extract and check the file extension, avoiding unnecessary allocations and making the function more robust and idiomatic.
pub fn wrap_windows_script<P: AsRef<std::path::Path>>(cmd_path: P, args: &[&str]) -> Command {
#[cfg(target_os = "windows")]
{
let path = cmd_path.as_ref();
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
let lower = ext.to_ascii_lowercase();
if lower == "cmd" || lower == "bat" {
let mut c = Command::new("cmd");
c.arg("/C").arg(path);
for a in args {
c.arg(a);
}
return c;
}
}
}
let mut c = Command::new(cmd_path.as_ref());
for a in args {
c.arg(a);
}
c
}| fn build_command(bin: &std::path::PathBuf) -> Command { | ||
| super::resolve::build_command(bin) | ||
| let path_str = bin.to_string_lossy(); | ||
| let mut c = wrap_windows_script(&path_str, &[]); | ||
| c.no_console(); | ||
| c | ||
| } |
There was a problem hiding this comment.
If wrap_windows_script is refactored to accept AsRef<std::path::Path>, we can simplify build_command to take &std::path::Path and pass it directly, avoiding the to_string_lossy allocation entirely.
fn build_command(bin: &std::path::Path) -> Command {
let mut c = wrap_windows_script(bin, &[]);
c.no_console();
c
}…nt UX 매니페스트 4 곳 + Cargo.lock bump. CHANGELOG entry 추가. 핵심 fix: - Windows agents/ .cmd batch arg escape (PR #294, merge a70459e) Rust 1.77+ CVE-2024-24576 fix. agents/ 영역 누락 → win_spawn helper SSOT. - MetaAgent endpoint explicit trigger (PR #295, merge a5c8479) LM Studio Endpoint 자동 detect 제거 → Enter / refresh 버튼. 외부 사용자 (메신저) 보고 2 건 즉시 대응 hotfix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
외부 Windows 사용자 보고 (메신저, 2026-05-20):
Rust 1.77+ 의
std::process::Command가.cmd/.bat파일을 직접 spawn 시 batch arg escape 강화 (CVE-2024-24576) 로 raw 특수문자 포함 인자를InvalidInputreject. npm 글로벌 CLI (gemini / codex / claude / opencode) 가 Windows 에서%APPDATA%\npm\<name>.cmdwrapper 형태라 회귀 발생.PR #278 이 onboarding 영역 (
commands/project_onboarding.rs) 에 도입한.cmd→cmd /Cwrapping 패턴이agents/영역에 누락 되어 일반 send 경로에서 회귀. 본 PR 은agents::win_spawn::wrap_windows_scriptSSOT 로 추출 후 5 spawn site 에 적용.Changes (T1~T6)
feat(agents): wrap_windows_script helper): 신규agents/win_spawn.rs—cfg(target_os = "windows")분기 +.cmd/.batcase-insensitive (.BAT도 cover) + 4 unit test (no-extension /.exe/.cmd/.BAT)fix(gemini)):agents/gemini.rsrun()+stream_run()2 spawn site 적용fix(codex)):agents/codex.rsrun()+stream_run()2 spawn site 적용fix(opencode)):agents/opencode.rs의 helper 함수를wrap_windows_scriptSSOT 로 통일 + 사용 안 되는resolve::build_command제거 (helper 통합)chore(claude)):agents/claude.rs:370, :647bare name"claude"를 helper 통과 형태로 통일. 현재는.cmd/.bat분기 도달 X (동작 변경 0), 후속 PR 에서resolve_claude_binary()full path 로 전환 시 자동cmd /C분기 활성. 회귀 가드 grep 단일화 효과.Verification
회귀 가드 grep:
git diff main...HEAD -- src/→ 0 lines (frontend 변경 0)git diff main...HEAD -- src-tauri/src/commands/project_onboarding.rs→ 0 lines (PR fix(win): agent CLI 동작 정상화 — PATHEXT / no_console / cwd / args #278 영역 보존)git diff main...HEAD -- src-tauri/src/commands/pty/session.rs→ 0 lines (별 path 보존)rg "Command::new\(.*\.cmd" src-tauri/src/agents/→ 0 hits (모두 helper 통과)Test plan
cargo test --libbaseline 652 → 656 (T6 helper unit test 4건 추가)cargo checkwarning 0tsc --noEmitcleanvitest run478 PASSgemini.cmd호출 정상 — 메신저 회신 확인 (사용자 영역)CI 정책
cross-platform 회귀 영역이라 admin merge 회피 — Windows runner SUCCESS 확인 후 squash merge.
다음 step (본 PR scope 외)
Command::new("claude")bare name → resolve+wrap 패턴): 별 PRclaudeWindowsResolvePlanscope. 본 PR 은 helper 통과 형태로 통일만 (동작 변경 0).Refs:
docs/plans/windowsAgentCmdSpawnFixPlan_2026-05-20.mddocs/prompts/windowsAgentCmdSpawnFixDeveloperHandoff_2026-05-20.mdsrc-tauri/src/commands/project_onboarding.rs:587~613🤖 Generated with Claude Code