Skip to content

fix(config): 非默认 HOME 下 daemon 与 CLI 子进程读到不同 bots.json - #753

Merged
deepcoldy merged 5 commits into
deepcoldy:masterfrom
Ghost-LZW:feat/botmux-config-dir
Aug 11, 2026
Merged

fix(config): 非默认 HOME 下 daemon 与 CLI 子进程读到不同 bots.json#753
deepcoldy merged 5 commits into
deepcoldy:masterfrom
Ghost-LZW:feat/botmux-config-dir

Conversation

@Ghost-LZW

@Ghost-LZW Ghost-LZW commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

问题

HOME=~/alt botmux start 起第二个 fleet 时,daemon 加载的是 ~/alt/.botmux/bots.json;但 daemon 注入子进程的只有 cwd 和整套 BOTMUX_*从不注入 HOME。于是子进程按 homedir()/.botmux 重新解析 registry,找不到自己正在扮演的那个 bot,该会话里所有 botmux send / botmux history 全部失败:

Bot not registered: cli_xxxxxxxx

单树部署(绝大多数用户)永远撞不到。

为什么不是「把 HOME 注入子进程」

因为 HOME 同时锚定子 CLI 自己的配置发现CLAUDE_CONFIG_DIR 未设时 Claude Code 回落到 $HOME/.claude。把 HOME 改成 fleet home,会连带把那个 agent 的 skills / settings 搬到别处(实测:一棵树有 42 个 skill,另一棵 0 个)。

改动

唯一权威 = daemon 实际 parse 的那个文件。 daemon 已经把 getLoadedConfigPath() 冻进 loadedBotsConfigPath(原本供 sandbox fs-policy 用),spawnCli 直接把它钉进子进程的 BOTS_CONFIG

BOTS_CONFIG 而不是新增一个 config-dir 变量,有两个决定性原因:

  1. 它在优先级链顶端。 registry 的真实优先级是 BOTS_CONFIG > <config dir>/bots.json。任何排在它之下的新变量,都能被共享 tmux server global env 里的一个陈旧 BOTS_CONFIG 悄悄盖掉,把子进程指向别的 fleet。钉顶端是替换陈旧值,而不是试图超越它。
  2. 它是文件形状,支持任意文件名。 dir 形状的 hint 必须 dirname + 猜 bots.json,猜不回 /srv/fleet-a.json

钉不钉,由 provenance 决定,不由「文件此刻在不在」决定

这是本 PR 的核心不变量。新增 BotsConfigProvenance = 'loaded' | 'synthetic',由 registry 在每一处设置 loadedConfigPath 的地方同时记录,经 forkWorker 冻进 worker init message,再由 spawnCli 消费:

  • 'loaded'(daemon 真的 open 并 parse 过这个文件)→ 无条件 pin。 即使该文件后来消失(unmount / rotate / 权限变),pin 也保留,让 child 在 loader 里硬失败BOTS_CONFIG file not found: … — refusing to fall back to a different registry.)。因为多 fleet 非默认 HOME 下,"优雅降级到 <自己 HOME>/.botmux/bots.json" 意味着换一棵 registry——同 appId 可能带上另一个 fleet 的 secret 与 oncall 路由。丢文件是运维可见的故障;背着人换 registry 更糟。
  • 'synthetic'(core-only 合成,从未 parse 过任何文件)→ 显式 omit,并 delete 继承值。 它把 loadedConfigPath 钉成默认路径纯粹是为了让 no-transport 的 fs-policy 看到 authority root 内的配置,那个文件按设计被忽略、甚至可能不存在。既然没有权威,就没有可传播的东西。

为什么不能用 existsSync 代替:存在性与 provenance 是互相独立的两件事,所以存在性探测在两个方向上都错——真实加载过、后来消失的文件被读成"不存在",pin 被丢掉(child 于是静默加载 foreign registry);而合成的 placeholder 只要那个默认文件恰好存在就被读成"存在",于是一个 daemon 从未 parse 过的文件被当成权威钉了进去。两个方向都已在 review 中以进程级 probe 实测复现。

配套关掉另三个泄漏面:

  • BOTS_CONFIG 加入 BOTMUX_INJECTED_ENV_KEYS。这一个 list 同时驱动三条通路(PANE_ENV_UNSET_KEYSTMUX_CLIENT_STRIP_KEYS 都由它派生):pane 注入、tmux client env 剥离、pane wrapper 的 unset。
  • BOTS_CONFIG 列入 sanitizePerBotEnv reserved keys。它不在 BOTMUX 前缀覆盖范围内必须显式加——否则 bot 自己的 env 就能重定向定义自己的那个 registry。
  • 无权威时 delete 而非留继承值:BOTS_CONFIG 顶在优先级链最上面,一个陈旧的 ambient 值会盖过磁盘默认值。

bot-registry.ts 的三处 registry 路径解析统一走新增的 resolveBotsConfigFile(),不再各自内联 env + 默认值。

默认行为不变:未走 daemon spawn 的普通进程解析结果就是原来的 <home>/.botmux/bots.json

config dir 的 home 语义:os.homedir(),与 cli.ts 完全一致

resolveBotmuxConfigDir()os.homedir()——cli.tsCONFIG_DIR/DATA_DIR/PM2_HOME/BOTS_JSON_FILE 以及 dashboard 写路径是同一个语义。自定义 home 只走 homeDir 测试缝,绝不读 env。

为什么不是 env.HOME ?? env.USERPROFILE ?? homedir():因为 os.homedir() 本身已经是"按平台跟随 env"的规则,手写一遍只会把平台弄错。Node 的契约(https://nodejs.org/api/os.html#oshomedir)是:

  • POSIX — 用 $HOME(未设时 getpwuid())。所以 HOME=~/alt botmux … 零额外代码就能迁移 config dir,手写的 HOME 分支在这里什么也没多买到。
  • win32 — 用 %USERPROFILE%不看 HOME

因此 HOME-first 的规则会在 win32 上cli.ts 分叉:只要 HOMEUSERPROFILE 同时存在且不同(Git-for-Windows / MSYS shell 就会设 HOME),setup/start/PM2 写 %USERPROFILE%\.botmux、daemon 的 registry 读 %HOME%\.botmux——正好重造本 PR 要修的那个 daemon/child 分叉,而仓库支持 win32(PM2 / Task Scheduler / .cmd wrapper)。master 的 bot-registry 用裸 homedir()、与 cli.ts 一致,所以 HOME-first 是回归而不是新特性。

手写 env 读取还有一个与平台无关的洞:?? 是 nullish,所以 HOME=''(在被剥干净的 service 环境里真实存在)会被当成有效值,join('', '.botmux') 产出相对、依赖 cwd.botmuxhomedir() 在 POSIX 上没有这个洞——空 $HOME 会落到 getpwuid()。

为什么不引入公开的 config-dir 变量(相对第一版)

第一版新增了公开变量 BOTMUX_CONFIG_DIR。复审时测出一个决定性事实:os.homedir() 本来就跟随 $HOME。所以 cli.ts 的生命周期路径、dashboard 写路径、setup 全都已经跟着 HOME 走——HOME=<fleet> botmux setup list --json 零改动就能读到 fleet registry。真正分叉的只有 daemon spawn 的 CLI 子进程,因为它是唯一不继承 HOME 的那个。

若保留公开变量却只 govern registry、不 govern data dir / pm2 home / dashboard 写路径,会造出「半迁移」部署和第二个真相源。这是一个 daemon→child 的内部传播问题,复用既有的 BOTS_CONFIG 契约就够。故该变量整个删除。

测试

test/config-dir.test.ts25 个用例,含 review 指出的两个覆盖缺口:vanished-path(加载过的文件消失后不得 fail-open)与 win32 HOMEUSERPROFILE 真分叉。其中 vanished-path 那条配了阳性对照——证明同一个 child 在「什么都没钉」时确实会加载 foreign registry,否则 fail-closed 断言可能是空的。另有一条 source lock 断言「registry 里每一处设置 loadedConfigPath 的地方都同时记录 provenance」,防止将来新增赋值点时静默漏掉。

7 个变异体,全部被杀(阳性对照,确认测试有牙):

变异 是否被杀
M1 provenance 闸门移除(回到信任任意路径)
M2 provenance 闸门换成 existsSync 探测(即 round-2 的 bug)
M3 home 规则回到 HOME-first(即 win32 分叉)
M4 core-only 合成把 placeholder 误标成 'loaded'
M5 消失的 pin 降级到默认 registry 而不是 throw
M6 worker 的 pin 决策回到存在性探测、无视 provenance
M7 worker-pool 从不把 provenance 冻进 init message

pnpm build exit 0,tsc --noEmit 干净。

全量 pnpm test(串行独占):12960 passed | 3 failed | 49 skipped(813 文件)。这 3 个失败所在的文件集是未改动 PR head 的严格子集——没有任何文件在本改动下失败而在 baseline 下不失败。 全部是 timeout / 真 CLI spawn 预算类签名,且没有一个是本 PR 触碰过的文件。

一个诚实的注记:中途有一次 baseline 与 fix 并发跑,导致 baseline 因 CPU 与 spawn 预算竞争膨胀到 28 个失败(测量扰动了被测对象);上面报告的 fix 数字是串行独占跑的。另有 3 个文件的差异一度被当成 flake,实为 baseline 树没有 dist/——api-only-cli-gate.behavior / preset-export-cli / workflow-cli 都依赖构建产物;baseline 树补跑 pnpm build 后这三个文件 21 passed

一个新增导出带来的、tsc 看不见的坑

bot-registry 新增 getLoadedConfigProvenance() 后,凡是用完整 factory(不带 importOriginal)mock 该模块、且实际求值到这个缺失导出的测试,vitest 会硬报错。实测只有 session-lifecycle-start.test.ts 真正走到(50 个用例全灭),已随本 PR 补上。mock 的形状不参与类型检查,所以这类改动只能靠全量跑发现,靠 typecheck + 相关单测会漏。

已知限制

  • adopt 与已存活 pane 的 reattach 改不了既有进程的 env,升级后旧 pane 不会自愈,需重启该 CLI 会话
  • riff 后端走远端 env-credential、不读本机 registry,此 key 对它无实际作用。
  • 读隔离(read-isolated)的 child 仍能正确 pin:Seatbelt 拒绝内容读但放行 metadata 读,loader 的 EPERM + underReadIsolation 分支接管。
  • core/data-dir.ts 有同款 HOME ?? USERPROFILE ?? homedir(),同样是 latent win32 bug 且没有可辩护的理由,但暴露面窄得多——cli.ts:588/627 显式注入 SESSION_DATA_DIR=DATA_DIR,优先级高于那个 resolver,PM2 管理的路径因此掩盖了分叉。故意留在本 PR 范围外core/botmux-wrapper.ts:33 形状相同,且 test/data-dir.test.ts 目前是 env 驱动的,需要独立一次改动),建议单独 issue。
  • 默认单树部署零回归:未走 daemon spawn 的普通进程解析结果与改动前逐字节一致。

@Ghost-LZW
Ghost-LZW requested a review from deepcoldy as a code owner August 6, 2026 00:05
@deepcoldy

Copy link
Copy Markdown
Owner

抱歉最近巨忙,忘了跟进(其实agent已经自动reivew了)

@Ghost-LZW Ghost-LZW changed the title feat(config): 新增 BOTMUX_CONFIG_DIR,修非默认 HOME 下 daemon 与 CLI 子进程读到不同 bots.json fix(config): 非默认 HOME 下 daemon 与 CLI 子进程读到不同 bots.json Aug 10, 2026
@Ghost-LZW

Copy link
Copy Markdown
Contributor Author

已按复审意见迭代:22389770bde85cf

