Skip to content

fix(update): 编译版二进制的更新路径按安装形态分流 - #1078

Merged
deepcoldy merged 6 commits into
masterfrom
fix/binary-update-paths
Aug 29, 2026
Merged

fix(update): 编译版二进制的更新路径按安装形态分流#1078
deepcoldy merged 6 commits into
masterfrom
fix/binary-update-paths

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Aug 29, 2026

Copy link
Copy Markdown
Owner

问题

botmux update、Dashboard 手动更新、定时自动更新三处在编译版单文件二进制下全部失效。在真实发布的 v3.18.4 二进制上端到端复现(不是推理):

$ ./botmux --version
3.18.4                                   ← 版本号是对的(baked 生效)
$ ./botmux update
❌ 无法安全识别当前安装方式(unknown),请使用原包管理器手动更新 botmux。

自 v3.18.x 起这个二进制是 npm 和 install.sh 两种装法的共同产物,所以这条基本覆盖了所有用户。

根因

三处都走 resolveGlobalInstallPlan(botmuxInstallRoot()),而它按「install root 的路径形态」判包管理器。编译版没有 package.json 落盘(模块图在虚拟 /$bunfs/),packageRoot() 一路向上走到 / 才停。实测(真编译探针 + 真发布二进制,两边逐字相同):

{ "installRoot": "/", "detectedManager": "unknown", "planIsNull": true,
  "cliEntry": "/dist/cli.js", "cliEntryExists": false }

关键点:两种装法是同一个二进制

很容易以为「npm 用户走 npm 的更新、curl 用户走 curl 的更新」是两种产物之间的分流。不是——自 #1047 去掉 bin 字段后,npm i -g botmux 装的是平台子包里的编译版二进制,postinstall 写个 launcher exec 它;install.sh 下载的是同一个二进制。同样的字节,只是位置不同。

所以按模块图判形态必然失败(两边都是 /),按位置判才可行。process.execPath 正是那个还有区别的东西(实测,且是通过各自 launcher 调起来的真实形态):

装法 process.execPath
npm …/node_modules/botmux-linux-x64/botmux
curl ~/.botmux/bin/botmuxBOTMUX_INSTALL_DIR 可改)

改动

  • 新增 core/binary-install-shape.ts(纯函数、零依赖):判形态 + 给出更新策略。npm 子包形态翻译成同级主包根后交回已有的 resolveGlobalInstallPlan,而不是重推一遍 npm prefix——后者会变成同一套规则的第二份实现,且会把 pnpm/bun 全局装错误地逼上 npm(实测翻译后 bun 全局仍正确解析成 bun add -g)。拆成独立叶子模块是为了让 utils/global-install.ts 能引它而不成环(双向实测无环)。
  • 新增 core/binary-self-update.ts:install.sh 形态自替换——下载资产 → 校验 SHA-256 → 原子 rename。musl 判据与 install.sh / postinstall 同序,且只在正向观测到时才认 musl。
  • 抽出 core/release-download.ts:复用 hd2d 已有的「代理感知 + 重定向逐跳拆连接」下载逻辑,不再复制一份(规范化比对验证移动前后语义逐字相同,仅 UA 从模块常量变成参数)。
  • 三处入口接上策略解析;新增 resolveAutoUpdateSupport() 作为唯一判据,供设置页 projection 与写入校验共用(否则前端画灰、后端放行)。

