Skip to content

fix(win): agents/ batch arg escape (.cmd → cmd /C wrapping, v0.1.8-beta-5 hotfix)#294

Merged
hang-in merged 5 commits into
mainfrom
fix/windows-agent-cmd-spawn
May 28, 2026
Merged

fix(win): agents/ batch arg escape (.cmd → cmd /C wrapping, v0.1.8-beta-5 hotfix)#294
hang-in merged 5 commits into
mainfrom
fix/windows-agent-cmd-spawn

Conversation

@hang-in
Copy link
Copy Markdown
Owner

@hang-in hang-in commented May 28, 2026

Summary

외부 Windows 사용자 보고 (메신저, 2026-05-20):

[gemini error] Failed to spawn gemini (C:\Users\bery5\AppData\Roaming\npm\gemini.cmd):
batch file arguments are invalid

Rust 1.77+ 의 std::process::Command.cmd/.bat 파일을 직접 spawn 시 batch arg escape 강화 (CVE-2024-24576) 로 raw 특수문자 포함 인자를 InvalidInput reject. npm 글로벌 CLI (gemini / codex / claude / opencode) 가 Windows 에서 %APPDATA%\npm\<name>.cmd wrapper 형태라 회귀 발생.

PR #278 이 onboarding 영역 (commands/project_onboarding.rs) 에 도입한 .cmdcmd /C wrapping 패턴이 agents/ 영역에 누락 되어 일반 send 경로에서 회귀. 본 PR 은 agents::win_spawn::wrap_windows_script SSOT 로 추출 후 5 spawn site 에 적용.

Changes (T1~T6)

  • T1 + T6 (feat(agents): wrap_windows_script helper): 신규 agents/win_spawn.rscfg(target_os = "windows") 분기 + .cmd/.bat case-insensitive (.BAT 도 cover) + 4 unit test (no-extension / .exe / .cmd / .BAT)
  • T2 (fix(gemini)): agents/gemini.rs run() + stream_run() 2 spawn site 적용
  • T3 (fix(codex)): agents/codex.rs run() + stream_run() 2 spawn site 적용
  • T4 (fix(opencode)): agents/opencode.rs 의 helper 함수를 wrap_windows_script SSOT 로 통일 + 사용 안 되는 resolve::build_command 제거 (helper 통합)
  • T5 (chore(claude)): agents/claude.rs:370, :647 bare name "claude" 를 helper 통과 형태로 통일. 현재는 .cmd/.bat 분기 도달 X (동작 변경 0), 후속 PR 에서 resolve_claude_binary() full path 로 전환 시 자동 cmd /C 분기 활성. 회귀 가드 grep 단일화 효과.

Verification

cd src-tauri && cargo check                  → clean
cd src-tauri && cargo test --lib             → 656 passed (baseline 652 + T6 +4)
npx tsc --noEmit                             → 0 errors (frontend 변경 0)
npx vitest run                               → 478 passed (baseline 동일)

회귀 가드 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 --lib baseline 652 → 656 (T6 helper unit test 4건 추가)
  • cargo check warning 0
  • tsc --noEmit clean
  • vitest run 478 PASS
  • Windows 환경 e2e: v0.1.8-beta-5 publish 후 외부 사용자 (bery5) 가 gemini.cmd 호출 정상 — 메신저 회신 확인 (사용자 영역)
  • 같은 환경에서 codex.cmd / claude.cmd 도 정상

CI 정책

cross-platform 회귀 영역이라 admin merge 회피 — Windows runner SUCCESS 확인 후 squash merge.

다음 step (본 PR scope 외)

  • T5 의 진짜 fix (claude.rs Command::new("claude") bare name → resolve+wrap 패턴): 별 PR claudeWindowsResolvePlan scope. 본 PR 은 helper 통과 형태로 통일만 (동작 변경 0).

Refs:

🤖 Generated with Claude Code

dghong and others added 5 commits May 28, 2026 18:48
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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +37 to +39
if lower.ends_with(".cmd") || lower.ends_with(".bat") {
let mut c = Command::new("cmd");
c.arg("/C").arg(cmd_path);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

⚠️ 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:

  1. There are more than two quote characters on the entire command line.
  2. 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:

  1. Short-term / Mitigation: Consider warning the team or documenting this limitation.
  2. 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 via std::os::windows::process::CommandExt::raw_arg. However, this requires wrap_windows_script to accept all arguments at once so it can build the entire command line, rather than allowing callers to chain .arg() afterwards.

Comment on lines +33 to +51
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
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
}

Comment on lines 69 to 74
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
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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
}

@hang-in hang-in merged commit a70459e into main May 28, 2026
3 checks passed
hang-in pushed a commit that referenced this pull request May 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant