fix(config): 非默认 HOME 下 daemon 与 CLI 子进程读到不同 bots.json - #753
Conversation
|
抱歉最近巨忙,忘了跟进(其实agent已经自动reivew了) |
已按复审意见迭代:
|
| 变异 | 被杀用例数 |
|---|---|
从注入 list 删掉 BOTS_CONFIG |
3 |
| 解除 per-bot reserved | 1 |
破坏 existsSync 守卫 |
1 |
去掉 else delete 分支(= 回到第一版行为) |
1 |
全量 pnpm test:12954 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。
标题与描述已同步重写(原标题宣传的变量已不存在)。
已按第二轮复审迭代:
|
| 变异 | 结果 |
|---|---|
| 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/,补跑构建后即通过。
标题与描述已按新方案整体重写。
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 + 直接相关的单测。
bd0d635 to
b27374d
Compare
已 rebase 到最新 master:
|
三处注释把内部 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 用例断言的行未受影响。
注释里的过程叙事已清除:
|
| 位置 | 原文 |
|---|---|
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.ts仍 25 passed。这一点不是形式检查::100附近那行正好被一条 source-lock 用例断言,改注释若碰到被断言的代码行会立刻红。npx tsc --noEmitexit 0。- 复扫净 diff:
review/round N/reviewer/Blocking N:/复审全部零命中,并配阳性对照(同一命令下provenance命中 48 次)证明搜索本身有效——否则「零命中」可能只是搜索没生效。 - GitHub 服务端 diff 复核同样零命中。11 文件不变。
mergeable 仍为 MERGEABLE(本次是 ff,未重写历史,所以不会再 dismiss approve)。
合码方式按复审建议 squash + 中文客观 commit message,由 maintainer 在合码时决定,作者不代劳。
|
🚀 Released in v3.13.0 |
问题
HOME=~/alt botmux start起第二个 fleet 时,daemon 加载的是~/alt/.botmux/bots.json;但 daemon 注入子进程的只有cwd和整套BOTMUX_*,从不注入 HOME。于是子进程按homedir()/.botmux重新解析 registry,找不到自己正在扮演的那个 bot,该会话里所有botmux send/botmux history全部失败:单树部署(绝大多数用户)永远撞不到。
为什么不是「把 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 变量,有两个决定性原因:BOTS_CONFIG><config dir>/bots.json。任何排在它之下的新变量,都能被共享 tmux server global env 里的一个陈旧BOTS_CONFIG悄悄盖掉,把子进程指向别的 fleet。钉顶端是替换陈旧值,而不是试图超越它。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_KEYS与TMUX_CLIENT_STRIP_KEYS都由它派生):pane 注入、tmux client env 剥离、pane wrapper 的 unset。BOTS_CONFIG列入sanitizePerBotEnvreserved keys。它不在BOTMUX前缀覆盖范围内必须显式加——否则 bot 自己的env就能重定向定义自己的那个 registry。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.ts的CONFIG_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)是:$HOME(未设时 getpwuid())。所以HOME=~/alt botmux …零额外代码就能迁移 config dir,手写的 HOME 分支在这里什么也没多买到。%USERPROFILE%,不看HOME。因此 HOME-first 的规则会在 win32 上与
cli.ts分叉:只要HOME与USERPROFILE同时存在且不同(Git-for-Windows / MSYS shell 就会设HOME),setup/start/PM2 写%USERPROFILE%\.botmux、daemon 的 registry 读%HOME%\.botmux——正好重造本 PR 要修的那个 daemon/child 分叉,而仓库支持 win32(PM2 / Task Scheduler /.cmdwrapper)。master 的 bot-registry 用裸homedir()、与cli.ts一致,所以 HOME-first 是回归而不是新特性。手写 env 读取还有一个与平台无关的洞:
??是 nullish,所以HOME=''(在被剥干净的 service 环境里真实存在)会被当成有效值,join('', '.botmux')产出相对、依赖 cwd 的.botmux。homedir()在 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.ts共 25 个用例,含 review 指出的两个覆盖缺口:vanished-path(加载过的文件消失后不得 fail-open)与 win32HOME≠USERPROFILE真分叉。其中 vanished-path 那条配了阳性对照——证明同一个 child 在「什么都没钉」时确实会加载 foreign registry,否则 fail-closed 断言可能是空的。另有一条 source lock 断言「registry 里每一处设置loadedConfigPath的地方都同时记录 provenance」,防止将来新增赋值点时静默漏掉。7 个变异体,全部被杀(阳性对照,确认测试有牙):
existsSync探测(即 round-2 的 bug)'loaded'pnpm buildexit 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 会话。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。