fix: avoid Windows npm cmd shim launches
This commit is contained in:
@@ -279,7 +279,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 permission-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. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Codex, OpenCode, Pi, Gemini, and Kiro all launch from the active vault cwd with transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. 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 using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault.
|
||||
|
||||
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 expand the active vault path before using it as the subprocess working directory, then 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.
|
||||
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 expand the active vault path before using it as the subprocess working directory, then 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. Windows npm `.cmd` shims are not spawned directly; the shared CLI runtime resolves them to their quoted Node script or native executable target first so prompt arguments do not hit batch-file argument validation.
|
||||
|
||||
CLI-agent system prompts also include a local Tolaria docs orientation when the bundled docs resource is present. `scripts/build-agent-docs.mjs` generates `src-tauri/resources/agent-docs/` from the public VitePress Markdown sources, including `index.md`, `AGENTS.md`, per-section bundles, `all.md`, `search-index.json`, and generated per-page files. Tauri bundles that folder as `agent-docs/`; `get_agent_docs_path` resolves the installed resource path, with a repository fallback for development, and `getAgentDocsPath()` caches it before each agent run. Agents are instructed to read the active vault's `AGENTS.md` for local conventions and search the bundled docs for Tolaria product behavior.
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ use std::io::{BufRead, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitStatus};
|
||||
|
||||
mod windows_cmd_shim;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AgentStreamRequest {
|
||||
pub message: String,
|
||||
@@ -107,13 +109,8 @@ pub(crate) fn version_for_binary(binary: &Path) -> Option<String> {
|
||||
pub(crate) fn command_target_avoiding_windows_cmd_shim(
|
||||
binary: &Path,
|
||||
) -> Result<AgentCommandTarget, String> {
|
||||
if is_windows_batch_shim(binary) {
|
||||
if let Some(script) = node_script_from_windows_cmd_shim(binary) {
|
||||
return Ok(AgentCommandTarget {
|
||||
program: crate::mcp::find_node()?,
|
||||
first_arg: Some(script),
|
||||
});
|
||||
}
|
||||
if let Some(target) = windows_cmd_shim::command_target(binary)? {
|
||||
return Ok(target);
|
||||
}
|
||||
|
||||
Ok(AgentCommandTarget {
|
||||
@@ -244,43 +241,6 @@ pub(crate) fn has_windows_cli_extension(path: &Path) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn is_windows_batch_shim(binary: &Path) -> bool {
|
||||
binary
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
extension.eq_ignore_ascii_case("cmd") || extension.eq_ignore_ascii_case("bat")
|
||||
})
|
||||
}
|
||||
|
||||
fn node_script_from_windows_cmd_shim(binary: &Path) -> Option<PathBuf> {
|
||||
let contents = std::fs::read_to_string(binary).ok()?;
|
||||
contents
|
||||
.split('"')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.filter(|token| is_node_script_token(token))
|
||||
.find_map(|token| resolve_cmd_shim_script_path(binary, token))
|
||||
}
|
||||
|
||||
fn is_node_script_token(token: &str) -> bool {
|
||||
let lower = token.to_ascii_lowercase();
|
||||
(lower.ends_with(".js") || lower.ends_with(".mjs") || lower.ends_with(".cjs"))
|
||||
&& (lower.starts_with("%dp0%") || lower.starts_with("%~dp0"))
|
||||
}
|
||||
|
||||
fn resolve_cmd_shim_script_path(binary: &Path, token: &str) -> Option<PathBuf> {
|
||||
let relative = token
|
||||
.strip_prefix("%dp0%")
|
||||
.or_else(|| token.strip_prefix("%~dp0"))?
|
||||
.trim_start_matches(['\\', '/']);
|
||||
let mut script = binary.parent()?.to_path_buf();
|
||||
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
|
||||
script.push(part);
|
||||
}
|
||||
script.is_file().then_some(script)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_json_line(
|
||||
line: Result<String, std::io::Error>,
|
||||
) -> Result<Option<serde_json::Value>, String> {
|
||||
|
||||
115
src-tauri/src/cli_agent_runtime/windows_cmd_shim.rs
Normal file
115
src-tauri/src/cli_agent_runtime/windows_cmd_shim.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use super::AgentCommandTarget;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn command_target(binary: &Path) -> Result<Option<AgentCommandTarget>, String> {
|
||||
if !is_batch_shim(binary) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(target) = target_path(binary) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if is_node_script(&target) {
|
||||
return Ok(Some(AgentCommandTarget {
|
||||
program: crate::mcp::find_node()?,
|
||||
first_arg: Some(target),
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(Some(AgentCommandTarget {
|
||||
program: target,
|
||||
first_arg: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_batch_shim(binary: &Path) -> bool {
|
||||
has_extension(binary, &["cmd", "bat"])
|
||||
}
|
||||
|
||||
fn target_path(binary: &Path) -> Option<PathBuf> {
|
||||
let contents = std::fs::read_to_string(binary).ok()?;
|
||||
contents
|
||||
.split('"')
|
||||
.skip(1)
|
||||
.step_by(2)
|
||||
.find_map(|token| resolve_target_path(binary, token))
|
||||
}
|
||||
|
||||
fn resolve_target_path(binary: &Path, token: &str) -> Option<PathBuf> {
|
||||
let relative = token
|
||||
.strip_prefix("%dp0%")
|
||||
.or_else(|| token.strip_prefix("%~dp0"))?
|
||||
.trim_start_matches(['\\', '/']);
|
||||
let mut target = binary.parent()?.to_path_buf();
|
||||
for part in relative.split(['\\', '/']).filter(|part| !part.is_empty()) {
|
||||
target.push(part);
|
||||
}
|
||||
is_supported_target(&target)
|
||||
.then_some(target)
|
||||
.filter(|target| target.is_file())
|
||||
}
|
||||
|
||||
fn is_supported_target(path: &Path) -> bool {
|
||||
is_node_script(path) || is_native_executable(path)
|
||||
}
|
||||
|
||||
fn is_node_script(path: &Path) -> bool {
|
||||
has_extension(path, &["js", "mjs", "cjs"])
|
||||
}
|
||||
|
||||
fn is_native_executable(path: &Path) -> bool {
|
||||
has_extension(path, &["exe", "com"]) && !has_file_name(path, "node.exe")
|
||||
}
|
||||
|
||||
fn has_extension(path: &Path, expected_extensions: &[&str]) -> bool {
|
||||
path.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
expected_extensions
|
||||
.iter()
|
||||
.any(|expected| extension.eq_ignore_ascii_case(expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn has_file_name(path: &Path, expected_name: &str) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(expected_name))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_target_uses_native_exe_from_windows_cmd_shim() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let shim = dir.path().join("claude.cmd");
|
||||
let native_exe = dir
|
||||
.path()
|
||||
.join("node_modules")
|
||||
.join("@anthropic-ai")
|
||||
.join("claude-code")
|
||||
.join("bin")
|
||||
.join("claude.exe");
|
||||
std::fs::create_dir_all(native_exe.parent().unwrap()).unwrap();
|
||||
std::fs::write(dir.path().join("node.exe"), "node runtime").unwrap();
|
||||
std::fs::write(&native_exe, "native claude launcher").unwrap();
|
||||
std::fs::write(
|
||||
&shim,
|
||||
r#"@ECHO off
|
||||
IF EXIST "%~dp0\node.exe" (
|
||||
SET "_prog=%~dp0\node.exe"
|
||||
)
|
||||
"%~dp0\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %*
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let target = command_target(&shim).unwrap().unwrap();
|
||||
|
||||
assert_eq!(target.program, native_exe);
|
||||
assert_eq!(target.first_arg, None);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user