diff --git a/src-tauri/src/agents/claude.rs b/src-tauri/src/agents/claude.rs index 55359a7..298bf3c 100644 --- a/src-tauri/src/agents/claude.rs +++ b/src-tauri/src/agents/claude.rs @@ -1,6 +1,6 @@ use std::io::{BufRead, BufReader, Read, Write as IoWrite}; use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::thread; use serde::Deserialize; use crate::errors::AppError; @@ -367,7 +367,11 @@ where G: FnMut(String), C: Fn() -> bool, { - let mut cmd = Command::new("claude"); + // Windows: bare name `"claude"` 는 helper 의 `.cmd`/`.bat` 분기에 도달 + // 하지 않아 동작 변경 0 (현재 PATHEXT 가 `claude.cmd` 를 잡으면 batch arg + // escape 회귀 가능성 잠복). 향후 `resolve_claude_binary()` 결과를 + // full path 로 넘기는 follow-up PR 에서 자동으로 `cmd /C` 분기 활성. + let mut cmd = crate::agents::win_spawn::wrap_windows_script("claude", &[]); cmd.no_console(); cmd.arg("-p") .arg(&input.prompt) @@ -644,7 +648,10 @@ where /// Caller must NOT hold the DbState lock while calling this function, /// since the subprocess can take an arbitrarily long time. pub fn run(input: RunInput) -> Result { - let mut cmd = Command::new("claude"); + // Windows: bare name `"claude"` 는 helper 분기 도달 X (위 `stream_run_once` + // 와 동일 이유). SSOT helper 통과 형태로 통일해 후속 PR 에서 full path + // 로 전환 시 자동 `cmd /C` 분기. + let mut cmd = crate::agents::win_spawn::wrap_windows_script("claude", &[]); cmd.no_console(); cmd.arg("-p") .arg(&input.prompt) diff --git a/src-tauri/src/agents/codex.rs b/src-tauri/src/agents/codex.rs index 3601bb8..5e4bf6d 100644 --- a/src-tauri/src/agents/codex.rs +++ b/src-tauri/src/agents/codex.rs @@ -1,8 +1,9 @@ use std::io::{BufRead, BufReader, Read, Write}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::thread; use crate::agents::claude::{RunInput, RunOutput}; +use crate::agents::win_spawn::wrap_windows_script; use crate::errors::AppError; use crate::no_console::NoConsole; @@ -54,7 +55,9 @@ fn push_agent_text_dedup(texts: &mut Vec, incoming: &str) { pub fn run(input: RunInput) -> Result { let (codex_cmd, codex_script) = resolve_codex(); - let mut cmd = Command::new(&codex_cmd); + // Windows: codex_cmd 가 npm `.cmd` wrapper 면 cmd /C 로 wrapping — + // Rust 1.77+ batch arg escape (CVE-2024-24576) 회피. + let mut cmd = wrap_windows_script(&codex_cmd, &[]); cmd.no_console(); if let Some(ref script) = codex_script { cmd.arg(script); @@ -232,7 +235,8 @@ where { let (codex_cmd, codex_script) = resolve_codex(); - let mut cmd = Command::new(&codex_cmd); + // Windows: `.cmd` wrapper → cmd /C wrapping (위 `run()` 와 동일 이유). + let mut cmd = wrap_windows_script(&codex_cmd, &[]); cmd.no_console(); if let Some(ref script) = codex_script { cmd.arg(script); diff --git a/src-tauri/src/agents/gemini.rs b/src-tauri/src/agents/gemini.rs index 5ad4117..000c1f8 100644 --- a/src-tauri/src/agents/gemini.rs +++ b/src-tauri/src/agents/gemini.rs @@ -1,10 +1,11 @@ use std::io::{BufRead, BufReader, Read}; -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::thread; use serde::Deserialize; use crate::agents::claude::{RunInput, RunOutput}; +use crate::agents::win_spawn::wrap_windows_script; use crate::errors::AppError; use crate::no_console::NoConsole; @@ -27,7 +28,9 @@ use super::claude::resolve_cwd; pub fn run(input: RunInput) -> Result { let (gemini_cmd, gemini_script) = resolve_gemini(); - let mut cmd = Command::new(&gemini_cmd); + // Windows: gemini_cmd 가 `.cmd` wrapper 면 cmd /C 로 wrapping — + // Rust 1.77+ batch arg escape (CVE-2024-24576) 회피. + let mut cmd = wrap_windows_script(&gemini_cmd, &[]); cmd.no_console(); if let Some(ref script) = gemini_script { cmd.arg("--no-warnings=DEP0040").arg(script); @@ -153,7 +156,8 @@ where { let (gemini_cmd, gemini_script) = resolve_gemini(); - let mut cmd = Command::new(&gemini_cmd); + // Windows: `.cmd` wrapper → cmd /C wrapping (위 `run()` 와 동일 이유). + let mut cmd = wrap_windows_script(&gemini_cmd, &[]); cmd.no_console(); if let Some(ref script) = gemini_script { cmd.arg("--no-warnings=DEP0040").arg(script); diff --git a/src-tauri/src/agents/mod.rs b/src-tauri/src/agents/mod.rs index 9c59f92..14d9253 100644 --- a/src-tauri/src/agents/mod.rs +++ b/src-tauri/src/agents/mod.rs @@ -17,3 +17,4 @@ pub mod opencode; pub mod rawq; pub mod resolve; pub mod tool_handler; +pub mod win_spawn; diff --git a/src-tauri/src/agents/opencode.rs b/src-tauri/src/agents/opencode.rs index f02c995..cc14588 100644 --- a/src-tauri/src/agents/opencode.rs +++ b/src-tauri/src/agents/opencode.rs @@ -4,7 +4,9 @@ use std::process::{Command, Stdio}; use std::thread; use crate::agents::claude::{RunInput, RunOutput}; +use crate::agents::win_spawn::wrap_windows_script; use crate::errors::AppError; +use crate::no_console::NoConsole; use super::claude::resolve_cwd; @@ -59,9 +61,16 @@ fn resolve_opencode_path() -> PathBuf { /// - non-zero exit → Err with stderr (or stdout) detail /// - zero exit, stdout empty, stderr has content → Err (soft error) /// - zero exit, stdout empty, no stderr → Ok("") — caller decides how to display -/// Build command with Windows .cmd handling via shared resolve module. +/// Build command with Windows .cmd handling via shared `win_spawn` SSOT. +/// +/// `wrap_windows_script` 가 `.cmd` / `.bat` 만 `cmd /C` 로 wrapping — +/// PR #278 onboarding 영역과 `agents/gemini.rs`, `agents/codex.rs` 와 +/// 동일 helper 를 통일 사용 (단일 분기점 가드 grep 용이). 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 } pub fn run(input: RunInput) -> Result { diff --git a/src-tauri/src/agents/resolve.rs b/src-tauri/src/agents/resolve.rs index cddeca8..fe0179a 100644 --- a/src-tauri/src/agents/resolve.rs +++ b/src-tauri/src/agents/resolve.rs @@ -6,8 +6,6 @@ use std::path::PathBuf; -use crate::no_console::NoConsole; - /// Result of resolving a CLI binary. /// For npm-installed CLI tools on Windows, the command may be "node" with /// the actual script as an argument. @@ -178,27 +176,9 @@ pub fn first_existing(candidates: &[PathBuf]) -> Option { candidates.iter().find(|p| p.exists()).cloned() } -/// Build a `std::process::Command` that handles Windows .cmd files correctly. -/// `no_console()` 적용 — Windows 에서 cmd 창 생성 차단. -#[cfg(target_os = "windows")] -pub fn build_command(bin: &std::path::Path) -> std::process::Command { - let mut c = if bin.extension().and_then(|e| e.to_str()) == Some("cmd") { - let mut c = std::process::Command::new("cmd"); - c.arg("/C").arg(bin); - c - } else { - std::process::Command::new(bin) - }; - c.no_console(); - c -} - -#[cfg(not(target_os = "windows"))] -pub fn build_command(bin: &std::path::Path) -> std::process::Command { - let mut c = std::process::Command::new(bin); - c.no_console(); - c -} +// `build_command` 는 `agents::win_spawn::wrap_windows_script` 로 통합되었다. +// 모든 호출 site (`opencode.rs` / `gemini.rs` / `codex.rs`) 가 `wrap_windows_script` +// SSOT 로 전환되어 본 helper 는 제거. 회귀 가드 grep 단일화 효과. // ─── Tests ────────────────────────────────────────────────────────────────── diff --git a/src-tauri/src/agents/win_spawn.rs b/src-tauri/src/agents/win_spawn.rs new file mode 100644 index 0000000..c4d5d4c --- /dev/null +++ b/src-tauri/src/agents/win_spawn.rs @@ -0,0 +1,116 @@ +//! Windows `.cmd`/`.bat` spawn wrapper — `std::process::Command` SSOT. +//! +//! ## 배경 +//! Rust 1.77+ 부터 `std::process::Command` 가 `.cmd`/`.bat` 파일을 직접 spawn +//! 할 때 인자에 raw 특수문자 (`"`, `\`, 제어문자 등) 가 포함되면 batch arg +//! escape 강화 (CVE-2024-24576) 로 `InvalidInput` reject 한다 — 메시지 +//! `batch file arguments are invalid`. +//! +//! npm 글로벌 CLI (gemini / codex / claude / opencode 등) 는 Windows 에서 +//! `%APPDATA%\npm\.cmd` wrapper 형태로 설치되므로, `Command::new` 가 +//! 그 path 를 직접 spawn 시 위 회귀 발생. +//! +//! ## 해법 +//! PR #278 (`commands/project_onboarding.rs:587~613`) 가 onboarding 영역에 +//! 도입한 패턴: `.cmd` / `.bat` 은 `cmd /C ` 로 wrapping 해 batch arg +//! 검사를 cmd.exe 가 자체 처리하도록 위임. agents/ 영역에 같은 패턴을 +//! SSOT helper 로 추출. +//! +//! ## 시그니처 선택 +//! `&str path + &[&str] args` 형태로 받아 native `std::process::Command` +//! 반환. 호출 site 는 그 후 `.no_console()` / `.stdin()` / `.stdout()` / +//! `.stderr()` / `.current_dir()` / `.spawn()` chain 을 그대로 유지한다. +//! 추가 args 가 동적 (model 조건부, image_paths 등) 인 호출 site 도 반환된 +//! Command 에 `.arg(...)` 를 이어 붙이면 된다. + +use std::process::Command; + +/// `.cmd` / `.bat` 파일이면 `cmd /C` 로 wrapping, 그 외는 직접 spawn. +/// macOS / Linux 에선 항상 직접 spawn (cfg 분기). +/// +/// `args` 는 path 다음에 forward 되는 초기 인자 (예: node script path). +/// 후속 인자는 호출 site 에서 반환된 Command 에 `.arg(...)` 로 chain. +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 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_script_path_returns_direct_command() { + // `.exe` / no-extension 은 plain wrapping 없이 직접 spawn. + // get_program() 으로 program 비교 — Windows 에선 .cmd 만 cmd /C 분기. + let cmd = wrap_windows_script("gemini", &["-p", "hello"]); + // bare name → 직접 spawn (cfg 가 windows 이면 .cmd 가 아니므로 직접) + let program = cmd.get_program().to_string_lossy().into_owned(); + #[cfg(not(target_os = "windows"))] + assert_eq!(program, "gemini"); + #[cfg(target_os = "windows")] + assert_eq!(program, "gemini"); + } + + #[test] + fn exe_path_returns_direct_command() { + let cmd = wrap_windows_script("C:\\path\\to\\binary.exe", &[]); + let program = cmd.get_program().to_string_lossy().into_owned(); + // `.exe` 는 batch 아니므로 직접 spawn. + assert!(program.ends_with("binary.exe")); + } + + #[test] + fn cmd_extension_is_handled() { + let cmd = wrap_windows_script("C:\\Users\\u\\AppData\\Roaming\\npm\\gemini.cmd", &["-p", "x"]); + let program = cmd.get_program().to_string_lossy().into_owned(); + let args: Vec = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect(); + + #[cfg(target_os = "windows")] + { + // Windows: cmd /C 분기. program == "cmd", 첫 args == "/C", 두번째 == 원 path. + assert_eq!(program, "cmd"); + assert_eq!(args[0], "/C"); + assert!(args[1].ends_with("gemini.cmd")); + assert_eq!(args[2], "-p"); + assert_eq!(args[3], "x"); + } + #[cfg(not(target_os = "windows"))] + { + // macOS / Linux: cfg 분기 안 가서 직접 spawn (실제 호출은 거의 없지만 helper 동작 안정성 검증). + assert!(program.ends_with("gemini.cmd")); + assert_eq!(args, vec!["-p", "x"]); + } + } + + #[test] + fn bat_extension_case_insensitive() { + let cmd = wrap_windows_script("D:\\tools\\foo.BAT", &[]); + let program = cmd.get_program().to_string_lossy().into_owned(); + + #[cfg(target_os = "windows")] + { + // `.BAT` 대문자도 case-insensitive 비교로 wrapping. + assert_eq!(program, "cmd"); + } + #[cfg(not(target_os = "windows"))] + { + assert!(program.ends_with("foo.BAT")); + } + } +}