diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 43e9e8fd..3b6025dd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -249,7 +249,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too 4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. 5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter`, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH` -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, nvm-managed Node installs, 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. +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, nvm-managed Node installs, 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. App-managed CLI spawns also extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path. #### Agent Event Flow diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 9abeb716..569800b5 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -387,6 +387,7 @@ fn build_claude_command( cwd: Option<&str>, ) -> std::process::Command { let mut cmd = crate::hidden_command(bin); + crate::cli_agent_runtime::configure_agent_command_environment(&mut cmd, bin); cmd.args(args) .env_remove("CLAUDECODE") // prevent "nested session" guard .stdin(Stdio::null()) @@ -1527,7 +1528,7 @@ mod tests { let mut events = vec![]; let result = run_claude_subprocess(&fake_bin, &[], None, &mut |e| events.push(e)); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Failed to spawn")); + assert!(result.unwrap_err().contains("Failed to start claude")); } #[cfg(unix)] diff --git a/src-tauri/src/cli_agent_runtime.rs b/src-tauri/src/cli_agent_runtime.rs index 613d815f..54e87e15 100644 --- a/src-tauri/src/cli_agent_runtime.rs +++ b/src-tauri/src/cli_agent_runtime.rs @@ -1,5 +1,6 @@ use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent}; use serde::Deserialize; +use std::ffi::OsString; use std::io::BufRead; use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus}; @@ -39,7 +40,9 @@ pub(crate) fn mcp_server_path_string() -> Result { } pub(crate) fn version_for_binary(binary: &PathBuf) -> Option { - crate::hidden_command(binary) + let mut command = crate::hidden_command(binary); + configure_agent_command_environment(&mut command, binary); + command .arg("--version") .output() .ok() @@ -47,6 +50,68 @@ pub(crate) fn version_for_binary(binary: &PathBuf) -> Option { .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) } +pub(crate) fn configure_agent_command_environment(command: &mut Command, binary: &Path) { + if let Some(path) = expanded_agent_path(binary) { + command.env("PATH", path); + } +} + +fn expanded_agent_path(binary: &Path) -> Option { + let mut paths = std::env::var_os("PATH") + .map(|path| std::env::split_paths(&path).collect::>()) + .unwrap_or_default(); + + for candidate in agent_path_candidates(binary) { + push_unique_path(&mut paths, candidate); + } + + std::env::join_paths(paths).ok() +} + +fn agent_path_candidates(binary: &Path) -> Vec { + let mut candidates = Vec::new(); + + if let Some(parent) = binary + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + candidates.push(parent.to_path_buf()); + } + + if let Some(home) = dirs::home_dir() { + candidates.extend([ + home.join(".local/bin"), + home.join(".local/share/mise/shims"), + home.join(".asdf/shims"), + home.join(".npm-global/bin"), + home.join(".npm/bin"), + home.join(".bun/bin"), + home.join(".linuxbrew/bin"), + home.join("AppData/Roaming/npm"), + home.join("AppData/Local/pnpm"), + home.join("scoop/shims"), + ]); + } + + candidates.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + PathBuf::from("/home/linuxbrew/.linuxbrew/bin"), + ]); + + candidates +} + +fn push_unique_path(paths: &mut Vec, candidate: PathBuf) { + if candidate.as_os_str().is_empty() { + return; + } + if paths.iter().any(|path| path == &candidate) { + return; + } + paths.push(candidate); +} + pub(crate) fn find_executable_binary_candidate( candidates: Vec, agent_label: &str, @@ -134,7 +199,7 @@ where { let mut child = command .spawn() - .map_err(|error| format!("Failed to spawn {process_name}: {error}"))?; + .map_err(|error| format_spawn_error(process_name, &error))?; let stdout = child.stdout.take().ok_or("No stdout handle")?; let reader = std::io::BufReader::new(stdout); let mut session_id = String::new(); @@ -166,6 +231,16 @@ where }) } +fn format_spawn_error(process_name: &str, error: &std::io::Error) -> String { + if error.kind() == std::io::ErrorKind::NotFound { + return format!( + "Failed to start {process_name}: the CLI or one of its runtime dependencies was not found. If it was installed with Homebrew, make sure /opt/homebrew/bin or /usr/local/bin contains the CLI and Node.js, then restart Tolaria. Details: {error}" + ); + } + + format!("Failed to spawn {process_name}: {error}") +} + pub(crate) fn run_ai_agent_json_stream( command: Command, process_name: &'static str, @@ -229,6 +304,31 @@ mod tests { assert!(error.contains("broken pipe")); } + #[test] + fn agent_command_environment_keeps_homebrew_shims_available() { + let mut command = Command::new("/opt/homebrew/bin/codex"); + configure_agent_command_environment(&mut command, Path::new("/opt/homebrew/bin/codex")); + let path = command + .get_envs() + .find(|(key, _)| *key == std::ffi::OsStr::new("PATH")) + .and_then(|(_, value)| value) + .expect("PATH should be set"); + let paths = std::env::split_paths(path).collect::>(); + + assert!(paths.contains(&PathBuf::from("/opt/homebrew/bin"))); + assert!(paths.contains(&PathBuf::from("/usr/local/bin"))); + } + + #[test] + fn spawn_not_found_errors_explain_gui_path_runtime_dependencies() { + let error = std::io::Error::new(std::io::ErrorKind::NotFound, "No such file or directory"); + let message = format_spawn_error("codex", &error); + + assert!(message.contains("Failed to start codex")); + assert!(message.contains("/opt/homebrew/bin")); + assert!(message.contains("Node.js")); + } + #[cfg(unix)] #[test] fn executable_binary_candidate_skips_unusable_file_when_later_candidate_works() { diff --git a/src-tauri/src/codex_cli.rs b/src-tauri/src/codex_cli.rs index 1b7cef19..3d5a5abe 100644 --- a/src-tauri/src/codex_cli.rs +++ b/src-tauri/src/codex_cli.rs @@ -203,6 +203,7 @@ fn build_codex_command( vault_path: &str, ) -> std::process::Command { let mut command = crate::hidden_command(binary); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); command .args(args) .arg(prompt) @@ -561,6 +562,28 @@ mod tests { assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); } + #[test] + fn build_codex_command_extends_path_with_resolved_homebrew_bin() { + let binary = PathBuf::from("/opt/homebrew/bin/codex"); + let command = build_codex_command( + &binary, + vec!["exec".to_string(), "--json".to_string()], + "Summarize".into(), + "/tmp/vault", + ); + let path_value = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("PATH")) + .and_then(|(_, value)| value) + .expect("PATH should be set"); + let paths = std::env::split_paths(path_value).collect::>(); + + assert!( + paths.contains(&PathBuf::from("/opt/homebrew/bin")), + "PATH should include the resolved Codex binary directory, got {paths:?}" + ); + } + #[cfg(unix)] #[test] fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() { diff --git a/src-tauri/src/gemini_config.rs b/src-tauri/src/gemini_config.rs index cc8dfa27..f3ebdea1 100644 --- a/src-tauri/src/gemini_config.rs +++ b/src-tauri/src/gemini_config.rs @@ -10,6 +10,7 @@ pub(crate) fn build_command( ) -> Result { let settings_path = write_settings(settings_dir, &request.vault_path, request.permission_mode)?; let mut command = crate::hidden_command(binary); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); command .args(build_args(request.permission_mode)) .arg("--prompt") @@ -122,6 +123,28 @@ mod tests { assert!(settings_dir.path().join("settings.json").exists()); } + #[test] + fn command_extends_path_with_resolved_homebrew_bin() { + let settings_dir = tempfile::tempdir().unwrap(); + let command = build_command( + &PathBuf::from("/opt/homebrew/bin/gemini"), + &request(), + settings_dir.path(), + ) + .unwrap(); + let path_value = command + .get_envs() + .find(|(key, _)| *key == OsStr::new("PATH")) + .and_then(|(_, value)| value) + .expect("PATH should be set"); + let paths = std::env::split_paths(path_value).collect::>(); + + assert!( + paths.contains(&PathBuf::from("/opt/homebrew/bin")), + "PATH should include the resolved Gemini binary directory, got {paths:?}" + ); + } + #[test] fn safe_settings_include_tolaria_mcp_and_exclude_shell() { let settings = build_settings("/tmp/vault", AiAgentPermissionMode::Safe).unwrap(); diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index 3051ae4f..beb02095 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -8,6 +8,7 @@ pub(crate) fn build_command( request: &AgentStreamRequest, ) -> Result { let mut command = crate::hidden_command(binary); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); command .args(build_args()) .arg(build_prompt(request)) diff --git a/src-tauri/src/pi_config.rs b/src-tauri/src/pi_config.rs index 97bef3a7..dfb6aab2 100644 --- a/src-tauri/src/pi_config.rs +++ b/src-tauri/src/pi_config.rs @@ -11,6 +11,7 @@ pub(crate) fn build_command( write_mcp_config(agent_dir, &request.vault_path, request.permission_mode)?; let mut command = crate::hidden_command(binary); + crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary); command .args(build_args()) .arg(build_prompt(request))