复审提的三条同根问题(BOTS_CONFIG 优先级盖过新变量 / 对外契约没接 cli.ts 生命周期 / 注入值是从 HOME 现场派生而非 daemon 实际加载的路径)确认全部成立,已在新 commit 一并收口。修复方向按复审的指认走:唯一权威 = daemon 冻结的实际加载路径。

关键改动:不是补上那个变量,而是删掉它

BOTMUX_CONFIG_DIR 整个移除。daemon 把它实际 parse 的那个文件(getLoadedConfigPath(),早已冻进 loadedBotsConfigPath 供 sandbox fs-policy 用)直接钉进子进程的 BOTS_CONFIG

这样解决了三条里的两条根因:

  • 钉的是权威值,不是派生值——不再 resolve 一次 HOME。复审给的反例(daemon 以 BOTS_CONFIG=/custom/fleet.json 启动,注入给 child 的却是 HOME/.botmux)在这个改法下不成立,因为钉入的就是 /custom/fleet.json 本身。
  • 落在优先级链顶端——陈旧 BOTS_CONFIG 是被替换而不是被超越。任何排在它之下的新变量都只能被盖掉,这是第一版的结构性缺陷,不是漏了几个 unset 点。
  • 文件形状而非目录形状——dirname + 猜 bots.json 猜不回 /srv/fleet-a.json,复审要求的「支持任意文件名」由此天然满足。

配套关掉另三个泄漏面:

  • BOTS_CONFIG 加入 BOTMUX_INJECTED_ENV_KEYS。这一个 list 同时驱动三条通路(PANE_ENV_UNSET_KEYSTMUX_CLIENT_STRIP_KEYS 都由它派生):pane 注入、tmux client env 剥离、pane wrapper 的 unset。所以复审列的「tmux 保留 ambient 值 / wrapper unset 列表不含它」两条一并闭合。
  • BOTS_CONFIG 列入 sanitizePerBotEnv reserved keys。它不在 BOTMUX 前缀覆盖范围内必须显式加——否则 bot 自己的 env 就能重定向定义自己的那个 registry,也就是第一版 JSDoc 承诺与实现不自洽的那处。
  • 无可用路径时 delete 而非留继承值,并加 existsSync fail-closed 守卫:core-only 合成会把 loadedConfigPath 钉成默认路径却从未读过它,而 BOTS_CONFIG 指向不存在的文件在 loader 里是硬 throw,钉一个幽灵比不钉更差。

对外契约那条选了「缩窄」而不是「接全」

因为复现过程测出一个决定性事实:os.homedir() 本来就跟随 $HOME。所以 cli.tsCONFIG_DIR/DATA_DIR/PM2_HOME/BOTS_JSON_FILE、dashboard 写路径、setup 已经跟着 HOME 走——HOME=<fleet> botmux setup list --json 零改动就能读到 fleet registry。

这让「把生命周期接到新变量」的动机前提消失了:真正分叉的只有 daemon spawn 的 CLI 子进程,因为它是唯一不继承 HOME 的那个。若保留公开变量却只 govern registry、不 govern data dir / pm2 home / dashboard 写路径,会造出半迁移部署和第二个真相源。因此定性为 daemon→child 的内部传播修复,复用既有 BOTS_CONFIG 契约,不增加公开表面积。

P3 参数形状

resolveBotmuxConfigDir(process.env) 把 env map 当 options bag 传,类型上合法ProcessEnv 结构满足 {env?, homeDir?}),所以 tsc 从不报错;实测只要环境里存在小写 env= 就会忽略正确值,小写 homeDir= 还能劫持兜底。新签名 resolveChildBotsConfig(loadedPath, {exists}) 不再吃 env。补的是真实调用点的 source lock——这类错误在 resolver 单测里永远测不出来,错的是调用点不是 resolver。

验证

test/config-dir.test.ts 17 用例,逐个做过变异测试确认有牙:

变异 被杀用例数
从注入 list 删掉 BOTS_CONFIG 3
解除 per-bot reserved 1
破坏 existsSync 守卫 1
去掉 else delete 分支(= 回到第一版行为) 1

全量 pnpm test12954 passed / 15 failed / 35 skipped。那 15 个用同 head 的 baseline worktree 对照证明是既有失败(13 个同名同文件复现,2 个隔离运行通过属 load-order flake,末 1 个 baseline 隔离同样失败),签名全是 timeout / 真 CLI spawn 预算。零回归。

pnpm build / tsc --noEmit 干净。

已知限制(已写进 PR 描述)

  • adopt 与已存活 pane 的 reattach 改不了既有进程的 env,升级后旧 pane 不自愈,需重启该 CLI 会话。
  • riff 后端走远端 env-credential、不读本机 registry,此 key 对它无实际作用。
  • 复审确认的「默认单树部署零回归」仍成立:未走 daemon spawn 的普通进程解析结果就是原来的 $HOME/.botmux/bots.json

标题与描述已同步重写(原标题宣传的变量已不存在)。

@Ghost-LZW

Copy link
Copy Markdown
Contributor Author

已按第二轮复审迭代:0bde85cfbd0d6350

两个新 blocking 确认全部成立,已修。方向(删公开变量 + 钉 daemon 实际加载的精确文件)按复审意见保留。

Blocking 1 — existsSync 守卫换成显式 provenance

复审的判断是对的:存在性与 provenance 是互相独立的两件事,所以那个守卫在两个方向上都错。

新增 BotsConfigProvenance = 'loaded' | 'synthetic',在 registry 每一处设置 loadedConfigPath 的地方同时记录,经 forkWorker 冻进 worker init message,再由 spawnCli 消费:

  • 'loaded' → 无条件 pin。 文件后来消失也保留 pin,让 child 在 loader 里硬失败(错误信息里明确写 refusing to fall back to a different registry,并提示该文件是 daemon 加载的那份、要么恢复要么重启 daemon)。多 fleet 下"优雅降级"等于换一棵 registry,这正是要避免的。
  • 'synthetic'(core-only,从未 parse)→ 显式 omit + delete 继承值。 没有权威可传播。

