From f07edc768042f7b5b34ab3406ab13380949930c6 Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 30 Jul 2026 16:00:03 +0800 Subject: [PATCH] fix(settings): make Grok Build upgrade work from the GUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `grok update` discovers and installs releases by spawning `npm view` and `npm i -g`, even for xAI's native install — 0.2.112 moved the self-update path onto npm distribution, so the binary now needs node on PATH. Lifecycle scripts run under a non-login `bash -c` inheriting launchd's narrow PATH, where npm and node are invisible, so upgrading grok failed with a bare `Error: No such file or directory (os error 2)`. Inject the login shell's real PATH into run_tool_lifecycle_silently, closing the asymmetry between probing (`$SHELL -lic`, which reads .zshrc) and execution (non-login bash). Read it through `/usr/bin/env` rather than `echo $PATH`: fish stores PATH as a list and would emit space-separated segments, while env always prints the child's real environment. This also revives the install chain's bare `npm i -g` fallback, which could only ever exit 127 under the narrow PATH. Chain the official installer after native Grok's self-update. An npm fallback would share both of the primary's failure modes — no node, or a registry mirror missing the tarball — and fail alongside it; the installer is the only node-free path and lands in the same ~/.grok/bin. It also rewrites `[cli] installer` back to `internal`, healing users whom the install-time npm fallback had switched onto npm distribution. --- src-tauri/src/commands/misc.rs | 230 +++++++++++++++++++++++++++++++-- 1 file changed, 216 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 14dacba3b..0cf46e426 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -214,11 +214,16 @@ fn run_tool_lifecycle_silently(command_line: &str, _label: &str) -> Result<(), S use std::process::Command; // command_line 是 bash 风格脚本(含 `set -e` 与多行命令);强制用 bash 执行, // 避免用户默认 shell 为 fish/zsh 时 `set -e` 等语义不一致。 - let output = Command::new("bash") - .arg("-c") - .arg(command_line) - .output() - .map_err(|e| format!("启动安装进程失败: {e}"))?; + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(command_line); + // GUI App 继承的是 launchd 的窄 PATH,而锚定探测用的是登录 shell —— 见 + // `login_shell_path` doc。命令自身用绝对路径不受影响,但工具**内部** spawn 的 + // npm/node 等子进程、以及 install 链里裸的 npm fallback 都需要真实 PATH。 + if let Some(login_path) = login_shell_path() { + let inherited = std::env::var("PATH").unwrap_or_default(); + cmd.env("PATH", merge_path_segments(&login_path, &inherited)); + } + let output = cmd.output().map_err(|e| format!("启动安装进程失败: {e}"))?; finish_lifecycle_output(&output) } @@ -1855,6 +1860,73 @@ fn first_abs_path_line(raw: &str) -> Option<&str> { raw.lines().map(str::trim).find(|l| l.starts_with('/')) } +/// 从 `env` 输出里取 `PATH=` 那行的值。要求值以 `/` 开头——PATH 首段必是绝对路径, +/// 这条约束顺带跳过"某个多行值的环境变量恰好有一行以 `PATH=` 开头"的污染, +/// 与 `first_abs_path_line` 对交互式 shell 噪音的容错同理。 +#[cfg(not(target_os = "windows"))] +fn path_line_from_env_output(raw: &str) -> Option<&str> { + raw.lines() + .filter_map(|line| line.strip_prefix("PATH=")) + .find(|value| value.starts_with('/')) +} + +/// 合并两段 PATH:`primary` 全部保留在前,`extra` 中未出现过的段按序追加。 +/// 空段直接丢弃(`a::b` 里的空段在 POSIX 下语义是"当前目录",注入时不该带上)。 +#[cfg(not(target_os = "windows"))] +fn merge_path_segments(primary: &str, extra: &str) -> String { + let mut seen = std::collections::HashSet::new(); + let mut merged: Vec<&str> = Vec::new(); + for segment in primary.split(':').chain(extra.split(':')) { + if segment.is_empty() || !seen.insert(segment) { + continue; + } + merged.push(segment); + } + merged.join(":") +} + +/// 用与 `resolve_path_default` 相同的登录 shell 解析用户的真实 PATH, +/// 供 `run_tool_lifecycle_silently` 注入给安装/升级脚本。 +/// +/// **要解决的不对称**:探测阶段(`try_get_version` / `resolve_path_default`)跑的是 +/// `$SHELL -lic`,会读 `.zshrc`/`.zprofile`,看得到 nvm / homebrew / volta;而执行阶段 +/// 是非登录 `bash -c`,继承的是 launchd 给 GUI App 的 PATH,通常只有 +/// `/usr/bin:/bin:/usr/sbin:/sbin`。锚定命令自己用绝对路径调用执行体,本不受这条影响 +/// ——但有两类漏网: +/// 1. **执行体在内部再 spawn 第三方 CLI**:`grok update` 靠 `npm view` 查最新版本 +/// (见 `grok_native_update_command`),npm 又是 `#!/usr/bin/env node` 脚本; +/// 未来任何 self-update 内部调 node/git/python 同理。 +/// 2. **install 分支的 `<官方 installer> || npm i -g @latest`**:`||` 右侧是裸命令, +/// 窄 PATH 下必然 exit 127,等于没有兜底。 +/// +/// 把执行阶段的 PATH 拉平到探测阶段的水平,一次消除这两类。 +/// +/// **用 `/usr/bin/env` 而不是 `echo $PATH`**:`$PATH` 在 fish 里是 list 类型, +/// `"$PATH"` 展开成空格分隔而非冒号分隔;`env` 打印的则是子进程的真实环境, +/// 任何 shell 下格式都正确。写绝对路径又绕过了 alias / function / PATH 三重不确定性 +/// (交互式 shell 会加载用户 alias)。 +/// +/// 解析不到时返回 `None`,调用方保持原有行为(不注入),不引入新的失败模式。 +#[cfg(not(target_os = "windows"))] +fn login_shell_path() -> Option { + use std::process::Command; + let shell = std::env::var("SHELL") + .ok() + .filter(|s| is_valid_shell(s)) + .unwrap_or_else(|| "sh".to_string()); + let flag = default_flag_for_shell(&shell); + let out = Command::new(shell) + .arg(flag) + .arg("/usr/bin/env") + .output() + .ok()?; + if !out.status.success() { + return None; + } + let raw = decode_command_output(&out.stdout); + Some(path_line_from_env_output(&raw)?.to_string()) +} + /// 用与 `try_get_version` 相同的登录 shell 解析 PATH 默认命中的可执行文件路径, /// canonicalize 后作为"命令行默认 / 升级目标"的锚点(与升级会作用的那处对齐)。 #[cfg(not(target_os = "windows"))] @@ -2205,6 +2277,64 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option update || <官方 installer>`。 +/// +/// **为什么唯独 native Grok 的 self-update 需要 fallback**(claude native / hermes 都没有): +/// `grok update` 虽然是自包含 Rust 二进制的子命令,**却把 npm 当成自己的分发管道**—— +/// 先 spawn `npm view @xai-official/grok version --json` 查最新版,再 spawn +/// `npm i -g @xai-official/grok@` 安装,由该包的 `postinstall.js` 从平台 optional +/// 依赖里解出二进制、安置成 `~/.grok/bin/grok-` 并 relink `grok`。而 `npm` 自身是 +/// `#!/usr/bin/env node` 脚本 → **native 安装也隐式硬依赖 PATH 里同时有 `npm` + `node`**。 +/// GUI 进程 PATH 由 launchd 给、`run_tool_lifecycle_silently` 又是非登录 `bash -c`, +/// nvm/homebrew 下的 node+npm 均不可见 → grok 内部 spawn 得到 ENOENT,只向用户抛出 +/// 费解的 `Error: No such file or directory (os error 2)`(实测复现)。 +/// +/// **这是上游换了机制**:0.2.111 及更早版本自更新是直接下载二进制到 `~/.grok/downloads/` +/// (彼时 `is_grok_native_install` 假设的 "native = 不碰 npm" 成立),0.2.112 起改走上述 npm +/// 管道(落点随之从 `downloads/` 变为 `bin/`)。**副作用**:npm 全局包那一处安装是 +/// `grok update` 自己装出来的,非用户手动所为,故 native 用户也会被 enumerate 到两处; +/// 两处由同一次 postinstall 同步,版本恒等。 +/// +/// 这正是 `anchored_command_from_paths` 那条"绝对路径 + 必要时把解释器目录放 PATH 首位" +/// 不变量没覆盖的第三类:**执行体自身既不需要解释器、也已用绝对路径,却在内部再 spawn +/// 第三方 CLI**。`login_shell_path` 的 PATH 注入已让绝大多数机器上的 primary 直接成功; +/// 这条 fallback 覆盖的是"这台机器根本没装 node"——用官方 installer 装的用户完全可能如此。 +/// +/// **fallback 必须是官方 installer,不能是 `npm i -g`**:后者与 primary **同源**——primary +/// 失败的两种现实原因(本机无 node;npm registry 指向未同步该 tarball 的镜像,见 +/// npmmirror dist-tag 事故)都会让 npm fallback 一并失败,`||` 形同虚设。官方 installer +/// 是唯一 node-free 的独立路径(只需 `curl`,在 `/usr/bin`,窄 PATH 下可用),且落点同为 +/// `~/.grok/bin`,锚定语义分毫不动 —— 真正的降级冗余要求 fallback 与 primary **失败模式不相关**。 +/// +/// **这条 fallback 还兼具第三重作用:修复上游锚点(勿在重构时丢掉)**。 +/// grok 的更新路径由 `~/.grok/config.toml` 的 `[cli] installer` 决定(`npm` / `internal` / +/// `gh-release`),而官方 install.sh 会**无条件把该字段覆写为 `internal`**(其 awk 段落先插入 +/// `installer = "internal"`、再跳过 `[cli]` 段里已有的 `installer`/`channel` 行)。于是: +/// 用户一旦因 install 端的 npm fallback 被切进 npm 模式(postinstall.js 会写 `installer = "npm"` +/// 并在每次 npm 更新时重写,自我巩固),只要 npm 路径出任何问题,这里的 `||` 就会把他拉回 +/// node-free 的 internal 模式,并顺带修好 npm 模式漏更新的 `~/.grok/bin/agent` launcher。 +/// **实测验证**(2026-07-30,窄 PATH + `installer = "npm"`):primary 抛 `os error 2` → fallback +/// 接管 → 装上最新版 → config 写回 `internal` → agent 对齐 → `.zshrc` 幂等更新不重复。 +/// ⇒ install 端保留 npm fallback 是安全的(它是 x.ai 不可达时的唯一退路,防火墙场景需要), +/// 其副作用由本链自愈;**把这里换成 npm fallback 会同时废掉降级冗余和这条自愈路径**。 +#[cfg(not(target_os = "windows"))] +fn grok_native_update_command(update: String) -> String { + chain_update_commands( + update, + GROK_INSTALL_UNIX.to_string(), + LifecycleCommandShell::Posix, + ) +} + +/// Windows 版同上,fallback 换成官方 PowerShell installer。 +/// **不走 `chain_update_commands`**:它会给 `||` 右侧加 `call`,而这里的 fallback 是 +/// `powershell.exe`(不是 `.cmd`/`.bat`),不需要 `call`——与 `hermes_update_windows_command` +/// 同一理由。 +#[cfg(target_os = "windows")] +fn grok_native_update_command(update: String) -> String { + format!("{update} || {}", grok_install_windows_command()) +} + /// 哪些工具的"官方 self-update"优先于包管理器升级(生成 ` update || `)。 /// /// **codex 刻意不在此列**:`codex update` 在 npm 安装上只是裸 `npm install -g @@ -2359,7 +2489,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> return anchored_official_update_command(tool, bin_path); } if tool == "grok" && is_grok_native_install(bin_path, real_target) { - return anchored_official_update_command(tool, bin_path); + return Some(grok_native_update_command( + anchored_official_update_command(tool, bin_path)?, + )); } let package_command = package_manager_anchored_command_from_paths(tool, bin_path, real_target); if brew_formula_from_path(real_target).is_some() { @@ -2438,7 +2570,9 @@ fn anchored_command_from_paths(tool: &str, bin_path: &str, real_target: &str) -> return anchored_official_update_command(tool, bin_path); } if tool == "grok" && is_grok_native_install(bin_path, real_target) { - return anchored_official_update_command(tool, bin_path); + return Some(grok_native_update_command( + anchored_official_update_command(tool, bin_path)?, + )); } let package_command = package_manager_anchored_command_from_paths(tool, bin_path); if prefers_official_update(tool, LifecycleCommandShell::WindowsBatch) { @@ -2535,6 +2669,11 @@ fn installer_with_npm_fallback(installer: &str, tool: &str) -> String { fn posix_install_command_for(tool: &str) -> String { match tool { "claude" => installer_with_npm_fallback(CLAUDE_INSTALL_UNIX, tool), + // Grok 的 npm fallback **会切换用户的分发模式**(该包 postinstall 把 + // `~/.grok/config.toml` 的 `[cli] installer` 写成 `npm`,此后 `grok update` 一律走 + // npm、隐式依赖 node)。仍然保留它:官方 installer 不可达(防火墙 / x.ai 被拦)时 + // 这是唯一退路,而副作用可自愈——`grok_native_update_command` 的 `||` 官方 installer + // 会在 npm 路径出问题时把 `installer` 覆写回 `internal`(见该函数 doc 的实测记录)。 "grok" => installer_with_npm_fallback(GROK_INSTALL_UNIX, tool), "opencode" => installer_with_npm_fallback(OPENCODE_INSTALL_UNIX, tool), "hermes" => HERMES_INSTALL_UNIX.to_string(), @@ -4043,11 +4182,24 @@ mod tests { } #[test] - fn grok_native_windows_uses_self_update() { + fn grok_native_windows_uses_self_update_with_installer_fallback() { + // sibling 有 npm.cmd 也**不能**拿它当 fallback:`grok update` 本身就是靠 npm + // 分发的(见 grok_native_update_command doc),npm fallback 与 primary 同源、 + // 会一起失败。fallback 必须是官方 PowerShell installer —— 唯一不经 npm 的路径。 let (_dir, _sub, bin_path) = setup_sibling(".grok/bin", "grok.exe", &["npm.cmd"]); - let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path); - let expected = format!("{} update", expect_quoted_path(&bin_path)); - assert_eq!(cmd.as_deref(), Some(expected.as_str())); + let cmd = anchored_command_from_paths("grok", &bin_path, &bin_path).unwrap(); + let expected = format!( + "{} update || {}", + expect_quoted_path(&bin_path), + grok_install_windows_command() + ); + assert_eq!(cmd, expected); + // fallback 是 powershell.exe 不是 .cmd/.bat —— `||` 右侧不该有 `call`。 + assert!( + !cmd.contains("|| call"), + "powershell needs no `call`: {cmd}" + ); + assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}"); } #[test] @@ -4517,7 +4669,7 @@ mod tests { } #[test] - fn grok_native_installer_uses_self_update() { + fn grok_native_installer_uses_self_update_with_installer_fallback() { // ~/.grok/bin/grok is a launcher symlink into ~/.grok/downloads. // Updating it through npm would create or mutate a different install. let cmd = anchored_command_from_paths( @@ -4525,7 +4677,25 @@ mod tests { "/Users/me/.grok/bin/grok", "/Users/me/.grok/downloads/grok-macos-aarch64", ); - assert_eq!(cmd.as_deref(), Some("/Users/me/.grok/bin/grok update")); + assert_eq!( + cmd.as_deref(), + Some(format!("/Users/me/.grok/bin/grok update || {GROK_INSTALL_UNIX}").as_str()) + ); + } + + #[test] + fn grok_native_update_falls_back_to_installer_not_npm() { + // 反向锁定:`grok update` 内部靠 `npm view` + `npm i -g` 完成升级,GUI 的窄 + // PATH 下会 ENOENT。fallback 必须是官方 installer —— 换成 `npm i -g` 就与 + // primary 同源(无 node / 镜像缺 tarball 时一起失败),`||` 形同虚设。 + let cmd = anchored_command_from_paths( + "grok", + "/Users/me/.grok/bin/grok", + "/Users/me/.grok/downloads/grok-macos-aarch64", + ) + .expect("native grok should anchor"); + assert!(cmd.contains("x.ai/cli/install.sh"), "{cmd}"); + assert!(!cmd.contains("npm"), "npm must not be the fallback: {cmd}"); } #[test] @@ -4535,7 +4705,10 @@ mod tests { "/Users/me/bin/grok", "/Users/me/.grok/downloads/grok-macos-aarch64", ); - assert_eq!(cmd.as_deref(), Some("/Users/me/bin/grok update")); + assert_eq!( + cmd.as_deref(), + Some(format!("/Users/me/bin/grok update || {GROK_INSTALL_UNIX}").as_str()) + ); } #[test] @@ -5006,6 +5179,35 @@ mod tests { assert_eq!(first_abs_path_line("welcome\nbye\n"), None); } + #[test] + fn path_line_from_env_output_survives_shell_noise() { + // `$SHELL -lic /usr/bin/env` 的 stdout 前面可能有交互式 rc 的欢迎语。 + let raw = "🚀 Welcome back, Jason!\nSHELL=/bin/zsh\nPATH=/opt/homebrew/bin:/usr/bin\nHOME=/Users/me\n"; + assert_eq!( + path_line_from_env_output(raw), + Some("/opt/homebrew/bin:/usr/bin") + ); + // 多行值的环境变量里恰好有一行以 `PATH=` 开头时,「值须以 / 开头」把它筛掉。 + let poisoned = "SOME_SCRIPT=line1\nPATH=not-a-path\nPATH=/usr/bin:/bin\n"; + assert_eq!(path_line_from_env_output(poisoned), Some("/usr/bin:/bin")); + // 完全没有 PATH 行 → None,调用方保持不注入。 + assert_eq!(path_line_from_env_output("HOME=/Users/me\n"), None); + } + + #[test] + fn merge_path_segments_dedupes_preserving_login_order() { + // 登录 shell 的段全部在前且保序;继承 PATH 里的新段追加在后。 + assert_eq!( + merge_path_segments( + "/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin", + "/usr/bin:/bin:/usr/sbin" + ), + "/Users/me/.nvm/versions/node/v22/bin:/usr/bin:/bin:/usr/sbin" + ); + // 空段(`a::b` 在 POSIX 下意为当前目录)不该被注入。 + assert_eq!(merge_path_segments("/usr/bin::/bin", ""), "/usr/bin:/bin"); + } + #[test] fn is_conflicting_thresholds() { let make = |version: Option<&str>, runnable: bool| ToolInstallation {