顺带修掉的同源缺陷

  1. isLocalDevInstall() 编译态判的是「/ 有没有 .git/src」。本机恰好都没有所以侥幸正确,但 /src 存在的镜像里编译版会被误判成源码 checkout,转去跑 git pull --ff-only
  2. spawnDetachedRestart[cliEntry,'restart'],编译态 argv[2] 是路径不是 restart。实测打印 help 后 exit 0,即「以为重启了其实没重启」。
  3. baked 版本遮蔽botmuxVersionAt任何目录都让 baked 值优先,所以自替换后、以及包管理器装的编译版更新后currentVersion() 都仍报旧版本 ⟹ after===before ⟹ tick 报「已是最新」且不重启;dashboard changed 恒 false;rollback 更会每次都报 installed_version_mismatch。新增 diskVersionAt()(显式忽略 baked)供所有 post-install 读取使用。
  4. pnpm 版本化 store:更新后 process.execPath 仍指旧版本路径 ⟹ ENOENT 或由旧二进制拉起 supervisor。新增 resolveStandaloneRestartExecutable(),包管理器形态走稳定 launcher。
  5. 回退能力单独下发:Web 原先用 updateSupported 推导 rollbackSupported,而 rollback 只会驱动包管理器 ⟹ curl 装的会看到必然失败的按钮。后端显式下发,且 rollback 端点也改为按策略解析根(否则 npm 编译版首次回退仍挂)。

影响面

动了 core/ 公共层 + dashboard + cli 三处热路径,但Node 形态全部逐字不变:strategy 在非 standalone 时直接返回原 installRootdiskVersionAt 在无 baked 值时与 botmuxVersionAt 等价。受影响的只有编译版二进制这一形态,也就是当前 npm / install.sh 的默认产物。

验证

  • tsc 0 错;bun run build 过(含 dashboard bundle)。全量单测 19058 passed / 8 failed,4 个红文件用干净基线 worktree(61c46f932)逐文件对照全部一致⟹均为既有。其中 plugin-mcp-sandbox 在基线上因缺 dist 被 skip、在我这儿因有 dist 才跑起来,给基线补跑 bun run build 后同样 2 failed | 1 passed,确认与本 PR 无关。
  • 真实二进制 A/B:PRE(v3.18.4 发布版)update 报 unknown;POST 同样调用真的下载并替换成功,替换后二进制 --version 输出 3.18.4,目录内无残留临时文件。npm 形态实测走 npm install -g --prefix … botmux@latest 并真装进推导出的 prefix。__self-update 子命令(maintenance 侧驱动的那条)端到端通,且从 npm 拥有的树里跑会 fail-closed 拒绝、那个二进制字节数前后一致。
  • 相关 11 个测试文件 203 条全绿14 组反变异全红(restart argv / standalone 落回 installRoot / 去 node_modules 锚点 / 跳过校验和 / 去截断下限 / 失败不清理临时文件 / 临时文件挪 tmpdir / 默认认 musl / 去 installedVersion / diskVersionAt 退回 botmuxVersionAt / autoUpdateSupport 恒 true / 重启目标忽略 launcher / rollback 与 run 两处根各自改回 botmuxInstallRoot())。
  • 另实测钉住依赖的 OS 事实:运行中 ELF 就地写入 ETXTBSYrename 覆盖成功且运行中进程存活——这正是必须用 rename 而非写入的原因(shell 脚本不是有效替身,它接受就地写入,测试注释里写明了)。

已知限制(如实记录,未声称覆盖)

BOTMUX_INSTALL_DIR 只在运行时仍导出该变量时才被识别,而 BOTMUX_INSTALL_DIR=… sh install.sh 不会持久化它 ⟹ 之后会 fail-closed 成 unknown(不破坏任何文件,但自更新不可用)。已补断言把这个限制钉住,避免文档/实现漂移成「已无条件支持 custom dir」。

🤖 Generated with Claude Code

deepcoldy and others added 6 commits August 28, 2026 23:44
`botmux update`、Dashboard 手动更新、定时自动更新三处在编译版单文件二进制下
全部失效。真实发布的 v3.18.4 二进制端到端复现:`--version` 正确输出 3.18.4,
但 `botmux update` 报「无法安全识别当前安装方式(unknown)」后退出。

根因:三处都走 `resolveGlobalInstallPlan(botmuxInstallRoot())`,而该函数按
「install root 的路径形态」判包管理器。编译版没有 package.json 落盘(模块图在
虚拟 `/$bunfs/`),`packageRoot()` 一路向上走到 `/` 才停 ⟹ 判定恒为 unknown。

