diff --git a/Cargo.lock b/Cargo.lock index f677cf3..60cbf2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3360,6 +3360,7 @@ dependencies = [ "usearch", "uuid", "walkdir", + "which", "zip", ] @@ -4522,6 +4523,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 21a13a6..5fdcdec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ async-trait = "0.1" ort = { version = "=2.0.0-rc.10", features = ["load-dynamic", "ndarray"] } ndarray = "0.16" tokenizers = { version = "0.21", default-features = false, features = ["fancy-regex"] } +which = "8" kiwi-rs = "0.1" sha2 = "0.10" tracing = "0.1" diff --git a/crates/secall-core/Cargo.toml b/crates/secall-core/Cargo.toml index fdd72e8..d6c8cd0 100644 --- a/crates/secall-core/Cargo.toml +++ b/crates/secall-core/Cargo.toml @@ -23,6 +23,7 @@ async-trait.workspace = true ort.workspace = true ndarray.workspace = true tokenizers.workspace = true +which.workspace = true sha2.workspace = true regex.workspace = true futures-util.workspace = true diff --git a/crates/secall-core/src/lib.rs b/crates/secall-core/src/lib.rs index 9551bb3..8a9cf97 100644 --- a/crates/secall-core/src/lib.rs +++ b/crates/secall-core/src/lib.rs @@ -24,27 +24,27 @@ pub async fn http_post_json(url: &str, body: &serde_json::Value) -> anyhow::Resu Ok(()) } -/// 크로스플랫폼 명령어 존재 확인 -pub fn command_exists(cmd: &str) -> bool { - #[cfg(target_os = "windows")] - let check = std::process::Command::new("where.exe") - .arg(cmd) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); - - #[cfg(not(target_os = "windows"))] - let check = std::process::Command::new("which") - .arg(cmd) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false); +/// PATH (Windows 는 PATHEXT 포함) 에서 명령어의 실제 실행 파일 경로를 resolve 한다. +/// +/// P87 (issue #92): Windows 에서 npm 으로 설치된 CLI (codex, claude 등) 는 +/// `codex.cmd` 같은 배치 래퍼다. `std::process::Command::new("codex")` 는 +/// PATHEXT 를 적용하지 않아 `.exe` 만 찾고 `.cmd` 는 "program not found" 로 +/// 실패한다. `which` crate 는 PATHEXT 를 적용해 `.cmd`/`.bat`/`.exe` 를 모두 +/// 탐색하므로 실제 경로를 얻을 수 있고, Rust 1.77+ 는 `.cmd`/`.bat` 확장자가 +/// 포함된 경로를 `Command` 로 실행하면 cmd.exe 를 경유해 안전하게 실행한다. +/// +/// resolve 실패 시 입력 문자열을 그대로 `PathBuf` 로 반환한다 (기존 동작 유지). +pub fn resolve_program(cmd: &str) -> std::path::PathBuf { + which::which(cmd).unwrap_or_else(|_| std::path::PathBuf::from(cmd)) +} - check +/// 크로스플랫폼 명령어 존재 확인. +/// +/// P87 (issue #92): 외부 `where.exe` / `which` 프로세스 호출 대신 `which` crate +/// 사용 — Windows PATHEXT (`.cmd` 등) 를 적용하고, spawn 시 쓰는 [`resolve_program`] +/// 과 동일한 탐색 규칙이라 "존재하지만 spawn 실패" 불일치를 제거한다. +pub fn command_exists(cmd: &str) -> bool { + which::which(cmd).is_ok() } #[cfg(test)] diff --git a/crates/secall-core/src/search/query_expand.rs b/crates/secall-core/src/search/query_expand.rs index 1efc9b2..25b7818 100644 --- a/crates/secall-core/src/search/query_expand.rs +++ b/crates/secall-core/src/search/query_expand.rs @@ -28,7 +28,7 @@ pub fn expand_query(query: &str, db: Option<&Database>) -> Result { 쿼리: {query}" ); - let output = std::process::Command::new("claude") + let output = std::process::Command::new(crate::resolve_program("claude")) .args(["-p", &prompt, "--model", "claude-haiku-4-5-20251001"]) .output()?; diff --git a/crates/secall-core/src/wiki/claude.rs b/crates/secall-core/src/wiki/claude.rs index 53d5a75..7e80d88 100644 --- a/crates/secall-core/src/wiki/claude.rs +++ b/crates/secall-core/src/wiki/claude.rs @@ -36,7 +36,7 @@ impl WikiBackend for ClaudeBackend { _ => "claude-sonnet-4-6", }; - let mut child = tokio::process::Command::new("claude") + let mut child = tokio::process::Command::new(crate::resolve_program("claude")) .args(["-p", "--model", model_id]) .arg("--allowedTools") .arg("mcp__secall__recall,mcp__secall__get,mcp__secall__status,mcp__secall__wiki_search,Read,Write,Edit,Glob,Grep") diff --git a/crates/secall-core/src/wiki/codex.rs b/crates/secall-core/src/wiki/codex.rs index e873e31..11ad3ee 100644 --- a/crates/secall-core/src/wiki/codex.rs +++ b/crates/secall-core/src/wiki/codex.rs @@ -27,7 +27,7 @@ impl WikiBackend for CodexBackend { let output_file = tempfile::NamedTempFile::new()?; let output_path = output_file.path().to_path_buf(); - let mut child = tokio::process::Command::new("codex") + let mut child = tokio::process::Command::new(crate::resolve_program("codex")) .args([ "exec", "--skip-git-repo-check", diff --git a/crates/secall-core/src/wiki/reviewers/claude.rs b/crates/secall-core/src/wiki/reviewers/claude.rs index 6d16e2e..aa8abb2 100644 --- a/crates/secall-core/src/wiki/reviewers/claude.rs +++ b/crates/secall-core/src/wiki/reviewers/claude.rs @@ -39,6 +39,9 @@ async fn run_review_cli( anyhow::bail!("{bin} CLI not found in PATH"); } + // Gemini PR #96: PATH 탐색 (disk I/O) 은 루프 밖에서 1회만. + let program = crate::resolve_program(bin); + for strict in [false, true] { let prompt = format!( "{}\n\n{}", @@ -46,7 +49,7 @@ async fn run_review_cli( super::build_user_prompt(page_content, source_summary, strict) ); - let mut child = tokio::process::Command::new(bin); + let mut child = tokio::process::Command::new(&program); child .args(args) .stdin(Stdio::piped()) diff --git a/crates/secall-core/src/wiki/reviewers/codex.rs b/crates/secall-core/src/wiki/reviewers/codex.rs index 50851b6..45ea00e 100644 --- a/crates/secall-core/src/wiki/reviewers/codex.rs +++ b/crates/secall-core/src/wiki/reviewers/codex.rs @@ -19,6 +19,9 @@ impl WikiReviewer for CodexReviewer { anyhow::bail!("codex CLI not found in PATH"); } + // Gemini PR #96: PATH 탐색 (disk I/O) 은 루프 밖에서 1회만. + let program = crate::resolve_program("codex"); + for strict in [false, true] { let prompt = format!( "{}\n\n{}", @@ -29,7 +32,7 @@ impl WikiReviewer for CodexReviewer { let output_file = tempfile::NamedTempFile::new()?; let output_path = output_file.path().to_path_buf(); - let mut child = tokio::process::Command::new("codex"); + let mut child = tokio::process::Command::new(&program); child .args([ "exec", diff --git a/docs/plans/index.md b/docs/plans/index.md index 3b5260b..ba1387b 100644 --- a/docs/plans/index.md +++ b/docs/plans/index.md @@ -166,3 +166,11 @@ Plan document index. Register new plans here. - [전체 계획서](p86-ollama-batch-fail-fast.md) — in_progress, 2026-05-19 - 단일 Task: `commands/wiki.rs` 의 backend 선택 직후 ollama/lmstudio 차단 + 가이드 메시지. silent 30분 wait 사고 차단. - 관련: issue #88 (cakel). + +--- + +### seCall P87 — Windows `.cmd` 래퍼 CLI spawn 실패 fix (issue #92) + +- [전체 계획서](p87-windows-cmd-spawn.md) — in_progress, 2026-05-29 +- 단일 Task: `which` crate 로 `resolve_program` 추가 — Windows PATHEXT 적용해 npm `.cmd` 래퍼 (codex/claude) 를 정상 spawn. spawn 5곳 + `command_exists` 통일. +- 관련: issue #92 (cakel). diff --git a/docs/plans/p87-windows-cmd-spawn.md b/docs/plans/p87-windows-cmd-spawn.md new file mode 100644 index 0000000..d97cfa1 --- /dev/null +++ b/docs/plans/p87-windows-cmd-spawn.md @@ -0,0 +1,90 @@ +--- +type: plan +status: in_progress +updated_at: 2026-05-29 +canonical: true +--- + +# P87 — Windows `.cmd` 래퍼 CLI spawn 실패 fix (issue #92) + +## 배경 + +Issue #92 (cakel, Windows 10): `secall wiki update --backend codex` 실행 시: +``` +Wiki update: all sessions (backend: codex) + Launching codex... +Error: program not found +``` +`where codex` 는 `codex.cmd` + `codex` 를 찾고 `codex` 명령 자체도 동작하는데 secall 만 실패. + +## 원인 + +- codex 는 npm 으로 설치되어 `C:\Users\User\AppData\Roaming\npm\codex.cmd` (배치 래퍼) 형태다. +- `std::process::Command::new("codex")` 는 Windows 에서 **PATHEXT 를 적용하지 않아** `codex.exe` 만 시도하고 `codex.cmd` 는 못 찾아 "program not found". +- 반면 `command_exists` 는 `where.exe codex` 외부 호출로 `.cmd` 를 찾으므로 **통과** → "존재하는데 spawn 실패" 불일치. + +## 목표 + +- Windows 에서 npm `.cmd` 래퍼 CLI (codex / claude) 를 정상 spawn. +- macOS / Linux 회귀 없음. +- "존재 확인" 과 "실제 spawn" 의 탐색 규칙 일치. + +## 비목표 + +- codex/claude 외 다른 외부 명령 (ollama, git) 의 동작 변경 없음 (영향 받지만 회귀 없음). + +## 구현 + +### 1. `which` crate 도입 + +`Cargo.toml` workspace dep `which = "8"` + `secall-core/Cargo.toml` `which.workspace = true`. + +`which` 는 Windows 에서 PATHEXT (`.CMD`/`.EXE`/`.BAT`) 를 적용해 실제 경로를 탐색한다. + +### 2. `lib.rs` — `resolve_program` + `command_exists` 재구현 + +```rust +pub fn resolve_program(cmd: &str) -> std::path::PathBuf { + which::which(cmd).unwrap_or_else(|_| std::path::PathBuf::from(cmd)) +} + +pub fn command_exists(cmd: &str) -> bool { + which::which(cmd).is_ok() +} +``` + +`resolve_program` 이 `codex.cmd` 풀경로를 반환 → Rust 1.77+ 가 `.cmd`/`.bat` 확장자 경로를 `Command` 로 실행 시 cmd.exe 경유로 안전 실행 (인자 escaping 포함). + +### 3. spawn 사이트 5곳 — `Command::new(resolve_program(X))` + +| 파일 | 명령 | +|---|---| +| `wiki/codex.rs:30` | codex | +| `wiki/claude.rs:39` | claude | +| `wiki/reviewers/codex.rs:32` | codex | +| `wiki/reviewers/claude.rs` (`run_review_cli`) | bin (claude/codex) | +| `search/query_expand.rs:31` | claude | + +## 변경 파일 + +- `Cargo.toml`, `crates/secall-core/Cargo.toml` — which dep +- `crates/secall-core/src/lib.rs` — resolve_program + command_exists +- `crates/secall-core/src/wiki/{codex,claude}.rs` +- `crates/secall-core/src/wiki/reviewers/{codex,claude}.rs` +- `crates/secall-core/src/search/query_expand.rs` +- `docs/plans/p87-windows-cmd-spawn.md` (신규) + `docs/plans/index.md` + +## 검증 + +```bash +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test -p secall-core --lib # command_exists 회귀 (3건) +``` + +⚠️ **Windows 실제 spawn 검증 불가** (개발 환경 macOS). 머지 전 cakel (issue #92 보고자) 에게 Windows 빌드 검증 요청 필요. macOS/Linux 는 `which` 가 확장자 없는 실행파일 그대로 resolve → 회귀 없음 (위 테스트 통과 확인). + +## 리스크 + +- `which` resolve 실패 시 입력 문자열 그대로 fallback → 기존 동작 유지 (최소 회귀). +- Rust 1.77+ 의 `.cmd` 자동 cmd.exe 경유 실행에 의존 — secall MSRV 1.75 보다 높음. 단 실제 빌드/CI 는 최신 toolchain (1.94) 사용. MSRV 표기는 별도 검토 (본 PR 범위 외 — 코드는 1.75 에서도 컴파일됨, .cmd 실행 동작만 1.77+).