fs-policy.ts 消费端已核对:loadedBotsConfigPath值与语义都没有变(core-only 仍然钉 in-root 默认路径,buildFsPolicy 需要的正是它),只是旁边多了一个字段。该文件零改动,5 个消费端测试文件 242 tests 通过。

补的回归测试就是复审要求的那条:真实 loaded path 已消失 + child HOME 有 foreign bots.json → 保留 missing pin 并报错,绝不加载 foreign。并配了阳性对照证明同一个 child 在什么都没钉时确实会加载 foreign registry——否则这条 fail-closed 断言可能是空的。另加一条 source lock,断言每个设置 loadedConfigPath 的地方都记录 provenance,防止将来新增赋值点静默漏掉。

Blocking 2 — 选 (i):os.homedir() 单一语义

回归定性确认:master 的 bot-registry 用裸 homedir()、与 cli.ts 一致,是本 PR 改成 HOME-first 才分叉的。现已回到 os.homedir(),与 cli.tsCONFIG_DIR/DATA_DIR/PM2_HOME 及 dashboard 写路径同源;自定义 home 只走 homeDir 测试缝,绝不读 env。补了 win32 双变量不同值的单测。

顺带修掉同一行上一个与平台无关的缺陷:?? 是 nullish,HOME=''(被剥干净的 service 环境里真实存在)会被当作有效值,join('', '.botmux') 产出相对、依赖 cwd 的 .botmux

data-dir.ts 的调查结论

复审问得对:core/data-dir.ts 那个被 mirror 的样板确实是同款 latent win32 bug,没有可辩护的理由。但暴露面窄得多——cli.ts:588/627 显式注入 SESSION_DATA_DIR=DATA_DIR,优先级高于那个 resolver,PM2 管理的路径因此掩盖了分叉。故意留在本 PR 范围外core/botmux-wrapper.ts:33 形状相同,且 test/data-dir.test.ts 目前是 env 驱动的,一起改需要独立一次改动。建议单独 issue,不在本 PR 扩范围。

测试(含复审指出的两个覆盖缺口)

test/config-dir.test.ts25 个用例,vanished-path 与 win32 双变量真分叉都已覆盖。

7 个变异体全部被杀,其中 M2、M3 分别精确重放本轮两个 bug(provenance 闸门→existsSync 探测;home 规则→HOME-first):

变异 结果
M1 provenance 闸门移除 KILLED
M2 provenance 闸门换成 existsSync 探测(round-2 bug 1) KILLED
M3 home 回到 HOME-first(round-2 bug 2) KILLED
M4 core-only 把 placeholder 误标 'loaded' KILLED
M5 消失的 pin 降级而不 throw KILLED
M6 worker 回到存在性探测 KILLED
M7 worker-pool 不冻结 provenance KILLED

全量(串行独占):12960 passed | 3 failed | 49 skipped失败文件集是未改动 PR head 的严格子集,零新增

一个 tsc 看不见的坑,值得记

新增 getLoadedConfigProvenance() 导出后,用完整 factory(不带 importOriginal)mock bot-registry 且实际求值到该缺失导出的测试,vitest 会硬报错。实测只有 session-lifecycle-start.test.ts 真正走到(50 用例全灭),已补。mock 形状不参与类型检查,所以这类改动只能靠全量跑发现。

另有两处诚实注记已写进 PR 描述:一次 baseline/fix 并发跑导致 baseline 被 CPU 竞争膨胀(测量扰动了被测对象);以及 3 个文件的差异一度被误判为 flake,实为 baseline 树缺 dist/,补跑构建后即通过。

标题与描述已按新方案整体重写。

lanzongwei.lan added 4 commits August 11, 2026 12:32
NOTE (added while rebasing onto master): this commit introduced a public
BOTMUX_CONFIG_DIR env var. Review converged away from that design and the LATER
commits in this same series DELETE the variable entirely, pinning the exact
bots.json the daemon parsed onto children via BOTS_CONFIG instead. What survives
from this commit is core/config-dir.ts as the single place that answers "where
does botmux keep bots.json" plus the bot-registry routing. The original message
is kept below unedited so the review evolution stays readable; read it as the
first step, not as the net result of the PR.

---

The config dir was computed inline as `join(homedir(), '.botmux')`, tying the
location of bots.json to the value of HOME. That breaks any deployment where the
daemon's HOME differs from the HOME its spawned CLI children see.

Concretely, running a second fleet via `HOME=~/alt botmux start` makes the daemon
load `~/alt/.botmux/bots.json`, but the daemon injects only cwd and the BOTMUX_*
family into children — never HOME. The child re-derives `homedir()/.botmux`,
does not find the bot it is running as, and every `botmux send` / `botmux history`
from inside that session fails with `Bot not registered: <appId>`.

Injecting HOME into children is not a viable fix: HOME also anchors the child
CLI's own config discovery (Claude Code falls back to `$HOME/.claude` when
CLAUDE_CONFIG_DIR is unset), so overriding it silently relocates that agent's
skills and settings. A dedicated variable decouples "where botmux keeps
bots.json" from "who the OS user is".

- add core/config-dir.ts: `resolveBotmuxConfigDir()`, precedence
  `BOTMUX_CONFIG_DIR` (absolute only) > `$HOME/.botmux`, mirroring the shape of
  the existing `resolveBotmuxDataDir()`. A relative override is ignored because
  daemon, forked worker and pane child do not share one cwd.
- route the three bots.json resolvers in bot-registry.ts through it.
- add BOTMUX_CONFIG_DIR to BOTMUX_INJECTED_ENV_KEYS so the tmux pane path gets
  it too, not just direct spawn; the `BOTMUX` prefix rule already reserves it
  from per-bot `env`, so a bot cannot redirect the registry that defines it.
- worker.ts pins it on childEnv next to BOTMUX_LARK_APP_ID, always (not only when
  it diverges) so the child never re-derives a root from its own HOME.