关键点:**不存在「npm 用户走 npm、curl 用户走 curl」的产物差异** —— 自 #1047
去掉 `bin` 字段后,npm 装的和 install.sh 装的是**同一个编译版二进制**,只是位置
不同。所以按模块图判形态必然失败(两边都是 `/`),按**位置**判才可行:
`process.execPath` 在两种装法下干净可分(实测 npm →
`…/node_modules/botmux-linux-x64/botmux`,curl → `~/.botmux/bin/botmux`)。

改动:
- 新增 `core/binary-install-shape.ts`(纯函数,无依赖)判形态并给出更新策略;
  npm 子包形态**翻译成同级主包根**后交回已有的 `resolveGlobalInstallPlan`,
  而不是在这里重新推一遍 npm prefix —— 后者会变成同一套规则的第二份实现,
  且会把 pnpm/bun 全局装错误地逼上 npm。
- 新增 `core/binary-self-update.ts`:install.sh 形态自替换(下载对应平台资产 →
  校验 SHA-256 → 原子 rename)。musl 判据与 install.sh / postinstall 同序,
  且只在**正向观测到**时才认 musl(glibc 机器误装 musl 包会直接起不来)。
- 抽出 `core/release-download.ts`:hd2d 已有的「代理感知 + 重定向逐跳拆连接」
  下载逻辑原样复用,不再复制一份(已验证移动前后语义逐字相同)。
- 三处入口接上策略解析;Node 路径逐字未动。

顺带修掉两个同源缺陷:
- `isLocalDevInstall()` 在编译态判的是「`/` 有没有 .git/src」。本机恰好没有所以
  侥幸正确,但 `/src` 存在的镜像里编译版会被误判成源码 checkout 并转去跑
  `git pull --ff-only`。改为显式 standalone 早退。
- `spawnDetachedRestart` 拼 `[cliEntry,'restart']`,编译态 argv[2] 是那个路径
  而非 `restart` ⟹ 实测**打印 help 后 exit 0**(以为重启了其实没重启)。
- 自替换后 `currentVersion()` 读的是本进程 baked 版本,**看不到磁盘已换**,
  `after===before` 会让 tick 报「已是最新」且不重启;新增 `installedVersion()`
  由自替换路径显式上报。

影响面:`core/` 公共层 + dashboard + cli 三处热路径,但**Node 形态全部逐字不变**
(strategy 在非 standalone 时直接返回原 installRoot,后续调用链未改)。受影响的
只有编译版二进制这一形态,也就是当前 npm / install.sh 的默认产物。

验证:
- tsc 0 错;全量单测 19058 passed / 8 failed,4 个红文件用**干净基线 worktree
  (61c46f9) 逐文件对照全部一致**(其中 plugin-mcp-sandbox 在基线上因缺 dist
  被 skip,给基线补跑 `bun run build` 后同样 2 failed | 1 passed ⟹ 均为既有)。
- 真实二进制 A/B:PRE(v3.18.4 发布版) `update` 报 unknown;POST 同样调用真的
  下载并替换成功,替换后二进制 `--version` 输出 3.18.4 且无残留临时文件。
- npm 形态实测走 `npm install -g --prefix /tmp/npmshape botmux@latest` 并真的
  装进推导出的 prefix(未误走自替换)。
- 新增 22 条断言 + maintenance 3 条;**9 组反变异全红**(撤 restart argv 修复 /
  standalone 落回 installRoot / 去掉 node_modules 锚点 / 跳过校验和 / 去掉截断
  下限 / 失败不清理临时文件 / 临时文件挪去 tmpdir / 默认认 musl / 去掉
  installedVersion),基线全绿。
- 另实测钉住依赖的 OS 事实:运行中 ELF 就地写入 `ETXTBSY`、rename 覆盖成功且
  运行中进程存活(这正是必须用 rename 而非写入的原因)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
自审三处收口:

1. `botmux update` 的自替换路径**原先没握锁**,而 dashboard / maintenance 两侧都
   握。这条路径是写同一个文件,两个 update 并发会互相盖掉临时文件与 rename。
   改为握同一把 `globalInstallUpdateLockTarget()`;锁文件父目录可能不存在
   (daemon 从未在本机起过就先跑 update),先 mkdir 再握,避免 ENOENT 盖掉真实错误。

