Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions src-tauri/src/agents/claude.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<RunOutput, AppError> {
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)
Expand Down
10 changes: 7 additions & 3 deletions src-tauri/src/agents/codex.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -54,7 +55,9 @@ fn push_agent_text_dedup(texts: &mut Vec<String>, incoming: &str) {
pub fn run(input: RunInput) -> Result<RunOutput, AppError> {
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);
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 7 additions & 3 deletions src-tauri/src/agents/gemini.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -27,7 +28,9 @@ use super::claude::resolve_cwd;
pub fn run(input: RunInput) -> Result<RunOutput, AppError> {
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);
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ pub mod opencode;
pub mod rawq;
pub mod resolve;
pub mod tool_handler;
pub mod win_spawn;
13 changes: 11 additions & 2 deletions src-tauri/src/agents/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
}
Comment on lines 69 to 74
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
}


pub fn run(input: RunInput) -> Result<RunOutput, AppError> {
Expand Down
26 changes: 3 additions & 23 deletions src-tauri/src/agents/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -178,27 +176,9 @@ pub fn first_existing(candidates: &[PathBuf]) -> Option<PathBuf> {
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 ──────────────────────────────────────────────────────────────────

Expand Down
116 changes: 116 additions & 0 deletions src-tauri/src/agents/win_spawn.rs
Original file line number Diff line number Diff line change
@@ -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\<name>.cmd` wrapper 형태로 설치되므로, `Command::new` 가
//! 그 path 를 직접 spawn 시 위 회귀 발생.
//!
//! ## 해법
//! PR #278 (`commands/project_onboarding.rs:587~613`) 가 onboarding 영역에
//! 도입한 패턴: `.cmd` / `.bat` 은 `cmd /C <path>` 로 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);
Comment on lines +37 to +39
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.

for a in args {
c.arg(a);
}
return c;
}
}
let mut c = Command::new(cmd_path);
for a in args {
c.arg(a);
}
c
}
Comment on lines +33 to +51
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
}


#[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<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();

#[cfg(target_os = "windows")]
{
// Windows: cmd /C <path> 분기. 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"));
}
}
}
Loading