Default behaviour is unchanged: with BOTMUX_CONFIG_DIR unset the resolver returns
`$HOME/.botmux` exactly as before.

test/config-dir.test.ts covers the precedence, the relative/blank rejection, the
USERPROFILE fallback, both plumbing points, and a regression asserting that a
daemon under a non-default HOME and its child agree on one config dir.
…BOTS_CONFIG

双 reviewer 在 head 2238977 上实测出三个问题,本 commit 三个一起收口。

## Blocking 1:更高优先级的 BOTS_CONFIG 仍能盖掉新变量

registry 的真实优先级是 `BOTS_CONFIG` > `<config dir>/bots.json`。上一版只钉
`BOTMUX_CONFIG_DIR`,排在 `BOTS_CONFIG` 之下,于是共享 tmux server 的 global env
里一个陈旧 `BOTS_CONFIG` 就能把子进程指向别的 fleet —— 实测 `HOME=不存在 +
BOTMUX_CONFIG_DIR=correct/ + BOTS_CONFIG=stale/other.json` 下 `loadBotConfigs()`
返回 stale registry。而且 PR 自己的 JSDoc 写着「a bot must not be able to redirect
the registry that defines it」,但 `sanitizePerBotEnv({BOTS_CONFIG})` 原样放行,
承诺内部不自洽。

改法不是再加一个变量,而是取消那个变量:daemon 把它**实际 parse 的那个文件**
(`getLoadedConfigPath()`,已经为 sandbox fs-policy 冻进 `loadedBotsConfigPath`)
直接钉进子进程的 `BOTS_CONFIG`。这样只有一个权威,且它就在优先级链顶端,陈旧值
被替换而不是被超越。因为钉的是文件而不是目录,reviewer 要求的 (a)「支持任意文件名」
天然满足 —— dirname + 固定 `bots.json` 猜不回 `/srv/fleet-a.json`。

配套关掉另外三个泄漏面((b)(c)):
- `BOTS_CONFIG` 加入 `BOTMUX_INJECTED_ENV_KEYS`。这一个 list 同时驱动三件事:
  pane 注入(否则只修好 pty backend)、tmux client env 剥离(否则 botmux 会把
  fleet 的 registry 路径播进共享 server 的 global env)、pane wrapper 的 unset
  (否则 co-tenant server global 里的陈旧值仍会盖过钉入值)。
- `BOTS_CONFIG` 列入 `sanitizePerBotEnv` 的 reserved keys —— 它不在 `BOTMUX`
  前缀覆盖范围内,必须显式加。
- `resolveChildBotsConfig` 无可用路径时返回 null,调用方 **delete** 而不是留着
  继承值;否则 core-only 合成场景会把子进程导向那个陈旧路径命名的 registry。

## Blocking 2:对外变量契约没接 cli.ts 生命周期 —— 选 (B),且是最窄的 (B)

实测确认 `BOTMUX_CONFIG_DIR` 根本不 govern start/setup/dashboard(`HOME=<空> +
BOTMUX_CONFIG_DIR=<有 bots.json>` 跑 `cli.js start` 报「未找到配置文件」)。

选 (B) 而非 (A),理由是测出来的一个关键事实:**`os.homedir()` 本来就跟随 `$HOME`**。
所以 `cli.ts` 的 `CONFIG_DIR`/`DATA_DIR`/`PM2_HOME`/`BOTS_JSON_FILE`、dashboard 写
路径、setup 全都已经跟着 HOME 走 —— `HOME=<fleet> botmux setup list` 零改动就能读到
fleet registry。真正分叉的只有 daemon spawn 的 CLI 子进程,因为它是唯一不继承 HOME
的那个,这也正是 PR JSDoc 描述的场景。

(A) 会引入一个 dir 形状的公开变量去管 registry,却管不了 data dir / pm2 home /
dashboard 写路径,造出「半迁移」部署和第二个真相源;(B) 的窄口径是干脆不加公开变量:
这是一个 daemon→child 的内部传播修复,复用既有的 `BOTS_CONFIG` 契约。因此
`BOTMUX_CONFIG_DIR` 整个删除,`resolveBotmuxConfigDir()` 退回纯粹的
`$HOME/.botmux`,新增 `resolveBotsConfigFile()` 作为 registry 路径的单一解析口
(`bot-registry` 的三处调用点统一走它,不再各自内联 env + 默认值)。

## P3:参数形状错

`resolveBotmuxConfigDir(process.env)` 把 env map 当 options bag 传(类型上合法,
因为 ProcessEnv 结构上满足 `{env?, homeDir?}`),实测只要环境里存在小写 `env=`
就会忽略正确值回落 `$HOME/.botmux`,小写 `homeDir=` 还能劫持 HOME 兜底。
新签名 `resolveChildBotsConfig(loadedPath, {exists})` 不再吃 env,形状错不了。
按要求补的是**真实 worker-call 路径**的测试(source lock),因为这类错误在
resolver 单测里永远测不出来 —— 错的是调用点,不是 resolver。

## 一个额外的 fail-closed

`{ exists: existsSync }` 守卫:core-only 合成会把 `loadedConfigPath` 钉成默认
`~/.botmux/bots.json` 却从未读过它,该文件可能不存在。`BOTS_CONFIG` 指向不存在的
文件在 loader 里是硬 throw,而不钉时默认路径是优雅降级 —— 钉一个幽灵比不钉更差。
read-isolation 下仍能正确钉:Seatbelt 拒内容读但放行 metadata 读,文件在这里
「存在」,随后由 loader 的 EPERM+underReadIsolation 分支接管。

## 验证

- `pnpm build` / `tsc --noEmit` 干净。
- `test/config-dir.test.ts` 重写为 17 个用例,**逐个做过变异测试**:删注入 list 里的
  key(杀 3)、解除 per-bot reserved(杀 1)、破坏 existsSync 守卫(杀 1)、去掉
  else delete 分支即回到原行为(杀 1)。四个变异体全部被杀,测试有牙。