2. 补文档说明**为何不做 `realpathSync(target)`**:rename 覆盖软链会替掉链本身并
   把真身孤立(已实测)。但默认 target 是 `process.execPath`,而它**已由 OS 解析过**
   —— 实测用编译版二进制经软链调起,`execPath` 报的是真身而非软链 ⟹ 该分支在生产
   路径上不可达,再解析一次只会引入自己的失败模式。install.sh 的裸 `mv` 语义相同。

3. 新增两条断言覆盖**真实世界的包管理器布局**(pnpm 虚拟 store / pnpm v11
   content-addressed / pnpm 保留软链 / yarn global / Windows npm / bun global):
   六种布局全部落在 `package-manager`,**没有一种会被误判成 self-replace**
   (那才是破坏性后果:往包管理器拥有的树里写文件)。并验证 pnpm 与 Windows npm
   各自解析成自己的命令,而非被逼上 npm。

验证:tsc 0 错;binary-self-update + maintenance + cli-update-alias 共 49 条全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
复审指出 5 条阻断项,全部复现并修掉:

1. **Dashboard `/api/update/run` 丢掉策略根**:拿到 `runStrategy` 后只消费了
   `self-replace` 分支,package-manager 分支又回去用 `botmuxInstallRoot()`
   (编译态是 `/`)⟹ status 显示可更新、点按钮回 `400 unsupported_install_method`。
   改为消费 `runStrategy.packageRoot`(`lastSuccessfulUpdatePlan` 仍优先,pnpm
   继续用它上次解析到的稳定软链)。

2. **baked 版本遮蔽不只影响 self-replace,也影响包管理器装的编译版**:
   `botmuxVersionAt` 让 baked 值优先于磁盘 package.json,**对任何目录都如此**
   (实测 package.json 写 3.19.0、调用返回 3.18.4)⟹ npm 装成功后 tick 仍算
   `after===before`,不写 intent 不重启;dashboard 的 `changed` 恒 false,
   rollback 的校验更会恒报 `installed_version_mismatch`。新增 `diskVersionAt`
   (显式忽略 baked)并把 maintenance + dashboard 三处 post-install 读取切过去。
   ⚠️ 这条是**既有潜在缺陷被我的修复变得可达**(修复前 plan 解析就抛了,根本走
   不到版本比较),所以算本 PR 的阻断项。

3. **定时更新开关在 UI 仍被禁用**:`resolveDashboardSettings()` 的
   `autoUpdateSupported` 直接跑 `tryResolveGlobalInstallPlan()`(编译态根是 `/`)
   ⟹ 后端 save 放行但前端画成灰的。且我上一版对**任何** `npm-binary` 直接返回
   true,会把已确认 `plan=null` 的 Yarn / pnpm-v11 布局误报支持。改为抽出
   `resolveAutoUpdateSupport()` 统一判据(`self-replace`⟹true;
   `package-manager`⟹要求 plan 可解析;`unsupported`⟹false),projection 与写入
   校验共用同一个函数,杜绝再次漂移。

4. **pnpm 版本化 store 更新后重启的是旧二进制**:standalone 下无条件
   `process.execPath`,而 pnpm 运行路径含 `botmux-linux-x64@旧版本`;更新后
   postinstall 已把稳定 launcher 指向新子包,父进程 execPath 仍是旧路径 ⟹ 旧
   store 项被回收就 ENOENT,没被回收则由**旧二进制**拉起 supervisor(更新静默
   不生效)。新增 `resolveStandaloneRestartExecutable()`:包管理器形态走稳定
   launcher、curl 自替换继续走自身路径、launcher 缺失回落 execPath(既有行为)、
   非 standalone 逐字不变。

5. **回退入口暴露给了不支持回退的形态**:Web 用 `updateSupported` 推导
   `rollbackSupported`,而我把前者对二进制置了 true ⟹ curl 装的会看到一个必然
   失败的按钮(`/api/update/rollback` 只会驱动包管理器)。改为后端显式下发
   `rollbackSupported`(= 有可用 plan),前端消费它并对旧后端保留回落。

