Skip to content

feat(companion): 实现封闭本机管理协议 - #1271

Merged
deepcoldy merged 7 commits into
deepcoldy:masterfrom
Phoobobo:feat/companion-control-api
Sep 7, 2026
Merged

feat(companion): 实现封闭本机管理协议#1271
deepcoldy merged 7 commits into
deepcoldy:masterfrom
Phoobobo:feat/companion-control-api

Conversation

@Phoobobo

@Phoobobo Phoobobo commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

关联需求

改动

  • botmux start / restart 新增配对参数 --companion-secret-file <path>--companion-bot <appId>,只允许精确绑定一个启用 sandbox 的 codex/traex 测试 Bot。
  • 在 Dashboard 本机端口增加独立、封闭、loopback-only 的 /__companion/v1 HMAC surface;不授予 Dashboard 管理身份,不代理现有 Dashboard 路由。
  • 固定开放 health、team Role read/write、Runtime read/update;Role 返回受限文本和现有 injectMode,Runtime 仅允许 codex/traecli、受限 model 与 provider/model 对应的 reasoning 闭集。未映射 trigger/result。
  • 专用 secret file 要求 canonical 绝对路径、非软链、普通文件、当前用户 owner、0600、非空;不复用 Dashboard/internal secret,不记录或返回密钥/路径/native ID。
  • HMAC 绑定 timestamp、nonce、method、固定 pathname 和 raw body SHA-256;包含时限、防重放、64 KiB body 上限、10 秒 operation timeout 与 requestId 并发幂等。
  • 补充中英文 API、环境变量、CLI 文档;会话 CLI、bot daemon/worker 不持有 companion startup authority。

固定协议

  • GET /__companion/v1/health
  • GET|PUT /__companion/v1/role
  • GET|PUT /__companion/v1/runtime
  • Headers: X-Botmux-Companion-Timestamp / Nonce / Signature
  • 签名材料:timestamp\nnonce\nMETHOD\nexact-pathname\nsha256(raw-body)

影响面

  • 公共层:CLI lifecycle env、Dashboard HTTP 前门、team-role 与 bot-agent 既有写路径。
  • 所有 companion 路由在普通 Dashboard auth/router 前独立处理;未配置时保持现有行为。普通 Dashboard token/cookie、IM、会话类型与 CLI backend 行为不变。
  • Runtime 更新复用绑定 bot daemon 的既有 /api/bot-agent 校验/落盘链路;Role 复用现有 team-role 文件与 metadata 语义。

验证

  • bun run build:通过。
  • bunx tsc --noEmit --pretty false:通过。
  • bunx vitest run --project unit test/companion-api.test.ts test/companion-startup-config.test.ts test/companion-startup-options.test.ts test/child-env.test.ts test/daemon-lifecycle-env.test.ts test/index-dashboard-entry.test.ts:99 passed。
  • git diff --check:通过。

未执行 daemon restart、部署或真实凭据探测。

@Phoobobo
Phoobobo requested a review from deepcoldy as a code owner September 6, 2026 10:14
@Phoobobo

Phoobobo commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

CI 首轮暴露 3 个源码契约测试依赖 cmdStart() / cmdRestart() 的既有无参函数签名;已保持该签名不变并从函数内读取 argv。相关 30 个回归测试现已通过,新 CI 已触发。

@Phoobobo
Phoobobo force-pushed the feat/companion-control-api branch 2 times, most recently from 45cd91d to 3fe564f Compare September 6, 2026 10:37
@Phoobobo

Phoobobo commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

上一轮仅 bun-test 的既有 plugin-card-action-gateway.integration.test.ts 单例失败(预期 1 次调用、收到 0);同一 head 的 Node 全量 build/test 已通过,且本地 bun test test/plugin-card-action-gateway.integration.test.ts 为 3/3 通过,判定为时序 flaky。因无仓库 rerun 权限,已通过更新 head 重新触发完整 CI,未改测试阈值或 CI。

@Phoobobo
Phoobobo force-pushed the feat/companion-control-api branch from 3fe564f to 54f0db9 Compare September 6, 2026 10:50
@Phoobobo

Phoobobo commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

最新 CI 已全部通过:build、bun-test、bun-binary、bun-binary-musl。PR 当前状态为 OPEN / REVIEW_REQUIRED,等待评审;未执行 daemon restart 或部署。

@deepcoldy

Copy link
Copy Markdown
Owner

感谢这个 PR — 整体设计思路很清晰:不复用 Dashboard token/HMAC、路由写死不代理、Bot 在启动期钉死不让请求方选、密钥文件做了严格的 owner/权限/非软链校验,这几条边界收得很干净。文档中英文同步、REDACTED_CHILD_ENV_KEYS 也补上了。

