diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9bbe04b4..c468bc27 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -218,7 +218,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too 1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection 2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters -3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`. Both app-launched paths can edit the active vault without enabling dangerous permission bypass modes. +3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json` with the same hidden Windows launch policy. Both app-launched paths can edit the active vault without enabling dangerous permission bypass modes. 4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index 27382c45..163add65 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -1,7 +1,7 @@ use serde::{Deserialize, Serialize}; use std::io::BufRead; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; +use std::process::Stdio; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -113,7 +113,7 @@ fn availability_from_codex() -> AiAgentAvailability { } fn version_for_binary(binary: &PathBuf) -> Option { - Command::new(binary) + crate::hidden_command(binary) .arg("--version") .output() .ok() @@ -138,7 +138,7 @@ fn find_codex_binary() -> Result { } fn find_codex_binary_on_path() -> Option { - Command::new("which") + crate::hidden_command("which") .arg("codex") .output() .ok() @@ -165,7 +165,7 @@ fn user_shell_candidates() -> Vec { } fn command_path_from_shell(shell: &Path, command: &str) -> Option { - Command::new(shell) + crate::hidden_command(shell) .arg("-lc") .arg(format!("command -v {command}")) .output() @@ -225,13 +225,7 @@ where let args = build_codex_args(&request)?; let prompt = build_codex_prompt(&request); - let mut command = Command::new(binary); - command - .args(args) - .arg(prompt) - .current_dir(&request.vault_path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + let mut command = build_codex_command(&binary, args, prompt, &request.vault_path); let mut child = command .spawn() @@ -289,6 +283,22 @@ where Ok(thread_id) } +fn build_codex_command( + binary: &Path, + args: Vec, + prompt: String, + vault_path: &str, +) -> std::process::Command { + let mut command = crate::hidden_command(binary); + command + .args(args) + .arg(prompt) + .current_dir(vault_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + fn build_codex_args(request: &AiAgentStreamRequest) -> Result, String> { let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js"); let mcp_server_path = mcp_server @@ -456,6 +466,7 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option = command.get_args().collect(); + + assert_eq!(command.get_program(), OsStr::new("codex")); + assert_eq!( + actual_args, + vec![ + OsStr::new("exec"), + OsStr::new("--json"), + OsStr::new("Summarize") + ] + ); + assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); + } + #[test] fn codex_binary_candidates_include_supported_macos_installs() { let home = PathBuf::from("/Users/alex"); diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index fc39fec5..d5f2ce96 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io::BufRead; use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Stdio}; +use std::process::{ExitStatus, Stdio}; /// Status returned by `check_claude_cli`. #[derive(Debug, Serialize, Clone)] @@ -79,7 +79,7 @@ pub(crate) fn find_claude_binary() -> Result { } fn find_claude_binary_on_path() -> Option { - Command::new(claude_path_lookup_command()) + crate::hidden_command(claude_path_lookup_command()) .arg("claude") .output() .ok() @@ -114,7 +114,7 @@ fn user_shell_candidates() -> Vec { } fn command_path_from_shell(shell: &Path, command: &str) -> Option { - Command::new(shell) + crate::hidden_command(shell) .arg("-lc") .arg(format!("command -v {command}")) .output() @@ -193,7 +193,7 @@ pub fn check_cli() -> ClaudeCliStatus { } }; - let version = Command::new(&bin) + let version = crate::hidden_command(&bin) .arg("--version") .output() .ok() @@ -327,15 +327,7 @@ fn run_claude_subprocess( where F: FnMut(ClaudeStreamEvent), { - let mut cmd = Command::new(bin); - cmd.args(args) - .env_remove("CLAUDECODE") // prevent "nested session" guard - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if let Some(dir) = cwd { - cmd.current_dir(dir); - } + let mut cmd = build_claude_command(bin, args, cwd); let mut child = cmd .spawn() .map_err(|e| format!("Failed to spawn claude: {e}"))?; @@ -392,6 +384,23 @@ where Ok(state.session_id) } +fn build_claude_command( + bin: &PathBuf, + args: &[String], + cwd: Option<&str>, +) -> std::process::Command { + let mut cmd = crate::hidden_command(bin); + cmd.args(args) + .env_remove("CLAUDECODE") // prevent "nested session" guard + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + cmd +} + fn format_failed_claude_exit(stderr_output: &str, status: ExitStatus) -> String { if is_claude_auth_error(stderr_output) { return "Claude CLI is not authenticated. Run `claude auth login` in your terminal.".into(); @@ -594,6 +603,9 @@ fn extract_tool_result_text(json: &serde_json::Value) -> Option { #[cfg(test)] mod tests { use super::*; + use std::ffi::OsStr; + use std::ffi::OsString; + use std::process::Command; #[test] fn check_cli_returns_status() { @@ -907,6 +919,33 @@ mod tests { // --- run_claude_subprocess with mock scripts --- + #[test] + fn build_claude_command_keeps_streaming_process_contract() { + let bin = PathBuf::from("claude"); + let args = vec!["-p".to_string(), "hello".to_string()]; + let command = build_claude_command(&bin, &args, Some("/tmp/vault")); + let actual_args: Vec = command.get_args().map(OsStr::to_os_string).collect(); + let claude_code_env = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("CLAUDECODE")) + .map(|(_, value)| value.map(OsStr::to_os_string)); + + assert_eq!( + ( + command.get_program().to_os_string(), + actual_args, + command.get_current_dir().map(Path::to_path_buf), + claude_code_env, + ), + ( + OsString::from("claude"), + vec![OsString::from("-p"), OsString::from("hello")], + Some(PathBuf::from("/tmp/vault")), + Some(None), + ), + ); + } + #[cfg(unix)] fn run_mock_script(script: &str) -> (Result, Vec) { run_mock_script_with_args(script, &[])