另按复审的非阻断提醒,修正 `BOTMUX_INSTALL_DIR` 的注释与断言:它只在**运行时仍
导出**该变量时才生效,而 `BOTMUX_INSTALL_DIR=… sh install.sh` 不会持久化 ⟹ 之后
会 fail-closed 成 unknown(不破坏文件,但自更新不可用)。补了断言把这个限制钉住,
避免文档/实现声称已无条件覆盖 custom dir。

验证:tsc 0 错;`bun run build` 通过(含 dashboard bundle,本轮动了 app.tsx);
binary-self-update 35 条 + 相关 10 个测试文件共 197 条全绿;**新增 3 组反变异全红**
(diskVersionAt 退回 botmuxVersionAt / resolveAutoUpdateSupport 恒 true /
重启目标忽略 launcher),与前 9 组合计 12 组。

Co-Authored-By: Claude Code <noreply@anthropic.com>
复审最后 1 条阻断项:`/api/update/status` 现在按映射后的策略根判定
`rollbackSupported: true`,但 `/api/update/rollback` 仍从 `botmuxInstallRoot()`
解析 plan —— **fresh 编译进程还没有 `lastSuccessfulUpdatePlan`,那就是 `/`**,
`resolveGlobalInstallPlan('/')` 直接抛 unsupported。也就是说上一版第 5 条只隐藏了
curl 的按钮,**npm 装的编译版首次回退仍坏**。

改动:
- rollback 先 `currentUpdateStrategy(botmuxInstallRoot())`,只接受
  `package-manager`(`self-replace` 明确回 `manager: 'binary'`),根用
  `lastSuccessfulUpdatePlan?.activePackageRoot ?? rollbackStrategy.packageRoot`。
- `/api/update/status` 里那个**只用于展示/分类**的根从 `packageRoot` 改名
  `classifyRoot`。它喂的是 `currentUpdateStrategy`(本身正确处理 `/`)和兜底的
  manager 标签,**从不进 plan**;改名让它与真正会进 `resolveGlobalInstallPlan`
  的 `packageRoot` 在源码层面不可混淆。

测试:新增「status 说支持 ⟹ rollback 必须也能解析」的对称性断言,以及一条
**源码级守卫**——每个 `const packageRoot = …` 都必须由 strategy 决定。

⚠️ 这条守卫我写坏了两版才对,过程记下来:①第一版按「行内出现
`botmuxInstallRoot()`」判定 ⟹ **在干净代码上就红**(把正确的 Node 路径三元回退和
展示用的那行一起判成违规)。**一个在正确代码上会红的守卫最终会被删掉**,等于没有
守卫。②真正的教训是:纯函数断言**看不见 dashboard 内部传的是哪个变量**——我把
rollback 那行改回 `botmuxInstallRoot()` 后**所有行为断言依旧全绿**,所以这里必须
有源码级守卫;但它的判据得是「是否由 strategy 决定」,而不是「有没有出现某个标识
符」。为此顺手把展示用变量改名,让两种用途在文本上可分。

验证:tsc 0 错;`bun run build` 过;11 个相关测试文件 203 条全绿;守卫在干净代码
**绿**、把 rollback 或 run 任一处根改回 `botmuxInstallRoot()` **各自变红**(累计
14 组反变异)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
等 CI 期间用**真编译二进制**跑并发 A/B 时发现的:同时跑两个 `botmux update`,
胜者正常完成、二进制完好(互斥本身是有效的),但**落败者把锁的内部状态直接透给
用户**:

```
❌ 升级失败:file-lock timeout waiting for …/npm-global-update.lock
   (held by pid 630166, age 2222ms)
```

根因:`withFileLock` 拿不到锁时是**抛异常**,不是安静返回 ⟹ 我上一版放在 await
之后的 `if (!acquired)` 是**死代码**,rejection 直接落到通用报错分支。而这压根不是
故障,是完全正常的互斥结果(dashboard 或定时任务正在更新)。