下面是评审中实测复现的几个问题,供你参考(每条都跑了探针,不是读码推测):

建议合入前修复

1. 幂等表 writes 无淘汰,且把失败永久缓存(src/dashboard/companion-api.ts:116,190-198

同文件的 nonces 有 TTL 清理,writes 没有任何 delete/clear。实测三点:

  • 500 个不同 requestId 全部驻留,进程生命周期内只增不减;
  • 失败也被缓存writeRole 抛错返回 500 后,即使后端恢复正常,同一 requestId 重试仍然返回 500,写入永远落不了地。而按 requestId 重试恰恰是幂等协议鼓励调用方做的动作;
  • 超时分叉bounded() 到 10s 只是不再等待,并没有中止底层操作。实测操作在超时后仍然真正执行成功,但客户端已收到 {"error":"timeout"},重试又永远拿到缓存的 500 —— 系统进入「客户端认为失败、实际已生效且无法重试」的不一致状态。PUT /runtimeproxyToDaemon 的 HTTP 调用,是最容易触发这条的路径。

建议:只缓存成功结果(失败即从 Map 移除以允许重试),并给条目加 TTL 或容量上限。

2. 绑定 Bot 的配置漂移会导致整个 Dashboard 进程无法启动(src/dashboard.ts:3418-3430

IIFE 在模块顶层调用了 requireBoundBot(),而 src/index-dashboard.ts:108-111await import('./dashboard.js') 包在 try/catch 里,catch 后执行 process.exit(1)

实测三种日常操作都会触发:把绑定 Bot 的 sandbox 关掉、把 cliId 改成 codex/traex 之外的值、或把该 Bot 从 bots.json 删掉 —— 都抛 companion_bound_bot_invalid,然后整个 Dashboard 挂掉,而不只是 companion 这道门不可用。

一个可选的附加能力,通常不应该有让主进程起不来的权力。建议:启动期校验失败时降级为「不挂载 companion 路由 + 打日志告警」(该前缀返回 503/404),把 fail-closed 收敛在这道门内部;每次请求时的 requireBoundBot() 仍然照常拦截。

3. 从会话内执行 restart --companion-* 时参数会被静默丢弃

两个 key 加进了 DAEMON_ENV_KEYS(共享 fleet env),但 resolveDaemonEnvBOTMUX_SESSION_ID 存在时 refreshPersistedEnv=true,此时只读 ~/.botmux/.env、忽略继承的 env

实测:会话内 restart 传入这两个参数 → 密钥文件和 Bot 合法性都完整校验通过 → 随后解析结果两个都是空字符串,companion 静默不启动,且没有任何提示。lease-bound 分支更直接:for (const key of DAEMON_ENV_KEYS) delete env[key] 会在 spawn 前删掉刚写入的两个 key。实际上只有 .env 读取失败走 fallback 时参数才能存活。

这与 src/cli/daemon-lifecycle-env.ts 中新增的注释「start/restart flags override them in inherited env」描述相反。botmux 的 restart 有不少正是从会话内发起的。建议:把这对参数持久化到 ~/.botmux/.env,或将其排除在 refresh 语义之外;无论哪种,参数被丢弃时都应报错而非静默。

非阻断建议

  • isCompanionLoopback 与现有 src/dashboard/daemon-internal-auth.tsisLoopback 实现逐字重复(10 组输入实测行为完全一致),建议直接复用现有实现,避免同一条安全判定维护两份。
  • 两处测试盲区(变异实测确认):把 handler 里的 loopback 判定整段删除、以及让 exactKeys 恒返回 true(允许多余字段),测试均全绿。前者是因为用例直接调用 isCompanionLoopback helper、未经过 handler 入口;后者是缺少「请求体多带一个字段」的用例。建议各补一条。
  • 关于「Requests must originate from loopback」:src/platform/tunnel-client.tsbridgeDataChannel 会把平台隧道流量以 raw TCP 桥接到 127.0.0.1:<dashboardPort>(无路径过滤),实测经此桥接的 /__companion/v1/role 请求会被 loopback 判定放行并正常返回。隧道是 opt-in(需绑定平台)所以不是默认暴露,但在绑定平台的部署下,实际边界只剩 HMAC 密钥。建议在文档中说明这一点。
  • 文档「Writes are idempotent by requestId」建议在修复第 1 条后补充说明失败/超时的语义。

验证情况

bun run build exit 0、tsc --noEmit 干净、companion 相关用例全绿;全量 unit 21609 passed 且无回归(已与干净 origin/master 基线逐条对照确认)。


以上为自动评审的初步意见,可能有理解偏差,最终以维护者审阅结论为准。辛苦了 🙏

@Phoobobo

Phoobobo commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

已处理评审中确认的阻断项,提交:4bb9756003b4c1c419dc9ded5718e9dee9ec4f90

  • 幂等表:成功结果增加 10 分钟 TTL / 1000 条上限;失败与超时结果不持久缓存,允许重试;补充失败重试和额外字段拒绝测试。
  • 配置漂移:绑定 Bot 失效时 Companion 降级为 disabled + warning,不再阻断 Dashboard 启动;请求时仍 fail-closed。
  • 会话/lease restart:Companion flags 优先于 persisted snapshot,并在 lease handoff 保留,避免静默丢弃。
  • 复用现有 isLoopback 判定;文档补充显式 tunnel 的受信传输边界。

本地验证:bun run build;相关测试 121 passed;compiled dist/cli.js start/restart 黑盒测试通过。未重启、未部署。

@Phoobobo

Phoobobo commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

本轮 CI:build、bun-binary、bun-binary-musl 通过;bun-test 仅复现既有 plugin-card-action-gateway.integration.test.ts 时序失败(Expected length 1 / Received 0),与本 PR 文件无关。此前同一用例本地通过且该失败已在前一轮记录。将仅通过保持内容不变的 head 更新重触 CI,不修改测试或 CI 工作流。

@Phoobobo
Phoobobo force-pushed the feat/companion-control-api branch from 4bb9756 to a2dfe1a Compare September 7, 2026 03:03
@Phoobobo

Phoobobo commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

最新 head a2dfe1a3f842c42688f785f64fa193a0474bb2d7 的 scoped fixes 已通过本地 build 与 121 个相关测试;CI build、bun-binary、bun-binary-musl 通过。bun-test 再次仅失败于既有 plugin-card-action-gateway.integration.test.ts 时序断言(Expected length 1 / Received 0),未触及 Companion 代码。未修改 CI/测试阈值;当前 merge blocker 是该 flaky check 与待评审状态。

@Phoobobo

Phoobobo commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

已明确并收紧 producer contract,提交:899e5aa4e4ec62f74d53a957990a060e908dc77b

精确 predicate:--companion-bot 选择的 app id 必须在 bots.json 中精确匹配一条,且该条 sandbox: truecliId: codex|traex;fleet 其它 Bot 不参与全局 exactly-one。缺失、重复或不合格统一返回 companion bot selection must match exactly one isolated codex/traex Bot,不输出 app id、secret 或路径。

安全预置:为专用测试 Bot 配置唯一 larkAppIdsandbox: truecliId: codextraex,与生产 Bot 分离;使用独立 canonical 0600 secret file 后传入两个参数。readIsolation 单独存在及 codex-app 不满足当前闭集。

新增 compiled-dist 黑盒覆盖:relative secret rejection、zero/multiple/nonqualifying selected Bot rejection、qualifying selected Bot acceptance without daemon side effect;并覆盖 start/restart 实际 CLI parser。相关测试 75 passed,build 已通过。未重启/部署。

@deepcoldy

Copy link
Copy Markdown
Owner

感谢快速跟进 —— 复审了 a2dfe1a3f899e5aa4e之前提的三条阻断项都已实测确认真正修好(我用上一轮同一批探针重跑,不是只看代码判断):

1. 幂等表 ✅ TTL + 上限 + 失败/超时结果不再驻留。

  • 之前:后端失败返回 500 后,即使恢复正常,同一 requestId 重试仍是 500、写入永不落地;现在:重试返回 200,写入成功
  • 超时路径同样:之前重试永远拿到缓存的 500,现在返回 200。
  • 「超时那次的副作用仍可能在客户端收到 timeout 之后落地」这一点无法从根上消除(底层操作没有取消通道),你在文档里补的 "the underlying operation must still be treated as asynchronous when a timeout is reported" 正是需要的说明,👍

2. 启动期漂移不再炸进程 ✅ 用真实入口验的:bun src/index-dashboard.ts + 把绑定 Bot 的 sandbox 改成 false → 输出 [WARN] [companion] disabled: companion_bound_bot_invalid,紧接着 [INFO] [dashboard] listening on ...进程正常存活。之前这里是 process.exit(1)

3. 生命周期参数不再被吞 ✅ 两条路径都验过:会话内 restart 现在两个值都保留(之前解析为 "");lease 分支我构造了真实 lease(claimRestartLeaseTobindRestartLeaseTo)后测试,确认删除循环确实执行了WEB_HOST/GOFLAGS 被删)而 companion 两个 key 存活。

另外几条非阻断建议也都闭环了:复用现有 isLoopback、补上「多余字段」的测试覆盖(变异实测由全绿转红)、隧道边界与幂等语义都写进了中英文档。

899e5aa4e 把 dashboard 侧的 .find() 改成 filter() + length === 1 是个真实收紧 —— A/B 实测:构造重复 larkAppId(第一条合规、第二条是非 sandbox 的生产 Bot),旧写法放行、新写法拒绝。CLI 侧一直是 matches.length !== 1,现在两侧口径对齐了。


还剩一处小问题(建议合入前顺手修,1 行)

docs-site/docs/en/companion-api.md:19 有个破句:

Requests must originate from loopback and carry. If an operator explicitly enables the platform tunnel, ...

原句以冒号结尾,用来引出紧随其后的三个 header 列表项(X-Botmux-Companion-Timestamp 等)。插入新句时 carry: 被改成了 carry.,导致下面的列表悬空。中文版同一位置是「并携带以下鉴权信息。」读得通,只有英文版受影响。建议改回冒号,或调整为:

Requests must originate from loopback and carry the headers below. If an operator explicitly enables the platform tunnel, ...

一条非阻断(可留作后续)

handler 里的 loopback 判定仍缺少 handler 级覆盖:把 if (!isCompanionLoopback(req.socket?.remoteAddress)) {...} 整段删掉,test/companion-api.test.ts 仍然 12 全绿。现有用例是直接调用 isCompanionLoopback helper,请求都来自 127.0.0.1,所以走不到那道闸。补一条「非 loopback peer 应得 403」的用例即可(可以注入一个假的 req.socket.remoteAddress)。

验证情况

bun run build exit 0、tsc --noEmit 干净、companion 相关 9 个测试文件 150/150 绿。全量 unit 21802 passed,无回归(已在已构建的干净 origin/master 基线上跑同一批文件逐条对照,红文件集完全一致)。

另外提醒一下:主干在此期间前进了 16 个 commit,其中新增了 GOFLAGSDAEMON_ENV_KEYS(正是你 F3 修法所在的那张表)。我本地基于最新主干 rebase 验证过,两边可以共存互不干扰、tsc 干净,合入前你可能想同步一下基线。


以上为自动评审的初步意见,最终以维护者审阅结论为准。修得很利落,辛苦了 🙏

@Phoobobo
Phoobobo force-pushed the feat/companion-control-api branch from 899e5aa to bccaccb Compare September 7, 2026 09:38
@Phoobobo

Phoobobo commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

按 babysit 要求已将 PR #1271 rebase 到最新 origin/master(7326f93f2),无冲突;Companion producer 代码/协议相对 Devbox 已验证的 899e5aa 未改变。当前 head:bccaccb28ab1ecdea9da51199f3522970e8cf124。本地 bun run build 通过,Companion/lifecycle/maintenance/child-env 相关测试 128 passed;compiled CLI start/restart 黑盒 7 passed。新 CI 已触发(build、bun-test、bun-binary、bun-binary-musl、bun-binary-darwin 均进行中)。未合并、未重启、未部署。

@Phoobobo

Phoobobo commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

已补合码前确认项并推送:0565807b04e9e321b55060756078c14c729230a9。\n\n- 修复英文文档 Requests must originate from loopback and carry the headers below. 破句。\n- 增加 handler 边界的非 loopback peer 403 回归测试。\n- 相关测试 23 passed;bun run build(使用现有临时 yaml 依赖链接)通过;git diff --check 通过。\n- 相对 Devbox 已验证的 899e5aa4,仅增加上述文档与测试覆盖,生产 Companion 协议无变化。\n- 未重启、未部署、未合并。

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

三轮双审通过:3 条阻断项(幂等表缓存失败/启动期漂移炸 dashboard/生命周期参数被吞)已全部修好并实测确认;文档破句与非回环 handler 级用例也已补齐(变异验证:删掉 loopback 闸后新用例转红,证明真承重)。

必需 CI 腿(build / bun-binary / -musl / -darwin)全绿。bun-test 的红是 test/child-env.test.ts 里 node-pty 的 lost-read,该 job 配置为 continue-on-error 且在不含本 PR 任何代码的干净 origin/master 上可复现同一条失败,与本 PR 无关。

@deepcoldy
deepcoldy merged commit 738da7f into deepcoldy:master Sep 7, 2026
4 of 5 checks passed
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown

🚀 Released in v3.19.3

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