fix(settings): enforce absolute paths for all anchored upgrade branches

- misc: extend sibling_bin derivation to claude/brew/volta/bun in addition
  to npm. Return Option<String> so the anchored-path invariant cannot be
  silently violated — when bin_path has no parent directory, propagate
  None and fall back to the static command at the call site instead of
  emitting a bare command that depends on the GUI process PATH.
- misc: factor out quote_path_if_spaced to keep all five anchored
  branches consistent when the resolved path contains spaces.
- AboutSection: add preflightTools lock + try/finally around
  probeToolInstallations so fast double-clicks cannot race two probe
  rounds into concurrent executeRun calls or duplicate confirm dialogs.
- Tests: cover sibling_bin None branch and space-quoting for every
  anchored branch (claude/brew/volta/bun).
This commit is contained in:
Jason
2026-05-23 20:44:56 +08:00
parent 108dda1772
commit c6fd2415c3
2 changed files with 230 additions and 48 deletions
+168 -29
View File
@@ -1428,16 +1428,67 @@ fn brew_formula_from_path(real: &str) -> Option<String> {
None None
} }
/// 含空格才用 POSIX 单引号包一层,否则保持裸路径——命令展示更干净。
/// claude / brew / volta / bun / npm 五个锚定分支共用,避免"含空格"判定漂移。
///
/// **仅按空格判定,不防其他 shell 元字符**(`$` / `` ` `` / `'` / `"` / `;` 等)。
/// 调用方传入的是探测得到的可执行路径(`enumerate_tool_installations` 里来源于
/// `Path::display()`),实际 macOS/Linux 上 home dir 名几乎不允许这类字符、
/// npm/brew/volta/bun 也不会装到含这类字符的路径,与 diff 前内联在 npm 分支里的
/// `if npm.contains(' ')` 实现等价。若未来要扩广,改成 `shell_single_quote` 无条件
/// 包裹即可,但会失去"无空格时的清洁展示"。
#[cfg(not(target_os = "windows"))]
fn quote_path_if_spaced(p: &str) -> String {
if p.contains(' ') {
shell_single_quote(p)
} else {
p.to_string()
}
}
/// 返回 `<bin_path 同目录>/<exe>` 的绝对路径。bin_path 是命令行命中的入口
/// (如 `/opt/homebrew/bin/gemini`、`~/.volta/bin/codex`),`exe` 是与之共处一个
/// bin 目录的另一个可执行(`brew` / `volta` / `bun` / `npm`)——这些包管理器
/// 都把自己的 cli 跟它们安装的命令并列放在同一个 bin 目录,所以"同目录推导"
/// 是可靠的绝对路径来源。
///
/// **dir 为空(bin_path 不含 `/`) → 返回 None**:此时无法推导出绝对路径,让上游
/// `anchored_command_from_paths` 整体退化为 None,调用方落到静态命令兜底——而非
/// 悄悄拼出 `npm i -g <pkg>` 这种依赖 PATH 的指令,违背"必须绝对路径"不变量。
/// 实际从 `enumerate_tool_installations` 走的 bin_path 都是 `Path::display()` 出
/// 来的绝对路径,这条防线不期望被触发,但闭合了 helper 与函数文档的语义一致。
#[cfg(not(target_os = "windows"))]
fn sibling_bin(bin_path: &str, exe: &str) -> Option<String> {
let dir = parent_dir(bin_path);
if dir.is_empty() {
None
} else {
Some(format!("{dir}/{exe}"))
}
}
/// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径, /// 给定工具、原始 bin 路径(命令行命中的入口)、canonicalize 后的真身路径,
/// 推断"写回同一处"的锚定升级命令。纯函数(不碰 FS),真实 canonicalize 由调用方做, /// 推断"写回同一处"的锚定升级命令。纯函数(不碰 FS),真实 canonicalize 由调用方做,
/// 便于单测覆盖各包管理器分支。hermes(pip)不在此处:`npm_package_for` 返回 None → None。 /// 便于单测覆盖各包管理器分支。hermes(pip)不在此处:`npm_package_for` 返回 None → None。
/// ///
/// **关键不变量:返回的命令必须用绝对路径调用执行体,不依赖 PATH**。
/// 这条命令最终在 `run_tool_lifecycle_silently` 的非登录 `bash -c` 里执行——
/// GUI App 启动的进程 PATH 由 launchd / Windows Service / systemd 给,通常**不含**
/// `~/.local/bin` / `/opt/homebrew/bin` / `~/.volta/bin` 等用户级 bin 目录;而探测
/// 阶段 `try_get_version` 用的是 `$SHELL -lic`(登录+交互式,会读 .zshrc/.zprofile),
/// 两者 PATH 不对称。裸 `claude update` / `brew upgrade ...` 在 GUI 进程里大概率
/// `command not found`(exit 127)→ `set -e` 中止 → 用户看到失败 toast,锚定决策却
/// 已展示给用户"将写回原生那处"——欺骗性故障。
///
/// 判定顺序(命中即返回): /// 判定顺序(命中即返回):
/// ① Claude 原生安装器(`~/.local/share/claude/versions/`)→ `claude update` 自更新 /// ① Claude 原生安装器(`~/.local/share/claude/versions/`)→ `<bin_path 绝对> update`
/// 它不归 npm 管,用 npm 升级会装到别处且被原生那份遮蔽(PATH 更靠前)。 /// bin_path 指向 launcher,launcher 内部 dispatch update 子命令。它不归 npm 管,
/// ② Homebrew formula(真身在 `Cellar/<formula>/`)→ `brew upgrade <formula>` /// 且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级会装到别处且被原生那份遮蔽。
/// ② Homebrew formula(真身在 `Cellar/<formula>/`)→ `<bin_path 同目录>/brew upgrade <formula>`;
/// formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli)。 /// formula 名 ≠ npm 包名(gemini-cli ≠ @google/gemini-cli)。
/// ③ volta / bun → 各自的全局安装命令 /// ③ volta → `<bin_path 同目录>/volta install <pkg>`;bun → `<bin_path 同目录>/bun add -g <pkg>@latest`
/// `~/.volta/bin` / `~/.bun/bin` 几乎不在 GUI 进程的 PATH 里,且用户可能 PATH
/// 上有另一份 volta/bun → 必须绝对路径锚定到命令行实际命中的那一份。
/// ④ 其余 npm 全局包(nvm / homebrew-npm / mise / fnm / system)→ 锚定到"那处 bin /// ④ 其余 npm 全局包(nvm / homebrew-npm / mise / fnm / system)→ 锚定到"那处 bin
/// 目录的 npm",确保升级写回命令行实际在用的那个 node,而非 PATH 第一个 npm。 /// 目录的 npm",确保升级写回命令行实际在用的那个 node,而非 PATH 第一个 npm。
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
@@ -1449,14 +1500,24 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
&& (real_lower.contains("/.local/share/claude/") && (real_lower.contains("/.local/share/claude/")
|| real_lower.contains("/claude/versions/")) || real_lower.contains("/claude/versions/"))
{ {
return Some("claude update".to_string()); return Some(format!("{} update", quote_path_if_spaced(bin_path)));
} }
if let Some(formula) = brew_formula_from_path(real_target) { if let Some(formula) = brew_formula_from_path(real_target) {
return Some(format!("brew upgrade {formula}")); let brew = sibling_bin(bin_path, "brew")?;
return Some(format!("{} upgrade {formula}", quote_path_if_spaced(&brew)));
} }
match infer_install_source(Path::new(bin_path)) { match infer_install_source(Path::new(bin_path)) {
"volta" => return Some(format!("volta install {pkg}")), "volta" => {
"bun" => return Some(format!("bun add -g {pkg}@latest")), let volta = sibling_bin(bin_path, "volta")?;
return Some(format!("{} install {pkg}", quote_path_if_spaced(&volta)));
}
"bun" => {
let bun = sibling_bin(bin_path, "bun")?;
return Some(format!(
"{} add -g {pkg}@latest",
quote_path_if_spaced(&bun)
));
}
// 自带同级 npm 的 node 管理器:落到下面锚定到那处的 npm。 // 自带同级 npm 的 node 管理器:落到下面锚定到那处的 npm。
"nvm" | "fnm" | "mise" | "homebrew" => {} "nvm" | "fnm" | "mise" | "homebrew" => {}
// system / 未知来源(如 opencode install.sh 装的 ~/.opencode/bin、~/go/bin // system / 未知来源(如 opencode install.sh 装的 ~/.opencode/bin、~/go/bin
@@ -1464,20 +1525,8 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) ->
// 回退静态裸命令(至少能跑),并由前端据 anchored=false 给出诚实文案。 // 回退静态裸命令(至少能跑),并由前端据 anchored=false 给出诚实文案。
_ => return None, _ => return None,
} }
let dir = parent_dir(bin_path); let npm = sibling_bin(bin_path, "npm")?;
let npm = if dir.is_empty() { Some(format!("{} i -g {pkg}@latest", quote_path_if_spaced(&npm)))
"npm".to_string()
} else {
format!("{dir}/npm")
};
// 含空格才单引号包裹(复用 shell_single_quote 的 POSIX 转义),否则保持裸路径,
// 命令展示更干净。
let npm = if npm.contains(' ') {
shell_single_quote(&npm)
} else {
npm
};
Some(format!("{npm} i -g {pkg}@latest"))
} }
/// 从枚举结果里取"命令行实际命中的那处":优先 `is_path_default`;否则(解析不到 /// 从枚举结果里取"命令行实际命中的那处":优先 `is_path_default`;否则(解析不到
@@ -2578,26 +2627,33 @@ mod tests {
#[test] #[test]
fn claude_native_installer_uses_self_update() { fn claude_native_installer_uses_self_update() {
// ~/.local/bin/claude → 真身在 ~/.local/share/claude/versions/自带 self-update // ~/.local/bin/claude → 真身在 ~/.local/share/claude/versions/,自带 self-update;
// 它不归 npm 管且在 PATH 里比 nvm/homebrew 更靠前用 npm 升级纯属白装。 // 它不归 npm 管,且在 PATH 里比 nvm/homebrew 更靠前,用 npm 升级纯属白装。
// **绝对路径调用 launcher** 避免 GUI 非登录 `bash -c` 时 PATH 没有
// ~/.local/bin 导致 `claude: not found`(exit 127)而失败。
let cmd = anchored_command_from_paths( let cmd = anchored_command_from_paths(
"claude", "claude",
"/Users/me/.local/bin/claude", "/Users/me/.local/bin/claude",
"/Users/me/.local/share/claude/versions/2.1.146", "/Users/me/.local/share/claude/versions/2.1.146",
); );
assert_eq!(cmd.as_deref(), Some("claude update")); assert_eq!(cmd.as_deref(), Some("/Users/me/.local/bin/claude update"));
} }
#[test] #[test]
fn gemini_homebrew_formula_uses_brew_upgrade() { fn gemini_homebrew_formula_uses_brew_upgrade() {
// /opt/homebrew/bin/gemini → Cellar/gemini-cli/...是 brew formula 而非 npm 全局包 // /opt/homebrew/bin/gemini → Cellar/gemini-cli/...:是 brew formula 而非 npm 全局包,
// 且 formula 名(gemini-cli) ≠ npm 包名(@google/gemini-cli)。 // 且 formula 名(gemini-cli) ≠ npm 包名(@google/gemini-cli)。
// **brew 与 formula 入口同目录**,用 `<dir>/brew` 绝对路径调用,避免 GUI
// 非登录 `bash -c` 时 PATH 没有 /opt/homebrew/bin 导致 `brew: not found`。
let cmd = anchored_command_from_paths( let cmd = anchored_command_from_paths(
"gemini", "gemini",
"/opt/homebrew/bin/gemini", "/opt/homebrew/bin/gemini",
"/opt/homebrew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js", "/opt/homebrew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js",
); );
assert_eq!(cmd.as_deref(), Some("brew upgrade gemini-cli")); assert_eq!(
cmd.as_deref(),
Some("/opt/homebrew/bin/brew upgrade gemini-cli")
);
} }
#[test] #[test]
@@ -2631,22 +2687,60 @@ mod tests {
#[test] #[test]
fn volta_uses_volta_install() { fn volta_uses_volta_install() {
// `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能
// PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。
let cmd = anchored_command_from_paths( let cmd = anchored_command_from_paths(
"codex", "codex",
"/Users/me/.volta/bin/codex", "/Users/me/.volta/bin/codex",
"/Users/me/.volta/tools/image/packages/codex/lib/node_modules/@openai/codex", "/Users/me/.volta/tools/image/packages/codex/lib/node_modules/@openai/codex",
); );
assert_eq!(cmd.as_deref(), Some("volta install @openai/codex")); assert_eq!(
cmd.as_deref(),
Some("/Users/me/.volta/bin/volta install @openai/codex")
);
} }
#[test] #[test]
fn bun_uses_bun_add() { fn bun_uses_bun_add() {
// bun 同 volta:`~/.bun/bin` 几乎不在 GUI PATH 里,绝对路径锚定。
let cmd = anchored_command_from_paths( let cmd = anchored_command_from_paths(
"opencode", "opencode",
"/Users/me/.bun/bin/opencode", "/Users/me/.bun/bin/opencode",
"/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode", "/Users/me/.bun/install/global/node_modules/opencode-ai/bin/opencode",
); );
assert_eq!(cmd.as_deref(), Some("bun add -g opencode-ai@latest")); assert_eq!(
cmd.as_deref(),
Some("/Users/me/.bun/bin/bun add -g opencode-ai@latest")
);
}
#[test]
fn volta_path_with_space_is_quoted() {
// volta 分支用 `<dir>/volta`,目录含空格时同样要 POSIX 引号包裹。
let cmd = anchored_command_from_paths(
"codex",
"/Users/my name/.volta/bin/codex",
"/Users/my name/.volta/tools/image/packages/codex/lib/node_modules/@openai/codex",
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.volta/bin/volta' install @openai/codex")
);
}
#[test]
fn bun_path_with_space_is_quoted() {
// bun 分支与 volta 共享 sibling_bin + quote_path_if_spaced,
// 这条用例锁住 `bun add -g` 命令头部的引号包裹形态。
let cmd = anchored_command_from_paths(
"opencode",
"/Users/my name/.bun/bin/opencode",
"/Users/my name/.bun/install/global/node_modules/opencode-ai/bin/opencode",
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.bun/bin/bun' add -g opencode-ai@latest")
);
} }
#[test] #[test]
@@ -2712,6 +2806,35 @@ mod tests {
); );
} }
#[test]
fn claude_native_path_with_space_is_quoted() {
// claude 分支同样要 POSIX 引号包裹含空格的 bin_path,
// 否则 `/Users/my name/.local/bin/claude update` 会被 shell 拆词。
let cmd = anchored_command_from_paths(
"claude",
"/Users/my name/.local/bin/claude",
"/Users/my name/.local/share/claude/versions/2.1.146",
);
assert_eq!(
cmd.as_deref(),
Some("'/Users/my name/.local/bin/claude' update")
);
}
#[test]
fn brew_path_with_space_is_quoted() {
// brew 分支用 `<bin_path 同目录>/brew`,目录含空格时同样要引号包裹。
let cmd = anchored_command_from_paths(
"gemini",
"/opt/my brew/bin/gemini",
"/opt/my brew/Cellar/gemini-cli/0.13.0/libexec/lib/node_modules/@google/gemini-cli/dist/index.js",
);
assert_eq!(
cmd.as_deref(),
Some("'/opt/my brew/bin/brew' upgrade gemini-cli")
);
}
#[test] #[test]
fn brew_formula_extraction() { fn brew_formula_extraction() {
assert_eq!( assert_eq!(
@@ -2730,6 +2853,22 @@ mod tests {
); );
} }
#[test]
fn sibling_bin_returns_none_when_bin_path_has_no_directory() {
// bin_path 不含 `/` → parent_dir 返回空 → sibling_bin 不能拼出绝对路径
// → None,让上游 anchored_command_from_paths 整体退化为静态命令兜底,
// 而不是悄悄拼出 `npm i -g <pkg>` 这种依赖 PATH 的指令(违背"必须绝对路径"
// 不变量)。实际从 enumerate_tool_installations 走的 bin_path 都是绝对路径,
// 这条防线不期望被触发,但闭合了 helper 与函数文档的语义一致。
assert_eq!(sibling_bin("codex", "npm"), None);
assert_eq!(sibling_bin("", "brew"), None);
// 含 `/` 即可拼出绝对路径——这是常规路径。
assert_eq!(
sibling_bin("/opt/homebrew/bin/gemini", "brew").as_deref(),
Some("/opt/homebrew/bin/brew")
);
}
#[test] #[test]
fn default_install_prefers_path_default() { fn default_install_prefers_path_default() {
let installs = vec![ let installs = vec![
+62 -19
View File
@@ -234,6 +234,16 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
toolNames: ToolName[]; toolNames: ToolName[];
plans: ToolInstallationReport[]; plans: ToolInstallationReport[];
} | null>(null); } | null>(null);
// 升级 preflight(probe 阶段)的 in-flight 工具集合。
// probeToolInstallations 是个 1-3 秒级别的跨进程探测(对每个工具跑 --version + canonicalize),
// 在它返回之前 toolActions / batchAction 都还没被置位 → 按钮不会 disabled → 用户快速双击
// 会并发开两轮 probe,各自再触发 executeRun(并发的 `npm i -g` / `curl | bash`,写冲突)。
// 把 probe 期间的工具登记在这里、纳入 isAnyBusy 派生,关掉这个并发窗口。
// 用 Set 而非 boolean:单卡片升级 & 批量升级可能在不同工具上独立 preflight,
// 精确反映到各自卡片按钮的 disabled。
const [preflightTools, setPreflightTools] = useState<Set<ToolName>>(
() => new Set(),
);
const toolVersionByName = useMemo(() => { const toolVersionByName = useMemo(() => {
return new Map(toolVersions.map((tool) => [tool.name, tool])); return new Map(toolVersions.map((tool) => [tool.name, tool]));
@@ -643,32 +653,60 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
[t, wslShellByTool, refreshToolVersions, diagnoseToolSilently], [t, wslShellByTool, refreshToolVersions, diagnoseToolSilently],
); );
// 升级前先探测安装分布:检测到多处安装(命令行只命中其中一处)时弹确认, // 升级/安装的统一入口锁。所有动作在入口处先登记 preflight、出口处解锁;
// 让用户在「升级只动默认那处、其余不动」这件事上知情。install 无锚点,直接执行。 // 这层锁覆盖三个否则会漏掉的窗口:
// ① update 的 probeToolInstallations 阶段(1-3 秒跨进程,executeRun 之前);
// ② executeRun 内部 setToolActions 落到 React commit 前的几个 microtask;
// ③ install 直接进 executeRun 的同一段 microtask 窗口。
// 早退检查避免同一工具在 toolActions / preflight 已登记时被重复入栈——批量场景里
// 只要有一个工具被锁,整批不开新一轮,因为后端 set -e 串行的语义假设是「一次性
// 单脚本」,跨两次 IPC 并发会破坏它。
const handleRunToolAction = useCallback( const handleRunToolAction = useCallback(
async (toolNames: ToolName[], action: ToolLifecycleAction) => { async (toolNames: ToolName[], action: ToolLifecycleAction) => {
if (toolNames.length === 0) return; if (toolNames.length === 0) return;
if (action === "install") { if (
await executeRun(toolNames, action); toolNames.some(
(name) => preflightTools.has(name) || toolActions[name] !== undefined,
)
) {
return; return;
} }
let reports: ToolInstallationReport[]; // 入栈 preflight,按钮立刻 disabled。new Set(prev) 是不可变更新(直接 mutate
// 原 Set 会让 React 复用引用、跳过 re-render);finally 块负责异常路径解锁。
setPreflightTools((prev) => {
const next = new Set(prev);
toolNames.forEach((name) => next.add(name));
return next;
});
try { try {
reports = await settingsApi.probeToolInstallations(toolNames); if (action === "install") {
} catch (error) { await executeRun(toolNames, action);
// 探测失败不应阻断升级:退回直接执行(等同旧行为)。 return;
console.error("[AboutSection] probeToolInstallations failed", error); }
await executeRun(toolNames, action); let reports: ToolInstallationReport[];
return; try {
reports = await settingsApi.probeToolInstallations(toolNames);
} catch (error) {
// 探测失败不应阻断升级:退回直接执行(等同旧行为)。
console.error("[AboutSection] probeToolInstallations failed", error);
await executeRun(toolNames, action);
return;
}
const needConfirm = reports.filter((r) => r.needs_confirmation);
if (needConfirm.length === 0) {
await executeRun(toolNames, action);
return;
}
setPendingUpgrade({ toolNames, plans: needConfirm });
} finally {
setPreflightTools((prev) => {
const next = new Set(prev);
toolNames.forEach((name) => next.delete(name));
return next;
});
} }
const needConfirm = reports.filter((r) => r.needs_confirmation);
if (needConfirm.length === 0) {
await executeRun(toolNames, action);
return;
}
setPendingUpgrade({ toolNames, plans: needConfirm });
}, },
[executeRun], [executeRun, preflightTools, toolActions],
); );
const handleConfirmUpgrade = useCallback(() => { const handleConfirmUpgrade = useCallback(() => {
@@ -684,7 +722,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
// 任一安装/升级进行中(批量或单工具)即视为忙碌:用于禁用所有操作按钮, // 任一安装/升级进行中(批量或单工具)即视为忙碌:用于禁用所有操作按钮,
// 避免并发触发多个 npm/pip 全局写入造成冲突。 // 避免并发触发多个 npm/pip 全局写入造成冲突。
const isAnyBusy = Boolean(batchAction) || Object.keys(toolActions).length > 0; // preflightTools 覆盖升级前的 probe 阶段——那段在 executeRun 之前、toolActions
// 还没置位,如果不算进 busy 会留出 1-3 秒的并发触发窗口。
const isAnyBusy =
Boolean(batchAction) ||
Object.keys(toolActions).length > 0 ||
preflightTools.size > 0;
return ( return (
<motion.section <motion.section