fix(ai): preserve gui path for cli agents

This commit is contained in:
lucaronin
2026-05-02 01:08:09 +02:00
parent c23a3c1caf
commit 0b5149ba8d
7 changed files with 153 additions and 4 deletions

View File

@@ -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

View File

@@ -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)]

View File

@@ -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<String, String> {
}
pub(crate) fn version_for_binary(binary: &PathBuf) -> Option<String> {
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<String> {
.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<OsString> {
let mut paths = std::env::var_os("PATH")
.map(|path| std::env::split_paths(&path).collect::<Vec<_>>())
.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<PathBuf> {
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<PathBuf>, 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<PathBuf>,
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<F>(
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::<Vec<_>>();
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() {

View File

@@ -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::<Vec<_>>();
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() {

View File

@@ -10,6 +10,7 @@ pub(crate) fn build_command(
) -> Result<std::process::Command, String> {
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::<Vec<_>>();
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();

View File

@@ -8,6 +8,7 @@ pub(crate) fn build_command(
request: &AgentStreamRequest,
) -> Result<std::process::Command, String> {
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))

View File

@@ -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))