- 全量 `pnpm test`:12954 passed / 15 failed。这 15 个已用同 PR head 的 baseline
  worktree 对照证明是既有失败(timeout / 真 CLI spawn 预算),本次零回归。

## 已知限制

`adopt` 与已存活 pane 的 reattach 改不了既有进程的 env,升级后旧 pane 不自愈,
需重启该 CLI 会话。riff 后端走远端 env-credential、不读本机 registry,此 key 对它
无实际作用。
双 reviewer 在 head 0bde85c 上实测出两个新 blocking,本 commit 两个一起收口。
上一轮收敛的方向(删掉公开变量 BOTMUX_CONFIG_DIR + 钉 daemon 实际 parse 的那个
文件)不变,两位 reviewer 都明确认可。

上一版用 `{ exists: existsSync }` 守卫决定要不要钉。病根是它把两件独立的事混成
一件:**provenance(这是不是 daemon 真正解析过的那个文件)** 与 **此刻是否存在**。
因此它在两个方向上都错,进程级 probe 双向复现:

- daemon 真实 loaded 的 config 在启动后被删除/卸载/改权限 -> existsSync=false ->
  pin 被丢弃 -> 子进程按自己的 HOME 回落 `~/.botmux/bots.json`。多 fleet 非默认
  HOME 下这就是**另一棵 foreign registry**:同 appId 可能拿到另一套 secret 与
  另一套 oncall 路由,而且子进程是**成功**而不是失败。实测 loadedIds=[cli_FOREIGN]。
- 反方向:core-only 合成把默认 `<configdir>/bots.json` 钉成 loadedConfigPath 却
  **从未解析它**;只要宿主该文件恰好存在,existsSync=true 就把这个从未加载过的
  文件钉给子进程。实测 loadedIds=[cli_NEVER_PARSED_BY_DAEMON]。

改法是**让 provenance 成为显式携带的事实**,而不是靠存在性猜:

- 新增 `BotsConfigProvenance = 'loaded' | 'synthetic'`(core/config-dir.ts)。
- bot-registry 在每个设置 `loadedConfigPath` 的地点同时记录 provenance:
  `resolveBotConfigPath()` 的两条真实解析路径记 'loaded',
  `maybeSynthesizeCoreOnlyConfig()` 记 'synthetic'。新增
  `getLoadedConfigProvenance()`,`__testOnly_resetBotRegistry` 一并重置。
- worker-pool 把 provenance 与 path 一起冻进 worker init message
  (`loadedBotsConfigProvenance`),worker 的 spawnCli 据此决定:
  'loaded' -> **无条件钉**(即使文件已消失);'synthetic' 或缺失 -> delete。
- 真实 loaded path 已消失时不再换权威:loader 对缺失的 BOTS_CONFIG **硬报错**,
  错误信息显式写明 refusing to fall back to a different registry。丢文件是
  operator 可见的故障;背着人换 registry 更糟。

`resolveChildBotsConfig` 签名随之从 `(path, {exists})` 变成 `(path, provenance)`,
不再碰 fs,也就不可能再用存在性代替 provenance。

**fs-policy 消费端已核对未被破坏**:`loadedBotsConfigPath` 的**值与语义都没动**
(core-only 仍然钉默认 in-root 路径,正是 buildFsPolicy 需要的 authority root),
本 commit 只是**新增一个并列字段**。fs-policy.ts 零改动,
fs-policy/api-only-wiring/bot-registry/data-dir/config-dir 五个文件 242 tests 全绿。

