fix(updater): prevent codex self-update from breaking npm platform-dispatch installs

Codex ships as an npm platform-dispatch package (JS launcher @openai/codex + per-platform binary optional deps like @openai/codex-darwin-arm64). The upgrade chain ran `<bin>/codex update || <bin>/npm i -g @openai/codex@latest`, which can leave codex throwing "Missing optional dependency @openai/codex-darwin-arm64":

- `codex update` on an npm install is a bare `npm install -g @openai/codex` that prints success and exits 0 even when the platform binary fails to land, short-circuiting the `||` npm fallback.

- The npm fallback is a no-op when version==latest and only targets the main package, so it can never re-land the missing platform binary.

Fixes: (1) remove codex from prefers_official_update (Posix+Windows) so npm-managed codex no longer runs the false-success `codex update`; (2) add a runnable=false gate in installs_anchored_command emitting an uninstall+install self-heal — the only repair that re-lands the platform binary; (3) narrow by source/real to npm-managed sources (nvm/fnm/mise/homebrew, non-brew-formula) so broken brew-formula/volta/bun installs fall back to their own anchored commands instead of being mis-repaired with npm. Reuses the existing enumerate runnable signal; no new FS probing.
This commit is contained in:
Jason
2026-06-14 21:17:21 +08:00
parent 34001aaffc
commit 5092fe51ce
+175 -11
View File
@@ -1985,10 +1985,19 @@ fn anchored_official_update_command(tool: &str, bin_path: &str) -> Option<String
official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path))) official_update_args(tool).map(|args| format!("{} {args}", win_quote_path_for_batch(bin_path)))
} }
/// 哪些工具的"官方 self-update"优先于包管理器升级(生成 `<tool> update || <pkg-mgr>`)。
///
/// **codex 刻意不在此列**`codex update` 在 npm 安装上只是裸 `npm install -g
/// @openai/codex`(无 `@latest` / `--include=optional` / 不先卸载),却只检查 exit code、
/// 无条件打印 “Update ran successfully”。当 npm 把平台二进制 optional 依赖
/// `@openai/codex-<triple>` 漏装时它仍 **exit 0 假成功**,使外层 `||` 兜底被短路、损坏被
/// 成功 toast 掩盖(用户报告的 “Missing optional dependency” 即源于此)。因此 codex 一律走
/// npm 锚定升级;真正损坏(`runnable=false`)时由 `installs_anchored_command` 的门控改用
/// `codex_repair_command` 的 uninstall+install 自愈,而非交给 codex 自身的 self-update。
fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool { fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
match shell { match shell {
LifecycleCommandShell::Posix => { LifecycleCommandShell::Posix => {
matches!(tool, "claude" | "codex" | "opencode" | "openclaw") matches!(tool, "claude" | "opencode" | "openclaw")
} }
LifecycleCommandShell::WindowsBatch => { LifecycleCommandShell::WindowsBatch => {
matches!( matches!(
@@ -1997,12 +2006,66 @@ fn prefers_official_update(tool: &str, shell: LifecycleCommandShell) -> bool {
// 安装方式探测失败弹交互 promptspawn npm.cmd 没传 shell:true);静默 // 安装方式探测失败弹交互 promptspawn npm.cmd 没传 shell:true);静默
// lifecycle 没有 stdin 会挂死,Windows 先锚到包管理器路径,等上游修了 // lifecycle 没有 stdin 会挂死,Windows 先锚到包管理器路径,等上游修了
// 再把 opencode 加回这里。 // 再把 opencode 加回这里。
"claude" | "codex" | "openclaw" "claude" | "openclaw"
) )
} }
} }
} }
/// Codex 平台分发包损坏的自愈命令。Codex 的 npm 包是「主包 `@openai/codex`(纯 JS
/// launcher+ 平台二进制 optional 依赖 `@openai/codex-<triple>`」的分发模式(同 esbuild/swc)。
/// 当平台二进制缺失时 codex 跑不起来——`enumerate_tool_installations` 跑 `--version` 会拿到
/// “Missing optional dependency” 的非 0 退出,标记 `runnable=false`。此状态下普通
/// `npm i -g @pkg@latest` 是 **no-op**npm 视 optional 依赖缺失为非致命,reify 又认为主包已是
/// 最新(外加半损坏留下的空 nested `node_modules` 残骸强化「tree 已满足」判断),不会补回平台
/// 二进制。唯一实测可靠的修复是先 `uninstall` 清掉残骸、再 `install` 装回完整的主包 + 平台二进制
/// (实测输出 `added 2 packages`)。
///
/// 锚定到与 codex 入口同目录的 npm(与升级路径一致,不依赖 GUI 非登录进程的 PATH)。`|| true`
/// 让 uninstall 失败(如 nvm 上对半损坏包静默返回非 0)不触发外层 `set -e` 中止,但随后的
/// install 若失败仍会被 `set -e` 捕获并上报给前端 toast。
///
/// **仅对会锚定到 sibling npm 的 node 管理器来源(nvm/fnm/mise/homebrew npm)生效**
/// `runnable=false` 是宽信号(权限 / node 版本 / 任意 `--version` 失败皆可触发),非 npm
/// 全局安装各有自己的二进制分发与修复方式,无脑套 npm uninstall+install 会出错——Homebrew
/// formulareal 在 `Cellar/`)本应 `brew upgrade codex`npm 够不到它反而旁路装第二份 npm
/// 全局 codexVolta/Bun 本应 `volta install`/`bun add`,且 `~/.bun/bin` 下没有 npm、
/// `sibling_bin` 会拼出不存在的路径;system/未知来源无可靠 sibling npm。这些来源一律返回
/// None,让上游继续走 source-specific 的 `anchored_command_from_paths`。白名单与
/// `package_manager_anchored_command_from_paths` 的 sibling-npm 分支对齐。
/// 刻意**不**额外用 `inst.error` 文本确认「确系缺二进制」:enumerate 只保留 stderr 末尾 4 行,
/// 而 codex.js 抛错的 "Missing optional dependency" 行会被尾部 node stack `at ...` 行挤出窗口
/// (实测用户原始错误即如此),强加该条件反而漏修真实缺包;对 npm 全局安装,uninstall+install
/// 对各类损坏都是合理且不会更糟的修复。
#[cfg(not(target_os = "windows"))]
fn codex_repair_command(bin_path: &str, real: &str) -> Option<String> {
// brew formulareal 在 Cellar)→ 不归 npm 管,交回 anchored 走 brew upgrade。
if brew_formula_from_path(real).is_some() {
return None;
}
// 只认会落到 sibling npm 的 node 管理器来源;volta/bun/system/未知交回 anchored。
if !matches!(
infer_install_source(Path::new(bin_path)),
"nvm" | "fnm" | "mise" | "homebrew"
) {
return None;
}
let npm = sibling_bin(bin_path, "npm")?;
let npm = quote_path_if_spaced(&npm);
let pkg = "@openai/codex";
Some(format!(
"{npm} uninstall -g {pkg} || true; {npm} i -g {pkg}@latest"
))
}
/// Windows 暂不做平台分发自愈:Windows 上 codex 的破坏模式不同(EPERM 文件锁 / 版本 bump
/// 残留,见 openai/codex#21872、#19824),且 `.bat` 链的错误处理与 POSIX `set -e` 语义不同,
/// 需要单独设计;先在本问题实际发生的 POSIX 平台落地。返回 None → 上游走正常锚定命令。
#[cfg(target_os = "windows")]
fn codex_repair_command(_bin_path: &str, _real: &str) -> Option<String> {
None
}
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
fn package_manager_anchored_command_from_paths( fn package_manager_anchored_command_from_paths(
tool: &str, tool: &str,
@@ -2190,6 +2253,17 @@ fn default_install(installs: &[ToolInstallation]) -> Option<&ToolInstallation> {
fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Option<String> { fn installs_anchored_command(tool: &str, installs: &[ToolInstallation]) -> Option<String> {
let inst = default_install(installs)?; let inst = default_install(installs)?;
let real = inst.real.to_string_lossy(); let real = inst.real.to_string_lossy();
// Codex 平台分发包损坏自愈:主包在但平台二进制缺失时 codex 跑不起来
// runnable=false),此时正常锚定的 `npm i -g @latest` 是 no-op 修不好——改用
// uninstall+install 重装补回平台二进制。**但仅限会锚定到 sibling npm 的 node 管理器
// 来源**codex_repair_command 内按 source/real 收窄,brew/volta/bun/system 交回下方
// source-specific 锚定,避免误用 npm 重装)。runnable=true 的正常升级也走下方普通锚定
// 路径(且因 codex 不在 prefers_official_update,不会再跑会假成功掩盖损坏的 `codex update`)。
if tool == "codex" && !inst.runnable {
if let Some(cmd) = codex_repair_command(&inst.path, &real) {
return Some(cmd);
}
}
anchored_command_from_paths(tool, &inst.path, &real) anchored_command_from_paths(tool, &inst.path, &real)
} }
@@ -4046,8 +4120,9 @@ mod tests {
#[test] #[test]
fn codex_nvm_anchors_to_that_npm() { fn codex_nvm_anchors_to_that_npm() {
// Codex 官方 self-update 只在支持的 release 上生效;失败时仍写回同一个 // Codex 不走 self-update`codex update` 在 npm 安装上只是裸 `npm install -g`
// node 的 npm,而非 PATH 第一个 npm。 // 却会假成功掩盖平台二进制漏装)——直接锚定到同一个 node 的 npm,而非 PATH
// 第一个 npm。损坏时的 uninstall+install 自愈见 codex_missing_platform_binary_*。
let cmd = anchored_command_from_paths( let cmd = anchored_command_from_paths(
"codex", "codex",
"/Users/me/.nvm/versions/node/v22.14.0/bin/codex", "/Users/me/.nvm/versions/node/v22.14.0/bin/codex",
@@ -4055,7 +4130,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
cmd.as_deref(), cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/codex update || /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest") Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
); );
} }
@@ -4075,9 +4150,25 @@ mod tests {
} }
#[test] #[test]
fn volta_uses_volta_install() { fn volta_self_update_chain_anchors_to_volta() {
// `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能 // `~/.volta/bin` 通常不在 GUI 非登录 `bash -c` 的 PATH 里,且用户可能
// PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。 // PATH 上还有另一份 volta → 必须绝对路径锚定到命令行命中的这一份。
// 用 openclaw(仍在 prefers_official_update)覆盖 volta 分支的 self-update 链;
// codex 已改为不 self-update(见 codex_volta_anchors_to_volta_install)。
let cmd = anchored_command_from_paths(
"openclaw",
"/Users/me/.volta/bin/openclaw",
"/Users/me/.volta/tools/image/packages/openclaw/lib/node_modules/openclaw",
);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.volta/bin/openclaw update --yes || /Users/me/.volta/bin/volta install openclaw")
);
}
#[test]
fn codex_volta_anchors_to_volta_install() {
// codex 锚定到命令行命中的那份 volta,但不 self-update:纯 `volta install`。
let cmd = anchored_command_from_paths( let cmd = anchored_command_from_paths(
"codex", "codex",
"/Users/me/.volta/bin/codex", "/Users/me/.volta/bin/codex",
@@ -4085,7 +4176,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
cmd.as_deref(), cmd.as_deref(),
Some("/Users/me/.volta/bin/codex update || /Users/me/.volta/bin/volta install @openai/codex") Some("/Users/me/.volta/bin/volta install @openai/codex")
); );
} }
@@ -4113,7 +4204,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
cmd.as_deref(), cmd.as_deref(),
Some("'/Users/my name/.volta/bin/codex' update || '/Users/my name/.volta/bin/volta' install @openai/codex") Some("'/Users/my name/.volta/bin/volta' install @openai/codex")
); );
} }
@@ -4181,7 +4272,7 @@ mod tests {
assert_eq!( assert_eq!(
cmd.as_deref(), cmd.as_deref(),
Some( Some(
"/Users/me/.local/share/fnm_multishells/12345_abc/bin/codex update || /Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest" "/Users/me/.local/share/fnm_multishells/12345_abc/bin/npm i -g @openai/codex@latest"
) )
); );
} }
@@ -4195,7 +4286,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
cmd.as_deref(), cmd.as_deref(),
Some("'/Users/my name/.nvm/versions/node/v22/bin/codex' update || '/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest") Some("'/Users/my name/.nvm/versions/node/v22/bin/npm' i -g @openai/codex@latest")
); );
} }
@@ -4292,6 +4383,78 @@ mod tests {
assert!(default_install(&installs).is_none()); assert!(default_install(&installs).is_none());
} }
#[test]
fn codex_missing_platform_binary_self_heals_via_uninstall_install() {
// 平台二进制缺失 → `codex --version` 报 "Missing optional dependency" 退出非 0
// → enumerate 标记 runnable=false。此状态下普通 `npm i -g @latest` 是 no-op 修不好,
// 升级路径改用 uninstall+install 重装补回平台二进制(`|| true` 让 uninstall 在
// set -e 下对半损坏包返回非 0 时仍继续 install)。
let mut broken = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
broken.runnable = false;
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm uninstall -g @openai/codex || true; /Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
}
#[test]
fn codex_runnable_uses_plain_npm_not_self_heal() {
// 正常(runnable=true)的 codex 升级:锚定 npm,既不重装、也不跑会假成功
// 掩盖损坏的 `codex update`。
let healthy = inst("/Users/me/.nvm/versions/node/v22.14.0/bin/codex", true);
let cmd = installs_anchored_command("codex", &[healthy]);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.nvm/versions/node/v22.14.0/bin/npm i -g @openai/codex@latest")
);
assert!(!cmd.unwrap().contains("uninstall"));
}
#[test]
fn codex_broken_homebrew_formula_uses_brew_not_npm_repair() {
// brew formula 装的坏 codexreal 在 Cellar):自愈门控必须收窄放行,回落到
// `brew upgrade codex`——若误走 npm 重装,npm 够不到 Cellar 那份、反而旁路
// 装第二份 npm 全局 codex 制造双安装。
let broken = ToolInstallation {
path: "/opt/homebrew/bin/codex".to_string(),
version: None,
runnable: false,
error: None,
source: "homebrew".to_string(),
is_path_default: true,
real: std::path::PathBuf::from("/opt/homebrew/Cellar/codex/1.2.3/bin/codex"),
};
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/opt/homebrew/bin/brew upgrade codex")
);
}
#[test]
fn codex_broken_volta_uses_volta_install_not_npm_repair() {
// volta 装的坏 codex:回落到 `volta install`,不走 npm 重装。
let mut broken = inst("/Users/me/.volta/bin/codex", true);
broken.runnable = false;
assert_eq!(
installs_anchored_command("codex", &[broken]).as_deref(),
Some("/Users/me/.volta/bin/volta install @openai/codex")
);
}
#[test]
fn codex_broken_bun_uses_bun_add_not_phantom_npm() {
// bun 装的坏 codex:回落到 `bun add`,且**绝不**拼出 `~/.bun/bin/npm`
// (bun 目录下没有 npm,那条路径不存在、执行会直接失败)。
let mut broken = inst("/Users/me/.bun/bin/codex", true);
broken.runnable = false;
let cmd = installs_anchored_command("codex", &[broken]);
assert_eq!(
cmd.as_deref(),
Some("/Users/me/.bun/bin/bun add -g @openai/codex@latest")
);
assert!(!cmd.unwrap().contains("npm"));
}
#[test] #[test]
fn first_abs_path_line_skips_shell_noise() { fn first_abs_path_line_skips_shell_noise() {
// 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示), // 交互式 .zshrc 先打印欢迎语(如 powerlevel10k / 自定义提示),
@@ -4419,8 +4582,9 @@ mod tests {
); );
assert_eq!( assert_eq!(
static_fallback_command("codex"), static_fallback_command("codex"),
"codex update || npm i -g @openai/codex@latest" "npm i -g @openai/codex@latest"
); );
assert!(!static_fallback_command("codex").contains("codex update"));
assert_eq!( assert_eq!(
static_fallback_command("gemini"), static_fallback_command("gemini"),
"npm i -g @google/gemini-cli@latest" "npm i -g @google/gemini-cli@latest"