fix: hide ai agent subprocess windows

This commit is contained in:
lucaronin
2026-04-27 21:21:00 +02:00
parent 1301653b81
commit a5c4b8fa20
3 changed files with 94 additions and 25 deletions

View File

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

View File

@@ -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<String> {
Command::new(binary)
crate::hidden_command(binary)
.arg("--version")
.output()
.ok()
@@ -138,7 +138,7 @@ fn find_codex_binary() -> Result<PathBuf, String> {
}
fn find_codex_binary_on_path() -> Option<PathBuf> {
Command::new("which")
crate::hidden_command("which")
.arg("codex")
.output()
.ok()
@@ -165,7 +165,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
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<String>,
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<Vec<String>, 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<AiAge
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsStr;
#[test]
fn normalize_status_contains_both_agents() {
@@ -497,6 +508,25 @@ mod tests {
}
}
#[test]
fn build_codex_command_keeps_agent_process_contract() {
let binary = PathBuf::from("codex");
let args = vec!["exec".to_string(), "--json".to_string()];
let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault");
let actual_args: Vec<&OsStr> = 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");

View File

@@ -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<PathBuf, String> {
}
fn find_claude_binary_on_path() -> Option<PathBuf> {
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<PathBuf> {
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
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<F>(
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<String> {
#[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<OsString> = 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<String, String>, Vec<ClaudeStreamEvent>) {
run_mock_script_with_args(script, &[])