`src/cli.ts:207` 是 `join(homedir(), '.botmux')`,而上一版把 config-dir 改成了
`HOME > USERPROFILE > homedir()`。按 Node 平台契约
(https://nodejs.org/api/os.html#oshomedir):POSIX 的 homedir() 跟 $HOME;
**win32 的 homedir() 跟 USERPROFILE,不以 HOME 为先**。于是 win32 上只要 HOME 与
USERPROFILE 同时存在且不同(Git-for-Windows / MSYS shell 会设 HOME),
setup/start/PM2/dashboard 看 `%USERPROFILE%\.botmux`、daemon 的 registry 看
`%HOME%\.botmux`——**正好重造本 PR 要修的那个分叉**。git 实核 master 的
bot-registry 三处(1935/2076/2093)都是裸 homedir()、与 cli.ts 一致,所以这是
**本 PR 引入的回归**。仓库有 win32 PM2 / Task Scheduler / .cmd wrapper 支持。

采纳 reviewer 的 (i):**生产默认继续以 homedir() 为唯一语义,自定义 home 只走
homeDir seam**。理由是 (ii) 需要手写平台分支去复刻 Node 已经实现的规则,多一处
会漂移的真相;而 homedir() 天然与 cli.ts 一致。`options.env` 收窄为**只**用于
读 BOTS_CONFIG,不再参与 home 推导。

顺带修掉同一行的第二个、平台无关的缺陷:`??` 是 nullish,`HOME=''`(stripped
service env 里确实出现)会被当成真值,`join('', '.botmux')` 产出**相对路径**
`.botmux`,即一个随 cwd 漂移的 registry。走 homedir() 后 POSIX 下空 HOME 会
落到 getpwuid()。

`resolveBotmuxDataDir` 的 `effectiveHome` 也是 `HOME ?? USERPROFILE ?? homedir()`
(本 PR 未触碰该文件;该写法来自 80175a1, 2026-07-11)。结论:**同款 latent
win32 bug 成立,没有可辩护的理由**,但与本 PR 的分叉**不直接耦合**,故不在本 PR
顺手改,建议单独 issue:

- 它与 cli.ts 的 `DATA_DIR = join(CONFIG_DIR, 'data')` 在 win32 上会同样分叉。
- 但实际暴露面窄得多:cli.ts 起 daemon / dashboard 时**显式**把
  `SESSION_DATA_DIR=DATA_DIR` 注入(cli.ts:588 / :627),而 SESSION_DATA_DIR 是
  resolveBotmuxDataDir 的最高优先级,所以 PM2 托管路径下 env 先赢、分叉被掩盖。
  裸 shell 的 CLI 调用才会走到 breadcrumb/默认分支。
- 修它需要连带核对 `core/botmux-wrapper.ts:33` 同款写法与若干 data-dir 单测
  (test/data-dir.test.ts 目前正是用 `env.HOME` 驱动的),属于独立整理。

`test/config-dir.test.ts` 从 17 扩到 25 个用例,两个 blocking 各自补上
reviewer 明确指出漏掉的回归:

- 「HOME 和 USERPROFILE 都在且不同」的真分叉(此前只测了 HOME 缺失的回落)。
- vanished-path 不再 fail-open:既有 resolver 级用例,也有**真实子进程端到端**
  用例——真实 loaded path 已消失 + child HOME 有 foreign bots.json 时,必须保留
  那个 missing pin 并让加载**报错**,且断言错误里不含 foreign registry 的 appId。
  同组还有一个**阳性对照**:同一个子进程在不钉 BOTS_CONFIG 时确实会加载
  foreign registry(证明夹具真实可达,而不是因为子进程什么都加载不了才通过)。
- 相对路径守卫、provenance 缺失时 fail closed、以及一条源锁断言 cli.ts 与
  registry 共享同一个 home 语义。

**变异测试(阳性对照):7 个变异体全部被杀,0 存活。** 包括「把 provenance 闸门
换回 existsSync 探测」(即本轮 blocking 1 的原病灶)、「home 规则退回 HOME-first」
(blocking 2 的 win32 分叉)、「core-only 把占位符标成 loaded」、「loader 在 pin
缺失时降级到默认 registry」、「worker 从存在性反推 provenance」、
「worker-pool 不冻结 provenance」。每个变异体都指名杀掉一个具体用例。

另外修了 `test/api-only-mode-wiring.test.ts` 一处过紧的源锁:它整行 pin 了
bot-registry 的 import 字符串,任何无关的新增符号都会让它失败;改为逐符号断言,
保留原意(provenance 必须与 path 一起被冻结)。

`pnpm build` / `tsc --noEmit` 干净。
上一个 commit 给 bot-registry 新增了 `getLoadedConfigProvenance()` 导出,而
worker-pool 的 forkWorker 会调用它。若某个测试用**完整 factory**(不带
importOriginal)mock 了 `../src/bot-registry.js`,vitest 会对缺失的导出**硬报错**:

  Error: [vitest] No "getLoadedConfigProvenance" export is defined on the
  "../src/bot-registry.js" mock. Did you forget to return it from "vi.mock"?

全量跑实测只有真正走到该调用路径的 mock 会炸:`session-lifecycle-start.test.ts`
(50 个用例全灭)。仓库里有 ~50 个文件 full-factory mock 了 bot-registry,但其余
都没有触达 forkWorker 的这一行,故不需要逐个补。

本 commit 给两处补上该导出:
- `session-lifecycle-start.test.ts`:返回 'loaded',与它已有的
  `getLoadedConfigPath: () => '/home/u/.botmux/bots.json'` 对应(该 mock 表达的是
  「daemon 真的解析过这个文件」,正是 forkWorker 要冻结进 init message 的东西)。
- `command-handler.test.ts`:跟随它已有的 `() => process.env.BOTS_CONFIG` 语义——
  有路径时为 'loaded',无路径时 undefined。这里是预防性补齐(该文件在全量跑中并未
  因此失败),但让 mock 与真实模块的契约保持一致,避免下次有人扩用例时踩到。

补丁后 `session-lifecycle-start` + `command-handler` 两文件 303 tests 全绿。

这个坑本身值得记一句:**新增一个被生产代码调用的导出,会让所有 full-factory mock
在运行时炸掉,而 tsc 完全看不见**(mock 的形状不参与类型检查)。所以这类改动的验证
必须靠全量跑,不能只看 typecheck + 直接相关的单测。
@Ghost-LZW
Ghost-LZW force-pushed the feat/botmux-config-dir branch from bd0d635 to b27374d Compare August 11, 2026 04:49
@Ghost-LZW

Copy link
Copy Markdown
Contributor Author

已 rebase 到最新 master:bd0d6350b27374d9

复审指出的合并阻塞已清除。mergeableCONFLICTING 变为 MERGEABLE,分支现在落后 master 0 commit(origin/master 426b8f7a 已是本分支的祖先)。

选 rebase 而非 merge:单一特性、只有 4 个 commit,保持线性演进更便于复审。commit 1–2 干净重放,只有 commit 3 冲突,且恰好是复审预测的那 2 个文件。动手前先打了备份 tag。

两处冲突怎么合的

src/core/worker-pool.ts(import 块) — 保留 master 的 isRiffBackendSessionmanagedTargetsForCliChange(都在 persistent-backend.js 那一行)、withBotTurnMutationscrubWorkflowWorkerEnv,并把本 PR 的 getLoadedConfigProvenance 加进 bot-registry.js 的 import。相对 master 的最终 diff 仅:

-import { getBot, ..., getLoadedConfigPath, resolveUsageDisplay } from '../bot-registry.js';
+import { getBot, ..., getLoadedConfigPath, getLoadedConfigProvenance, resolveUsageDisplay } from '../bot-registry.js';

src/types.tsDaemonToWorker union) — 复审提醒的「不能简单选边」在这里是实打实的:本 PR 那一侧是 master 三个分支的更早基线,选 ours 会直接丢掉 master 的工作。做法是逐字采用 master 的三个分支,只在 loadedBotsConfigPath?: string; 之后插入我们那一个字段。并用程序化断言核实:相对 master 的 delta 恰好只有那一个字段ours.replace(field,'') == master → True),且 master 的新增全部存活——queuedActivationTokenreplyTurnIdatMostOncecodexAppDispatchIdcodexAppGenerationCommits

冲突标记:rgsrc/test/ 零命中,并配阳性对照证明该模式确实能被搜到。git diff --check 干净。