改为在 catch 里按 `acquired` 分流:未进入临界区 ⟹ 「另一个更新正在进行中
(dashboard 或定时任务),请稍后重试。」;已进入后的真实失败(下载/校验/替换)
`throw` 交给外层统一报错,不被吞掉。

验证:真二进制 A/B 复跑——胜者 `✅ 升级完成`、落败者拿到上述可执行提示、
替换后二进制 `--version` 正常且无残留临时文件。新增源码级守卫(行为上要复现需要
两个进程抢一个 ~170MB 下载,手工验过一次即可,不适合塞进单测),**撤回修复后
变红**,累计 15 组反变异。相关 11 个测试文件 204 条全绿;tsc 0 错。

⚠️ 守卫的取窗口方式踩过一次:原先取「从分支头到第一个 `return;`」,而该分支为
「已是最新」会提前 return ⟹ 窗口只有 355 字符、根本没覆盖到锁代码,三条断言
**全部空真**。改为锚定 `withFileLock` 调用点取窗口,并对锚点本身加断言,
分支挪走时会明确失败而不是静默失效。

Co-Authored-By: Claude Code <noreply@anthropic.com>
复审指出上一版的分流是**二态假设**,实际有三态。已复现并修掉。

`acquired === false` 只证明「回调没执行」,**不等于「别人持锁」**:`withFileLock`
在调用回调之前还有四处会抛——`open` 的非 EEXIST errno(EACCES/ENOSPC/ENOENT)、
holder payload 写入失败、holder 元数据读取失败(O_NOFOLLOW 下的 ELOOP 等)、
stale-claim `link` 失败。只看 `acquired` 会把这些**真实故障全部误报成「另一个更新
正在进行」**——磁盘满被说成并发冲突,是最坏的一种误导。

A/B 实测(真 `withFileLock`,两种真实的 pre-callback 失败):
```
ENOENT parent : PRE-FIX ⇒ FRIENDLY「另一个更新正在进行」  POST-FIX ⇒ RETHROW
symlinked lock: PRE-FIX ⇒ FRIENDLY「另一个更新正在进行」  POST-FIX ⇒ RETHROW (ELOOP)
```

改动:
- `file-lock.ts` 新增 `FileLockTimeoutError`(带 `code: 'FILE_LOCK_TIMEOUT'`),
  async 与 sync 两处超时点**语义一致**。
- ⚠️ **message 文本逐字不变**:`workflows/v3/host.ts`、`workflows/v3/cli-run.ts`、
  `workflows/v3/goal-cli.ts`、`workflows/v3/daemon-run.ts`、`daemon.ts`、
  `services/session-store.ts` 共 6 处仍按字符串匹配它,改文案会静默破坏它们的处理。
  已逐条按各自的真实判据验证(`startsWith` / `includes` / 正则)**全部仍成立**,
  且仍 `instanceof Error`。新代码应改用类型判断。
- `cli.ts` 分流改为 `!acquired && error instanceof FileLockTimeoutError`:
  ① 超时且未进回调 → 友好并发提示;② 回调内失败 → 透出真实错误;
  ③ 回调前基础设施失败 → 同样透出真实错误。

测试:新增三态各一条 + 一条钉住 message 向后兼容;源码守卫按复审要求改为钉住
「`!acquired` **且是 timeout 类型**」,并加一条反向断言禁止裸 `if (!acquired) {`
再出现(否则守卫正在保护这个过宽分流)。

验证:tsc 0 错;binary-self-update 42 条全绿;**新增 2 组反变异全红**(去掉
`instanceof` 那半 / 把 timeout 退回裸 `Error`),累计 17 组。全量单测
19078 passed / 8 failed,红文件仍是既有那 3 个(`mojo-launcher-env-quarantine`、
`plugin-mcp-gateway`、`plugin-mcp-sandbox`),与干净基线对照一致 —— file-lock 是
共用原语,这里特意跑了全量而非只跑相关文件。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy
deepcoldy merged commit e73205a into master Aug 29, 2026
8 checks passed
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.18.5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant