Files
openidea/crates/ide-agent/src/native/mod.rs
arookieofcandl0ng-ai 504d8592d2 fix(tools): include the Windows user PATH agents and tools are resolved on (#499)
* fix(tools): include the Windows user PATH agents and tools are resolved on

On Windows the login-shell environment is never read (it is a Unix-only path),
so a process started before the user PATH was edited — or one that simply does
not inherit it — resolves agent executables and language-server tools on the
machine PATH alone. A tool installed for the user (npm -g, \dotnet tool
install -g\, a vendor installer) is then reported as not on PATH.

Read the user's own PATH from HKCU\\Environment (expanding REG_EXPAND_SZ
entries) and append its directories after the inherited ones in both the tool
search path and the native agent lookup, so user installs resolve without
shadowing the inherited order.

Fixes #330.

* fix(tools): use div_ceil for the registry buffer size

* fix(tools): add the Windows user PATH to the process PATH at startup

Lookups alone left agents reported as not installed, and programs started from
user PATH entries could not find their own dependencies.

---------

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
2026-10-31 10:15:04 +08:00

149 lines
6.7 KiB
Rust

//! Agents driven over their own protocol rather than ACP: `codex app-server` and Claude
//! Code's stream-json mode. Each runs behind an in-process ACP endpoint (a shim), so a
//! task drives every agent the same way and reads what it can do from `initialize`.
//!
//! A shim speaks ACP v1 as the agent side, plus:
//! - `agentCapabilities.sessionCapabilities.fork` and `session/fork`
//! (`{ sessionId, cwd, mcpServers }` → a `session/new` result for the copy);
//! - `agentCapabilities._meta.openidea.rollback` and `_openidea/rollback`
//! (`{ sessionId, turns }`: drops the last `turns` user turns from the session);
//! - `agentCapabilities._meta.openidea.steer` and `_openidea/steer`
//! (`{ sessionId, prompt }`: adds ACP content blocks to the running turn; fails when
//! no turn runs).
pub(crate) mod claude;
pub(crate) mod codex;
use ide_jsonrpc::{Framing, Handler, Peer};
use std::path::{Path, PathBuf};
use std::sync::Arc;
/// The newest install of `command` on the `PATH` a child sees, by its `--version`.
/// Several installs are common (npm, Homebrew, the vendor's own installer), and the first
/// on `PATH` is often an old one that the service no longer serves the current models to.
/// A path, or a name not found on `PATH`, is used as it is; a single match returns its
/// resolved path so Windows `PATHEXT` shims (`claude.cmd`, `codex.cmd`) start.
pub(crate) fn newest_install(command: &str) -> String {
let path = ide_core::process::host_var_os("PATH")
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
.unwrap_or_default();
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
newest_install_in(command, &ide_core::process::with_user_path(&path))
}
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
if Path::new(command).components().count() > 1 || Path::new(command).is_absolute() {
return command.to_owned();
}
let pathext = ide_core::process::host_var_os("PATHEXT");
let names =
ide_core::process::executable_names(command, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
let mut seen = Vec::new();
let candidates: Vec<PathBuf> = std::env::split_paths(search)
.filter(|d| !d.as_os_str().is_empty())
.flat_map(|dir| names.iter().map(move |n| dir.join(n)))
.filter(|p| p.is_file())
.filter(|p| {
let real = dunce::canonicalize(p).unwrap_or_else(|_| p.clone());
let new = !seen.contains(&real);
seen.push(real);
new
})
.collect();
if candidates.len() < 2 {
return candidates.into_iter().next().map_or_else(|| command.to_owned(), |p| p.to_string_lossy().into_owned());
}
candidates
.into_iter()
.filter_map(|p| version_of(&p).map(|v| (v, p)))
.max_by(|a, b| a.0.cmp(&b.0))
.map_or_else(|| command.to_owned(), |(_, p)| p.to_string_lossy().into_owned())
}
/// `x.y.z` from `<program> --version` ("codex-cli 0.164.0", "2.1.312 (Claude Code)").
fn version_of(program: &Path) -> Option<Vec<u64>> {
let mut cmd = std::process::Command::new(program);
ide_core::process::prepare(&mut cmd);
let out = cmd.arg("--version").envs(ide_core::process::shell_env()).output().ok()?;
parse_version(&ide_core::output_encoding::decode_tool_output(&out.stdout))
}
fn parse_version(text: &str) -> Option<Vec<u64>> {
text.split_whitespace().find_map(|word| {
let parts: Vec<u64> = word.trim_start_matches('v').split('.').map_while(|p| p.parse().ok()).collect();
(parts.len() >= 2).then_some(parts)
})
}
/// The agent side of an in-process ACP connection.
pub(crate) trait Shim: Handler {
/// Called once, before any message arrives: the connection to the client, for
/// `session/update` notifications and `session/request_permission` requests.
fn attach(&self, client: Peer);
}
/// Connects `client` to `shim`; returns the client's side. When the client goes away the
/// shim's connection closes (`Peer::closed`); a shim closes the client's by killing its
/// own side (`Peer::kill`) when its process exits.
pub(crate) fn serve<S: Shim>(shim: Arc<S>, client: Arc<dyn Handler>) -> Peer {
let (ours, theirs) = tokio::io::duplex(1 << 20);
let (or, ow) = tokio::io::split(ours);
let (tr, tw) = tokio::io::split(theirs);
let to_client = Peer::connect_framed(tr, tw, shim.clone(), Framing::Lines);
shim.attach(to_client);
Peer::connect_framed(or, ow, client, Framing::Lines)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_are_read_from_either_side_of_the_name() {
assert_eq!(parse_version("codex-cli 0.164.0\n"), Some(vec![0, 164, 0]));
assert_eq!(parse_version("2.1.312 (Claude Code)"), Some(vec![2, 1, 312]));
assert_eq!(parse_version("v1.2"), Some(vec![1, 2]));
assert_eq!(parse_version("no version here"), None);
assert!(parse_version("codex-cli 0.164.0") > parse_version("codex-cli 0.147.0"));
}
#[test]
fn newest_install_resolves_single_candidate_and_keeps_missing_or_paths() {
let dir = tempfile::tempdir().unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(a.join("claude"), "#!/bin/sh\n").unwrap();
std::fs::write(b.join("claude.cmd"), "@echo off\n").unwrap();
let search = std::env::join_paths([&a, &b]).unwrap();
if cfg!(windows) {
assert_eq!(newest_install_in("claude", &search), b.join("claude.cmd").to_string_lossy());
} else {
assert_eq!(newest_install_in("claude", &search), a.join("claude").to_string_lossy());
}
assert_eq!(newest_install_in("nope", &search), "nope");
assert_eq!(newest_install_in("bin/claude", &search), "bin/claude");
}
#[cfg(unix)]
#[test]
fn newest_install_compares_versions_across_path_entries() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let old = dir.path().join("old");
let new = dir.path().join("new");
for (path, version) in [(&old, "2.9.0"), (&new, "2.10.0")] {
std::fs::create_dir_all(path).unwrap();
let executable = path.join("agent");
std::fs::write(&executable, format!("#!/bin/sh\necho {version}\n")).unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
}
for paths in [[&old, &new], [&new, &old]] {
let search = std::env::join_paths(paths).unwrap();
assert_eq!(newest_install_in("agent", &search), new.join("agent").to_string_lossy());
}
}
}