fix(acp): 子进程 PATH 补上 GUI 看不到的工具链目录

pi、cline 这类 ACP 适配器只是壳,真正干活的是它们再 spawn 的 CLI
(pi-acp 会去 PATH 里找 pi)。原来只把程序目录塞进子进程 PATH,
GUI 启动的进程 PATH 里没有 nvm / fnm / volta 目录,适配器拿到的
ENOENT 就只能写进日志:

  Could not start pi: executable not found (command: pi)

补齐 fallback 工具链目录与基础系统目录;配置里写了 PATH 时以它为底
补全,不再整条换掉。
This commit is contained in:
胡飞
2026-10-28 16:11:37 +08:00
parent 812e505524
commit 415fe13e4f
4 changed files with 153 additions and 14 deletions
+4 -1
View File
@@ -1,6 +1,9 @@
mod program;
pub use program::{build_command, child_path_with_program_dir, resolve_program};
pub use program::{
build_command, child_path_with_program_dir, child_path_with_program_dir_and_path,
resolve_program,
};
pub const CREATE_NO_WINDOW: u32 = 0x0800_0000;
+94 -8
View File
@@ -23,6 +23,11 @@ const SYSTEM_PROGRAM_DIRS: &[&str] = &[
"/snap/bin",
];
/// macOS / Linux 上任何进程都该有的基础目录:GUI 进程的 PATH 被裁剪到极短,
/// 甚至可能只剩工具链目录,`sh` / `env` 这类基础命令不能因此丢失。
#[cfg(not(windows))]
const BASE_SYSTEM_DIRS: &[&str] = &["/usr/bin", "/bin", "/usr/sbin", "/sbin"];
/// 用户目录下与版本管理器相关的 bin 目录(不含 nvm / fnm 的版本目录)。
#[cfg(not(windows))]
const HOME_PROGRAM_DIRS: &[&str] = &[
@@ -55,23 +60,48 @@ pub fn build_command<S: AsRef<OsStr>>(name: &str, args: &[S]) -> Option<Command>
}
/// 把程序所在目录前置进子进程 PATH,其余部分保持当前进程的 PATH 顺序。
///
/// 除了当前进程的 PATH,还会补上 GUI 进程看不到的工具链目录(Homebrew、
/// `/usr/local/bin`、nvm / fnm / volta 的版本目录等)。原因不只是「找到自己」:
/// 拉起的外部命令常常再 `spawn` 一个兄弟 CLI——例如 ACP 的 `pi-acp` 适配器会去
/// `PATH` 里找 `pi`,而 `pi` 常常装在本机 nvm 的版本目录下。只把 npm 所在目录塞进
/// 子进程 PATH 时,适配器本身起得来,真正干活的 CLI 却报
/// `Could not start pi: executable not found (command: pi)`。
#[must_use]
pub fn child_path_with_program_dir(program: &Path) -> OsString {
let dir = program.parent().filter(|dir| !dir.as_os_str().is_empty());
let Some(dir) = dir else {
return std::env::var_os("PATH").unwrap_or_default();
};
let mut parts: Vec<PathBuf> = vec![dir.to_path_buf()];
if let Some(path) = std::env::var_os("PATH") {
parts.extend(std::env::split_paths(&path).filter(|part| !part.as_os_str().is_empty()));
let path = std::env::var_os("PATH");
child_path_in(program, path.as_deref(), &fallback_program_dirs())
}
/// 以调用方给的 PATH 为底,做和 [`child_path_with_program_dir`] 一样的补全。
///
/// 用于「配置里已经写了 PATH」的场景:用户显式配置的目录要排在兜底目录之前,
/// 不能因为补全逻辑把整条 PATH 换掉。
#[must_use]
pub fn child_path_with_program_dir_and_path(program: &Path, path: Option<&OsStr>) -> OsString {
child_path_in(program, path, &fallback_program_dirs())
}
/// `child_path_with_program_dir` 的显式版本:顺序 = 程序目录 → 当前 PATH →
/// 兜底工具链目录 → 基础系统目录,并去重。参数显式传入,便于不依赖宿主环境测试。
fn child_path_in(program: &Path, path: Option<&OsStr>, fallback_dirs: &[PathBuf]) -> OsString {
let mut parts: Vec<PathBuf> = Vec::new();
if let Some(dir) = program.parent().filter(|dir| !dir.as_os_str().is_empty()) {
parts.push(dir.to_path_buf());
}
if let Some(path) = path {
parts.extend(std::env::split_paths(path).filter(|part| !part.as_os_str().is_empty()));
}
parts.extend(fallback_dirs.iter().cloned());
#[cfg(not(windows))]
parts.extend(BASE_SYSTEM_DIRS.iter().map(PathBuf::from));
let mut unique: Vec<PathBuf> = Vec::with_capacity(parts.len());
for part in parts {
if !unique.contains(&part) {
unique.push(part);
}
}
std::env::join_paths(unique).unwrap_or_else(|_| dir.as_os_str().to_os_string())
std::env::join_paths(unique).unwrap_or_else(|_| path.unwrap_or_default().to_os_string())
}
fn resolve_program_in(
@@ -356,6 +386,62 @@ mod tests {
assert!(!needs_shell_wrapper(Path::new("C:/node/npx.exe")));
}
/// 子进程 PATH 必须带上 GUI 进程看不到的工具链目录,否则被拉起的 CLI
/// 找不到同机安装的兄弟命令(`pi-acp` 找 `pi` 就是这么失败的)。
#[test]
fn child_path_appends_gui_invisible_toolchain_dirs() {
let fallback = temp_dir("child-path-fallback");
let program = Path::new("/opt/navop-toolchain/bin/npx");
let process_path = std::env::join_paths([Path::new("/usr/bin"), Path::new("/bin")])
.expect("join process path");
let path = child_path_in(
program,
Some(process_path.as_os_str()),
std::slice::from_ref(&fallback),
);
let parts: Vec<PathBuf> = std::env::split_paths(&path).collect();
assert_eq!(
Some(Path::new("/opt/navop-toolchain/bin")),
parts.first().map(PathBuf::as_path),
"程序目录必须排在最前,npx 才能找到同目录的 node"
);
assert!(
parts.contains(&PathBuf::from("/usr/bin")),
"当前进程的 PATH 不能被丢掉:{parts:?}"
);
assert_eq!(
Some(fallback.as_path()),
parts
.iter()
.find(|part| part.as_path() == fallback)
.map(PathBuf::as_path),
"兜底工具链目录必须补进去:{parts:?}"
);
assert_eq!(
1,
parts.iter().filter(|part| *part == &fallback).count(),
"兜底目录只出现一次"
);
let _ = std::fs::remove_dir_all(&fallback);
}
/// PATH 被裁剪到只剩工具链目录时,`sh` / `env` 这类基础命令也不能丢。
#[cfg(not(windows))]
#[test]
fn child_path_always_keeps_base_system_dirs() {
let path = child_path_in(Path::new("/opt/navop-toolchain/bin/npx"), None, &[]);
let parts: Vec<PathBuf> = std::env::split_paths(&path).collect();
for dir in BASE_SYSTEM_DIRS {
assert!(
parts.contains(&PathBuf::from(dir)),
"缺少基础目录 {dir}: {parts:?}"
);
}
}
#[cfg(not(windows))]
#[test]
fn unix_uses_the_bare_program_name() {
+28 -5
View File
@@ -282,14 +282,31 @@ fn resolve_preset_command(
};
// 把程序目录补进子进程 PATH:npm/npx 需要找到同目录的 node,
// 否则 GUI 里拉起时会以 `env: node: No such file or directory` 失败。
let program_dir_path = process_util::child_path_with_program_dir(&resolved);
let program_dir = program_dir_path.to_string_lossy().to_string();
env.retain(|(name, _)| name != "PATH");
env.push(("PATH".to_string(), program_dir));
// 只补目录还不够:适配器会再 spawn 兄弟 CLI(`pi-acp` 找 `pi`),所以补全逻辑
// 还要带上 GUI 进程看不到的工具链目录(nvm / fnm / volta 等)。
extend_child_path(env, &resolved);
*command = resolved.to_string_lossy().to_string();
Ok(())
}
/// 给 ACP 子进程的 PATH 补上程序目录与 GUI 进程看不到的工具链目录。
///
/// 以配置里已写的 PATH(若有)为底:用户显式配置的目录要排在兜底目录之前,
/// 不能因为补全逻辑把整条 PATH 换掉。
fn extend_child_path(env: &mut Vec<(String, String)>, program: &Path) {
let configured = env
.iter()
.find(|(name, _)| name == "PATH")
.map(|(_, value)| value.clone());
let path = process_util::child_path_with_program_dir_and_path(
program,
configured.as_deref().map(std::ffi::OsStr::new),
);
let path = path.to_string_lossy().to_string();
env.retain(|(name, _)| name != "PATH");
env.push(("PATH".to_string(), path));
}
/// 用户自己定义的 agent(配置里带 `command` 的条目)。
fn user_defined_agent_entries(
config: &AcpUserConfig,
@@ -353,8 +370,14 @@ fn apply_user_override(config: &mut AcpAgentConfig, user: AcpResolvedAgentOverri
*current_args = args;
}
// 传输只剩 stdio 一种形态,直接解构即可(留着 `if let` 只会被警告说恒真)。
let AcpTransport::Stdio { env, .. } = &mut config.transport;
let AcpTransport::Stdio { command, env, .. } = &mut config.transport;
merge_env(env, user.env);
// 覆盖项里重写了 PATH 时,用户目录要留,但程序目录与工具链目录也不能丢:
// 适配器再 spawn 兄弟 CLI 时仍然依赖它们。
if env.iter().any(|(name, _)| name == "PATH") {
let command = command.clone();
extend_child_path(env, Path::new(&command));
}
user.timeouts.apply(&mut config.timeouts);
}
+27
View File
@@ -47,6 +47,33 @@ fn presets_resolve_commands_and_extend_the_child_path() {
assert!(path.starts_with("/opt/acp/bin"), "{path}");
}
/// 覆盖项里重写了 PATH 时,用户目录要保留,程序目录与兜底工具链目录也不能丢:
/// 适配器(如 pi-acp)还会再 spawn 兄弟 CLI,依赖这些目录。
#[test]
fn overridden_path_keeps_program_dir_and_toolchain_dirs() {
let user_config = parse_user_config(
r#"{"version": 2, "agents": {"pi-acp": {"env": {"PATH": "/custom/toolchain/bin"}}}}"#,
)
.unwrap();
let entries =
preset_agent_entries_with(&user_config, &|_| None, fake_resolver("/opt/acp/bin/npm"));
let pi = entries
.iter()
.find(|entry| entry.id.as_ref() == "pi-acp")
.expect("pi preset");
let config = pi.config.as_ref().expect("resolved config");
let AcpTransport::Stdio { env, .. } = &config.transport;
let path = env
.iter()
.find(|(name, _)| name == "PATH")
.map(|(_, value)| value.as_str())
.expect("PATH is injected for npm-backed presets");
assert!(path.starts_with("/opt/acp/bin"), "{path}");
assert!(path.contains("/custom/toolchain/bin"), "{path}");
assert!(path.contains("/usr/bin"), "{path}");
}
#[test]
fn presets_are_enabled_only_where_the_default_list_says_so() {
let user_config = parse_user_config(r#"{"version": 2, "agents": {}}"#).unwrap();