provenance 4 个赋值点护栏

master 没有新增 loadedConfigPath 赋值点(与 PR 前基线相同的 4 处),因此无需回填。rebase 后仍是 1:1 的 4:4:reset→undefined、core-only→'synthetic'、两条真实 parse 路径→'loaded'

验证

  • pnpm build exit 0;npx tsc --noEmit exit 0、零输出。这是 import 块与 union 类型冲突后的第一道闸门。
  • 复审那批定向测试(35 文件:config-dir、bot-registry、api-only、command-handler、session-lifecycle、fs-policy、tmux、zmx、sandbox、botmux-wrapper):998 passed / 12 skipped / 0 failedconfig-dir.test.ts 仍 25 个用例。
  • 全量,串行独占17 failed | 14643 passed | 37 skipped,7 个失败文件。
  • 基线对照:在纯 master 426b8f7a 上新建 worktree,跑了 pnpm install pnpm build(修正上一轮「基线树没有 dist/」的错误),同样串行独占、绝不与 fix 并发。基线是同样这 7 个失败文件——两个方向都零新增,子集成立。6 个不对称的 test 全部落在这 7 个双边都失败的文件内;唯一触及本 PR 代码路径的 worker-ordinary-im-init-concurrency 在两棵树上隔离运行都 3/3 通过,属 load/timeout flake。slash-commands-doc-sync 在纯 master 上也失败,而本 diff 不碰任何 docs/slash 源文件。
  • 未做变异测试:本次解冲突是机械性的(一个 import 符号 + 一个类型字段),未改变任何生产语义——由下面的 tree hash 证明。

commit message

只 reword 了 commit 1:它的 subject 宣传 BOTMUX_CONFIG_DIR,而本 PR 净效果是删掉它(rg 确认最终树零命中;阳性对照:在该 commit 处有 5 处命中)。现为 feat(config): add a single botmux config-dir resolver,正文顶部加了一句 NOTE 说明后续 commit 会删除该变量,原正文在 --- 下逐字保留。

未 squash,4 个 commit 保留。理由:从初版到两轮复审的演进对复审者有价值,且 squash 是 maintainer 合码时的选择,不该由作者预先替他决定。4 个 subject 全 ASCII(用 isascii() 实核,不靠肉眼)。

reword 是纯 message 级的:4 个 commit 的 tree 前后逐字节相同,且 HEAD 的 tree 仍等于被测那棵(4a637992),所以 reword 没有让上面的测试结果失效。

一件需要二位重新动作的事

force-push 是 rebase 固有的,它把二位此前的 approve 一并 dismiss 了(本仓库未配置 CI checks,所以 BLOCKED 现在是 REVIEW_REQUIRED,不是技术问题)。恳请对新 head b27374d9 重新 approve。

data-dir.ts 的 latent win32 问题按共识留独立 follow-up,未在本 PR 扩范围。

三处注释把内部 review 编排语言写进了源码(公开历史规范不只管 commit
message,也管进 git 的注释):

- src/core/config-dir.ts:100  "(verified in review, round 2)"
- src/core/config-dir.ts:153  "Round 2 of review proved why ..."
- test/config-dir.test.ts:325 "Blocking 2: cli.ts owns ..."

三处均改为直述技术事实,论证内容一字未减 —— 该保留的是「为什么 HOME-first
是回归」「为什么存在性探测替代不了 provenance」,而不是这些结论是在第几轮
复审中得到的。

纯注释改动:无生产语义变化,config-dir 25 个用例仍全绿、tsc --noEmit 干净。
其中两处被 source-lock 用例断言的行未受影响。
@Ghost-LZW

Copy link
Copy Markdown
Contributor Author

注释里的过程叙事已清除:b27374d9795de5d0

复审这条抓得对——公开历史规范不只管 commit message,也管进 git 的源码注释。已清除,ff 追加一个纯注释 commit,未 force。

不过实际是 3 处,不是 2 处。我按「净 diff 里的新增行」重新扫了一遍(git diff origin/master --unified=0 -- src/ test/ 只取 + 行),复审列的 2 处之外还有第三处:

位置 原文
src/core/config-dir.ts:100 REGRESSION, not a new feature (verified in review, round 2).
src/core/config-dir.ts:153 Round 2 of review proved why an existsSync probe cannot stand in for this:
test/config-dir.test.ts:325 // Blocking 2: cli.ts owns setup/start/PM2_HOME/dashboard paths via

第三处在测试文件里,两位都没点到。既然规则是「进 git 的注释都算」,测试注释同样进 git,所以一并清了。

三处都只删过程指代、论证内容一字未减:该保留的是「为什么 HOME-first 是回归」「为什么存在性探测替代不了 provenance」,而不是这些结论是在第几轮复审中得到的。

关于 worker-pool.ts 那处 (codex P1):同意复审的 scope 订正,本 PR 不动它。我独立核过——它不在本 PR 的净新增行里,属既有 commit 的内容,顺手改它会把无关改动混进本 PR。

验证

  • config-dir.test.ts25 passed。这一点不是形式检查::100 附近那行正好被一条 source-lock 用例断言,改注释若碰到被断言的代码行会立刻红。
  • npx tsc --noEmit exit 0。
  • 复扫净 diff:review / round N / reviewer / Blocking N: / 复审 全部零命中,并配阳性对照(同一命令下 provenance 命中 48 次)证明搜索本身有效——否则「零命中」可能只是搜索没生效。
  • GitHub 服务端 diff 复核同样零命中。11 文件不变。

mergeable 仍为 MERGEABLE(本次是 ff,未重写历史,所以不会再 dismiss approve)。

合码方式按复审建议 squash + 中文客观 commit message,由 maintainer 在合码时决定,作者不代劳。

@deepcoldy
deepcoldy merged commit 69598e6 into deepcoldy:master Aug 11, 2026
@github-actions

Copy link
Copy Markdown

🚀 Released in v3.13.0

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.

2 participants