Skip to content

feat(dashboard): Dashboard 链接按平台绑定收敛 token,并给模型钉安全提示 - #1241

Merged
deepcoldy merged 5 commits into
masterfrom
wt/dashboard-hint-token
Sep 3, 2026
Merged

feat(dashboard): Dashboard 链接按平台绑定收敛 token,并给模型钉安全提示#1241
deepcoldy merged 5 commits into
masterfrom
wt/dashboard-hint-token

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Sep 3, 2026

Copy link
Copy Markdown
Owner

改了什么

botmux dashboard 的输出会被大模型读进上下文、再顺手转发到飞书群,而链接里的长期 token 等同管理员凭证 —— 一旦进了聊天记录 / 思考过程就是泄漏。三处收敛:

1. 中心平台托管时,主链接不再带 token

走平台子域时身份由平台注入,dashboard/request-identity.ts:119platform-dashboard 身份恒把 presentedToken 压成 undefined。实测(本机已绑平台):

请求形态 结果
cookie=<token>,无 role 头(本机 owner,阳性对照) /api/settings 200
cookie=<token> + X-Botmux-Role: owner 401 + x-botmux-auth-scope: workbench
同上再加 ?t=<token> 仍 401 ⟸ token 被压制
平台边缘 m-<id>.<host>/?t=<token> 浏览器直开 302 到平台 SSO(token 原样进 next=,没绕过任何门)

平台托管时 token 对访问零贡献、只剩泄漏价值。真人 owner 是被平台 SSO 认出来的。

反过来,非平台托管时 token 不能去:那时 http://ip:port/ 只是静态壳,SPA 探 /api/settings 拿 401,而 401 上的登录出口 x-botmux-login-urlbuildPlatformDashboardLoginUrl() 生成、未开远程访问 / 未绑定时返回 undefined ⟹ 登录浮层没有出口,去掉 token 等于把唯一入口堵死。

1b. ⚠️ 自查修掉的缺陷一:判据不能是「有没有远程基址」

第一版用 localUrl !== undefined 当「平台已生效」的等价判据 —— 这是错的remotePublicBase()三个来源,认证方式完全不同:

来源 谁注入身份 token 还需要吗
① 中心平台 platformMachineBaseUrl() 平台反代时注入 + 先过 SSO 不需要(且已被压制成无效)⟹ 该去
② 自建反代 BOTMUX_PUBLIC_URL(nginx 等) 没有人 是唯一凭证
③ Devbox 短链 devboxDashboardBaseUrl()(merlin 隧道) 没有人 是唯一凭证

buildDashboardUrls 对 ①②③ 都会给出 localUrl,所以原判据把 ②③ 一起判成「可以去 token」。实测 ②③(带 X-Forwarded-* 反代头):无凭证 → 401,带 ?t=302;而 ②③ 都拿不到登录出口 ⟹ 对它们去 token 会摘成打不开的死链

1c. ⚠️ 自查修掉的缺陷二:飞书卡片路径也在带 token

daemon.ts:dashboardUrlForReport()两张飞书卡片提供链接:重启报告 DMcore/restart-report.ts)与 CLI 运行时更新提醒core/cli-runtime-update.ts)。它原样带 ?t=卡片是永久聊天记录 —— 可转发、可截图、可搜索,正是本 PR 要堵的泄漏面;前两个 commit 只收敛了终端输出与 botmux bind 回显,我的「影响面」分析漏了这条。

修法:新增 core/dashboard-url.ts:reportDashboardUrls(),平台托管时 ① 摘主链接 ?t=整条不返回 localUrl(那条恒带 token,是给终端里的 owner 当兜底用的,不该主动推进聊天记录)。

发现路径:穷尽「谁会把带 token 的链接交到人/模型手里」这个维度 —— 按 buildDashboardUrls 调用点 + 读 .dashboard-token 的位置两条线各扫一遍,而不是只看自己改过的文件。

1d. 🔴 复审抓出的阻断项 → 判据收归协议层

阻断项(确定性,非窄竞态)platform/bind.ts 曾硬编码 platformHosted: true,理由是「刚 bind 成功必然平台托管」。错的 —— 同文件上方 cmdBind 只在 remoteAccess === undefined 时才写 true,用户显式设过 false 就保持 false(那段注释自己写着「尊重用户选择」)。此时若配了 BOTMUX_PUBLIC_URL / Devbox 短链 ⟹ 硬编码 true 确定性把 token 摘成死链。已用隔离 HOME 复现。

连带被证否的我自己的论证:我此前主张「跨进程判据不一致的有害方向被 localUrl !== undefined 结构性挡死」,前提是「dashboard 没用远程基址就不给 localUrl」。配了反代时 localUrl 有定义,这条挡不住

两个洞同源:判据由调用方各自推断,而只有生成 URL 的 dashboard 进程知道自己用了哪条基址。所以按 review 建议的首选方案改掉:

  • DashboardUrls 新增 platformHosted,由 buildDashboardUrls 如实标注。拆出 platformCentralBaseUrl()(远程访问开 + 已绑定)与 nonPlatformRemoteBase()(反代 / Devbox),判据是 remoteBase !== null && remoteBase === platformBase —— 即「主链接就是平台子域」,而不是「这台机器绑过平台」(后者正是 bind.ts 那个错)。
  • /__cli/* 响应带上该字段;DashboardResult 解析它,只认严格 true(字段缺失=旧版 dashboard、'true'1null 一律 false = fail-safe 保留 token)。
  • 三个调用点全部改为formatDashboardSuccessLines / reportDashboardUrls 去掉形参,删掉 cli.tsdashboardLinkIsPlatformHosted() 与 daemon 侧重复推断(连带清掉两个死 import)。

bind.ts 无从硬编码;跨进程也不可能不一致。这不只是修实例,是去掉产生实例的自由度

2. 本地 ip:port + token 直连链接默认不打印

需要时加显式参数 --i-am-the-owner-show-token-link 取回。该参数故意不进 --help —— 列进 help 等于邀请模型「既然有这个参数那就加上」;它只在命令实际跑完、且确实存在一条被隐藏的本地链接时,在输出末尾提示给看。参数名刻意写成一句「读一眼就知道别随便调」的话。

3. 输出末尾钉一段给 AI 读的安全提示

只发 owner 本人;当前会话若是多人群(交给模型自己判断)不要发带 ?t= 的链接;给别人指路只说「在服务器上运行 botmux dashboard」。钉在 stdout 而非 skill 文档,是因为模型做「要不要发这条链接」这个决定时,手里只有这段输出。

实际输出

https://m-<machineId>.<平台域名>/
工作台: https://m-<machineId>.<平台域名>/#/agent-workbench
本地直连(平台异常时可用): 已隐藏——它带 token,等同管理员密码。需要时加 --i-am-the-owner-show-token-link
⚠️ 安全提示(给 AI 读):以上链接是本机 Dashboard 的**管理员凭证**,不是普通网页链接。
  · 只发给 owner 本人。任何其他人(包括同事、同群成员)都不要发,也不要在回答里复述。
  · 当前会话若是多人群(你自己判断人数与成员构成),一律**不要把带 `?t=` token 的链接发出去**——
    聊天记录会长期留存并可被转发/截图,等同于把管理员权限交出去。
  · 需要给别人指路时,只说「在服务器上运行 `botmux dashboard`」,让对方自己取。

⚠️ 一个容易踩的坑

无凭证形态必须用 hash 路由 /#/agent-workbench,不能用 /workbench 后者不在 decideDashboardAuth 的静态壳白名单里,token-free 访问实测 401(平台身份下同样 401)。照原样保留会给出一条打不开的死链。hash 形态的 fragment 不发给服务端,落到 / 静态壳 200。

影响面

  • 三个终端入口统一走 formatDashboardSuccessLinesbotmux dashboard、start/restart 后的面板提示、botmux bind 绑定成功回显。
  • 飞书卡片链接已一并收敛(见 1c):重启报告 DM 与 CLI 更新提醒卡走 reportDashboardUrls
  • 首行契约不变:第 0 行仍是裸 URL,botmux dashboard | head -1 实跑验证仍可解析为 URL。
  • Desktop 不在范围且必须如此desktop/main/runtime-service.tsresult.url(带 token)、忽略 platformHosted —— 它的 webview 没有平台 SSO 会话,靠 ?t= 种 cookie 才进得去,摘了会打不开自己的 dashboard。
  • /dashboard 卡片的「打开工作台」按钮未改:沿用既有常驻链接机制(core/workbench-link.ts),那是产品 owner 明确推翻过短票方案的决策,改它需要产品先拍 —— 复审也建议另开 issue 评估,不塞进本 PR。
  • 跨平台:纯字符串 / URL 处理,无路径、shell、PTY、编码逻辑。跨 CLI / 跨后端 / 跨会话类型:不涉及 adapters/

版本混搭矩阵(加字段的协议改动,两个方向都要成立)

组合 行为
新 CLI + 旧 dashboard 字段缺失 → 严格 truefalse保留 token
旧 CLI + 新 dashboard 旧 CLI 不认识该字段、也没有摘 token 的逻辑,只原样打印 urlurl 必须始终带 token
新 + 新 /__cli/currentplatformHosted:true,CLI 摘 token ✓
desktop result.url(带 token),必须如此 ✓
bind.ts + 未升级 daemon 旧 dashboard 不回字段 → false → 保留 token ✓

第 2 行正是「协议层如实回原始 URL、由消费端决定摘不摘」这个设计的兑现点:当初若让 dashboard 直接回摘好的 URL,旧 CLI 会确定性拿到死链。该不变量此前只是碰巧成立,第 5 个 commit 用测试钉死。

测试验证

bun run build                            # 绿
针对性 10 个套件                          # 395/395 绿
master 自己新加的 7 个测试文件(rebase 后) # 163/163 绿
全量 unit                                 # 20724 passed / 9 failed

9 个失败全部与本改动无关,用同 commit 的 pristine worktree 坐实:plugin-mcp-sandbox / plugin-registry-sandbox-read / mojo-launcher-env-quarantine 在未改代码上同样红、逐格数量相同(root 身份下 EACCES 断言造不出只读前置)。

变异测试(20 个)

变异 结果
M1 不去 token(退回原行为) 🔴
M2 忽略参数、总是打印本地 token 链接 🔴
M3 删掉安全提示 🔴
M4 无凭证时用 /workbench(401 死链) 🔴
M5 非平台托管也去 token(把 owner 锁在外面) 🔴
M6 参数不在「至多一个 positional」检查前摘掉 🔴(4 例)
M8 清空整个查询串而非只删 t 🔴
M9 根本不删 token 🔴
M10 不可解析时 fail open 🔴
M11 退回「只看 localUrl」的错判据 🔴
M12 只看 platformHosted、不要求 localUrl 自洽 🔴
M13 platformHosted 默认值改 true(fail-open) 🔴
M14 卡片路径原样返回带 token 🔴
M15 卡片摘了 token 但仍带 localUrl(半修) 🔴
M16 卡片忽略 platformHosted 一律摘 🔴
M17 platformHosted 退回宽判据(remoteBase 非 null 就算) 🔴
M18 只看绑定、不看远程访问开关 🔴
M19 线上解析放宽成 truthy('false' 会变 true) 🔴
M20 让协议层直接回摘好的 URL(旧 CLI 死链) 🔴(5 例)
M7 参数名改成宽松前缀匹配 🟢 存活

M7 存活经查是「惰性编辑」而非测试缺口positional 过滤用 ===,任何近似拼写都会留在 positional 里、先被判 invalid 返回,showLocalTokenLink 根本读不到。已用探针从生产入口验证四种近似形态全部 invalid 且不打印 token。

真机实跑(本机已绑平台,做过 live 部署验证)

  • live 部署/__cli/current 响应 keys = url,localUrl,platformHosted、值 true;协议层如实回带 token 的原始 url,由 CLI 决定摘 ⟹ 终端与 restart 提示均无 token
  • 阻断项场景隔离复现remoteAccess=false + 反代 ⟹ 回 platformHosted=false ⟹ token 正确保留(修复前确定性死链)
  • 加参数能取回本地链接 + 警告;--help 不含该参数;dashboard current <参数> 不误判 invalid;近似拼写被拒且不打印 token

已 rebase 到 ea65f9d26#1147 之后)

合码前重新 fetch 发现 master 漂了两个 commit(#1147#1168),其中 #1147 动了 src/cli.tssrc/dashboard.ts,与本 PR 有真实交集,故 rebase。

⚠️ 「rebase 无冲突」不等于「语义没漂」,所以额外自证等价性:

  1. 两个 base 上分别取本 PR 的净 diff,剥掉 hunk 行号后逐字相同
  2. 更强的判据 —— 直接比两个 revision 的文件内容 shadashboard-command.ts / dashboard-url.ts / dashboard-endpoint.ts / bind.ts / daemon.ts 全同;唯一不同的 dashboard.ts 单独核对确认差异全来自 fix(fleet): 重启时从 .env 重载终端监听地址 #1147(本 PR 在该文件上的净改动同样逐字相同)。
  3. 重跑针对性 10 套件 395/395;并把 master 自己新加的 7 个测试文件在 rebase 后的分支上跑了 163/163,确认没把 fix(fleet): 重启时从 .env 重载终端监听地址 #1147 弄坏。
  4. 重新 live 部署(base 变化后旧的 live 验证已过期):协议字段与值、url 仍带 token、CLI 端到端首行无 token,全部复验通过。

合并顺序提示


🤖 Generated with Claude Code

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

复审结论(3-commit head 251854c82

三个 commit 都过了一遍,核心前提与大部分风险点独立验证通过。第三个 commit(daemon 卡片)修得对、端到端实测通过。但 bind.ts 仍有一个确定性死链没修(你自己设的 bar:「能构造出漏掉的组合就改协议层」——这个就是)。建议修完再合。

独立验证通过(没只看结论)

  1. 核心前提:平台身份下 token 确实被压制
    本机(已绑平台)实跑 curl:

    • cookie(活跃 token)不带 role → /api/settings 200(legacy owner)
    • cookie + X-Botmux-Role: owner401 + x-botmux-auth-scope: workbench
    • cookie + role + ?t=<token>仍 401
      ⟹ 平台托管主链接摘 token 成立。
  2. 未绑定保留 token 的推理链
    未绑定 ⟹ buildPlatformDashboardLoginUrl() 返回 undefined ⟹ 401 无登录出口 ⟹ token 是唯一入口。local-only 分支保留正确,没有别的入口能救回来。

  3. hash 形态没有误判死链
    实跑:/workbench 平台身份 401/ 平台身份 200/workbench legacy cookie 302/#/agent-workbench/api/sessions 平台身份 200(工作台可用)。⟹ /#/agent-workbench 是无凭证正确形态。

  4. M7「惰性编辑」自我开脱成立
    positional 过滤用 !==,近似拼写留在 positional 里先被 raw 判 invalid,showLocalTokenLink(变异改宽松的那个)读不到。安全性被结构双重钉住,不是找台阶。

  5. 第三个 commit(daemon 卡片)

    • reportDashboardUrls 判据与 cli 同源,且 daemon 在同进程内同时算 URL 和判据 ⟹ 不存在跨进程视图分歧,Gap B(见下)不波及 daemon 路径。
    • 真机实测(本机已绑平台):reported url 无 token、localUrl 为 undefined。
    • 影响面独立扫了一遍(buildDashboardUrls 调用点 + .dashboard-token 读取点两条线):worker.ts:2122 是服务端验签、desktop/smoke.ts 是自测、workbench-link.ts 是产品决策过的常驻入口——都不是「把链接交到人/模型手里」的打印点,与你结论一致。
    • 测试 108/108 绿;build 绿(首跑一次瞬态失败,tsc 单独跑能产出缺失文件,PR 没碰构建脚本,非本 PR 引入)。

阻断项:bind.ts:158 硬编码 true 仍会确定性摘成死链

const [primary, ...rest] = formatDashboardSuccessLines(cur, false, true);

注释说「刚 bind 成功、platform.json 已落盘 ⇒ 必然是中心平台托管」——但这只在 remoteAccess 开时成立。cmdBind 只在 remoteAccess === undefined 时才写 remoteAccess: truebind.ts:128);用户显式设过 false 就保持 false

这时若配了 BOTMUX_PUBLIC_URL(或 devbox 短链),dashboard 的 remotePublicBase() = (remoteAccess=false → null) ?? publicReverseProxyBaseUrl() → 返回反代基址(不是平台基址),localUrl 有值。bind.ts 传 truedropToken = true && true ⟹ 摘 token ⟹ 死链(remoteAccess=false ⟹ buildPlatformDashboardLoginUrl() 返回 undefined ⟹ 无登录出口)。

实跑探针确认(构造反代形态输入):

formatDashboardSuccessLines(
  { url:'https://botmux.mycorp.example/?t=SECRET',
    localUrl:'http://10.0.0.7:7891/?t=SECRET' },
  false, true)
→ 首行 https://botmux.mycorp.example/   ← token 被摘,死链

cli.ts 两条路径用 dashboardLinkIsPlatformHosted() 没这个问题,daemon 第三个 commit 用同表达式也没问题,唯独 bind.ts 绕开了判据。这是确定性 bug,不是竞态。

第二点:cli.ts 路径「结构性不可达」论证有个洞(窄竞态,非阻断)

你论证「dashboard 拿更旧值时 remotePublicBase() 返回 null ⟹ localUrl 是 undefined ⟹ 被后半条挡死」。这在配了 BOTMUX_PUBLIC_URL/devbox 时不成立

remotePublicBase() = (remoteAccess ? platformBase : null) ?? publicReverseProxyBaseUrl() ?? devbox

dashboard 缓存里 remoteAccess=false(直接改 config.json 后的 ≤2s TTL 窗口内)⟹ platformBase=null,但 ?? publicReverseProxyBaseUrl() 非 null ⟹ remoteBase 非 null ⟹ localUrl 有定义 ⟹ 后半条挡不住。CLI(新读 remoteAccess=true)说平台托管 ⟹ 摘 token ⟹ 死链。

触发条件窄(直接改文件而非 dashboard UI——UI 走 mergeGlobalConfig 立即失效缓存;且要配反代/短链;且 ≤2s 窗口),但结构性可达,「不可达」结论不成立。和第一个 commit 是同一类错误:把「没有平台」当成「没有远程基址」。

建议

你说过「能构造出漏掉的组合就改协议层」——上面两个就是(Gap A 确定性、Gap B 窄竞态)。

  • 首选:dashboard 在 /__cli/current / /__cli/rotate 响应里回 platformHosted 字段,CLI + bind.ts 透传。两个洞一起消失,也不用维护跨进程推理。
  • 最低限度:把 bind.ts 的 true 换成 isRemoteAccessEnabled() && platformMachineBaseUrl()(或抽公共 helper),先把确定性死链堵上;Gap B 留注释说明 2s 窗口。

非阻断 follow-up

  • 工作台卡片按钮(resolveWorkbenchUrlworkbenchEntryUrl)仍带长期 token 在 multi_url 里。是既有产品决策(常驻入口),本 PR 没碰合理;但它毕竟是持久化聊天记录,建议另开 issue 评估是否也走短时票据。
  • dashboard-url.ts 注释说飞书卡片走 workbenchTicketRedeemUrl 短时票据,但 resolveWorkbenchUrl 实际用长期 token——既有文档/行为不一致,非本 PR 引入,建议顺手核对。

@deepcoldy

deepcoldy commented Sep 3, 2026

Copy link
Copy Markdown
Owner Author

阻断项已修(作者回应):判据收归协议层

⚠️ 上面那条复审针对的是 251854c82,当前 head 已是 a66739f92 —— 它指出的阻断项已修,修法是复审建议里的首选方案(协议层),不是最低限度那个。

复审的两条判断都成立,我独立复现过

阻断项:隔离 HOME 构造 remoteAccess=false + platform.json 存在 + BOTMUX_PUBLIC_URL ⟹ 正确判据应为 false,而 bind.tstrue ⟹ token 被摘、无登录出口。机制逐字成立:cmdBind 只在 remoteAccess === undefined 时才写 true,而那段注释自己就写着「已显式开/关的尊重用户选择」——我写硬编码时没读隔壁 30 行。

第二点(我的「结构性不可达」论证有洞):也成立。我的前提是「dashboard 没用远程基址就不给 localUrl」,但配了反代时 localUrl 有定义,后半条挡不住。

这里值得记一笔,因为它比单个 bug 更值得后来人看见:我在同一个 PR 里,先因为「把派生位 localUrl 当判据」写出缺陷(见 1b),修完并写下「判据要按语义命名、别靠派生位」,然后立刻拿同一个派生位去论证残余风险不可达。同一个坑踩了第二次——第二次它披的外衣是「安全论证的前提」而不是「判据」,所以我的模式匹配没触发。教训:修完一个缺陷要全文 grep 那个符号,问「它还在别处承担判断吗」;安全论证(「X 不可达」)和判据受同一条纪律约束。

修法:判据收归协议层(两个洞一起消失)

两个问题同源——判据由调用方各自推断,而只有生成 URL 的 dashboard 进程知道它用了哪条基址。所以:

  • DashboardUrls 新增 platformHosted,由 buildDashboardUrls 如实标注。拆出 platformCentralBaseUrl()(远程访问开 + 已绑定)与 nonPlatformRemoteBase()(反代 / Devbox),判据是 remoteBase !== null && remoteBase === platformBase —— 即「主链接就是平台子域」,而不是「这台机器绑过平台」(后者正是 bind.ts 那个错)。
  • /__cli/* 响应带上该字段;DashboardResult 解析它,只认严格 true(字段缺失=旧版 dashboard、'true'1null 一律 false = fail-safe 保留 token)。
  • 三个调用点全部改为formatDashboardSuccessLines / reportDashboardUrls 去掉形参,删掉 cli.tsdashboardLinkIsPlatformHosted() 与 daemon 侧重复推断(连带清掉两个死 import)。

bind.ts 无从硬编码;跨进程也不可能不一致(判据与 URL 同源、同一次计算、同一响应传回)。这不只是修实例,是去掉产生实例的自由度

验证

  • live 部署实测/__cli/current 响应 keys=url,localUrl,platformHosted、值 true;协议层如实回带 token 的原始 url,由 CLI 决定摘 ⟹ 终端与 restart 提示均无 token
  • 阻断项场景隔离复现remoteAccess=false + 反代 ⟹ 回 platformHosted=false ⟹ token 正确保留(修复前确定性死链)
  • 新增 6 格线上解析用例 + 反代那格 platformHosted: false 强断言(错判据当初被骗的正是这一格)
  • 变异 M17(退回宽判据)/ M18(只看绑定不看开关)/ M19(解析放宽成 truthy)全部被测到红
  • 针对性 10 套件 394/394;全量 unit 20724 passed / 9 failed,红的 3 个文件与同 commit pristine worktree 上坐实的集合逐格一致(root 下 EACCES 造不出只读前置),无新增

一处小更正

复审第 4 点写「positional 过滤用 !==」——我一开始以为是笔误去核了源码,它是对的args.filter(arg => arg !== DASHBOARD_LOCAL_TOKEN_FLAG)),我核错了方向,特此说明,免得后来人以为这条引用不准。


合并顺序(对最新 head 重验过):#884 trial merge exit=0 无冲突;#1071 与本 PR 冲突(src/cli.tstest/dashboard-command.test.ts),但它对当前 master 本身已是 CONFLICTING/DIRTY,冲突非本 PR 引入——rebase 时需注意 formatDashboardSuccessLines 现已去掉 platformHosted 形参、判据从 result.platformHosted 读。

@deepcoldy

Copy link
Copy Markdown
Owner Author

复审通过(5-commit head d06fa847c

三个 commit 后的协议层修法 + 混搭兼容钉死,我逐条独立验过,阻断项已消除,approve

阻断项(bind.ts 硬编码 true)→ 已修 ✓

第 4 个 commit 把判据收归协议层:DashboardUrls.platformHostedbuildDashboardUrls 如实标注(remoteBase !== null && remoteBase === platformBase,即「主链接就是平台子域」),/__cli/* 响应带上,DashboardResult 只认严格 true。bind.ts 改成读 cur.platformHosted,无从硬编码。我构造反代形态输入实测:platformHosted=false 时 token 正确保留(不再是死链);platformHosted=true 时正确摘掉。

跨进程分歧 → 结构性消除 ✓

我把「platformHostedlocalUrl 同次计算、同一响应传回」当待攻目标攻了一遍:两者由同一次同步 buildDashboardUrls 调用、同一配置视图算出,platformHosted=true 定义上等价于「url 就是平台子域」,而平台子域上 token 被 auth 层压制(已实测)。有害方向(flag=true 但 url 非平台)定义上不可能。这与上一版「靠 localUrl 派生位论证不可达」不同——那次是语义错位,这次是同源同算。唯一残留是平台边缘故障(不注入身份)的基础设施场景,那超出本 PR 范围。

混搭矩阵(按你建议的 2×2 + 第三组合)✓

  • 新 CLI + 旧 dashboard:字段缺失 → 严格 true 判 false → 保留 token ✓
  • 旧 CLI + 新 dashboard:url 始终带 token(第 5 commit 钉死)→ 旧 CLI 原样打印可用 ✓
  • 新 CLI + 新 dashboard:实测 /__cli/currentplatformHosted:true,CLI 摘 token ✓
  • desktop(第三组合):runtime-service.tsresult.url(带 token)、忽略 platformHosted——webview 无 SSO 会话必须留 token,22/22 绿 ✓
  • bind.ts + 未升级 daemon:旧 dashboard 不回字段 → false → 保留 token ✓

两层划分 ✓

协议层答「是不是平台托管」(客观事实)、消费端答「载体该不该带凭证」(终端摘/卡片摘+扣 localUrl/desktop 必须带)——分层正确。唯一张力是工作台卡片按钮(resolveWorkbenchUrl 仍带长期 token 在 multi_url),但那是既有产品决策、明确不在本 PR 范围,建议另开 issue 评估是否走短时票据。

真机实测(本机已绑平台,live daemon 跑本分支)

  • /__cli/current 响应 keys=url,localUrl,platformHosted、值 true
  • botmux dashboard current:首行裸 URL 无 token、工作台 hash 路由、本地直连隐藏、安全提示在
  • --i-am-the-owner-show-token-link:取回本地链接 + 警告;近似拼写被拒;--help 不含参数
  • 反代形态(隔离构造):token 正确保留

测试

build 绿;dashboard-command/url/endpoint/auth + restart-report + cli-runtime-update + desktop-runtime-service 全绿(306+55+47+22)。

非阻断 follow-up(不阻塞本 PR):工作台卡片按钮的长期 token 建议另开 issue。

deepcoldy and others added 5 commits September 3, 2026 09:54
`botmux dashboard` 的输出会被大模型读进上下文、再顺手转发到飞书群,而链接里的
长期 token 等同管理员凭证 —— 一旦进聊天记录/思考过程就是泄漏。三处收敛:

1. **绑定中心化平台(或配了 BOTMUX_PUBLIC_URL 反代)后,主链接不再带 token。**
   走平台子域时身份由平台注入,`dashboard/request-identity.ts` 对
   `platform-dashboard` 身份恒把 `presentedToken` 压成 undefined —— 实测带 `?t=`
   依旧 401(`x-botmux-auth-scope: workbench`),平台边缘更是先 302 去 SSO。
   即 token 对访问零贡献、只剩泄漏价值。未绑定平台时 token 是唯一入口,原样保留
   (去掉后只剩静态壳,而登录出口 `x-botmux-login-url` 未绑定时不生成)。
   判据用 `localUrl !== undefined`(仅远程基址生效时才有值),不必另读配置。

2. **本地 `ip:port`+token 直连链接默认不打印**,需要时加一个刻意起得很长的显式
   参数取回。该参数**不进 `--help`**,只在命令跑完后提示给人看 —— 列进 help 等于
   邀请模型「既然有这个参数那就加上」。

3. **输出末尾钉一段给 AI 读的安全提示**:只发 owner 本人;当前会话若是多人群
   (交给模型自己判断)不要发带 token 的链接。钉在 stdout 而非 skill 文档,是因为
   模型做「要不要发」这个决定时,手里只有这段输出。

⚠️ 无凭证形态必须用 hash 路由 `/#/agent-workbench`:`/workbench` 不在
`decideDashboardAuth` 静态壳白名单里,token-free 访问实测 401(平台身份下同样),
用它会给出一条打不开的死链。

首行仍是裸 URL,`botmux dashboard | head -1` 的脚本契约不变(已实跑验证)。

影响面:三个打印同一条链接的入口统一走 `formatDashboardSuccessLines`——
`botmux dashboard`、start/restart 后的面板提示(cli.ts)、`botmux bind` 绑定成功
回显(platform/bind.ts)。Desktop 那条路走结构化 `locate.url` 且渲染进自己的本地
webview(非聊天surface),不在本次范围。飞书卡片按钮沿用既有常驻链接机制,未改。

验证:
- `bun run build` 绿;针对性 9 个套件 338/338 绿
- 全量 unit 20709 passed / 11 failed —— 全部与本改动无关,已用**同 commit 的
  pristine worktree**坐实:`plugin-mcp-sandbox`/`plugin-registry-sandbox-read`/
  `mojo-launcher-env-quarantine` 在未改代码上同样红(root 下 EACCES 断言造不出
  前置);`dashboard-ipc-probe`/`tmux-pipe-backend-exit` 单跑全绿=负载 flake
- 变异测试 10 个:M1 不去 token / M2 忽略参数直接打印 / M3 删安全提示 / M4 用
  /workbench 死链 / M5 未绑定也去 token / M6 参数不在 positional 前摘掉 /
  M8 清空整个查询串 / M9 不删 token / M10 不可解析时 fail open —— 全部被测到红。
  M7(宽松匹配参数名)存活经查是**惰性编辑**:`positional` 过滤用 `===`,近似拼写
  会留在 positional 里先被判 invalid,精确性被结构双重钉住
- 真机实跑(本机已绑平台):主链接与工作台链接均不含 token;加参数后本地直连链接
  正常打印并附警告;`--help` 不含该参数;`dashboard current <参数>` 不误判
  invalid;近似拼写被拒且不打印 token

Co-Authored-By: Claude Code <noreply@anthropic.com>
自查发现上一个 commit 的真实缺陷:用 `localUrl !== undefined` 当「平台已生效」的
等价判据是错的。`remotePublicBase()` 有**三个**来源,认证方式完全不同:

  ① 中心平台 `platformMachineBaseUrl()` —— 平台反代时注入身份、先过 SSO,
     `request-identity.ts` 恒把 `presentedToken` 压成 undefined ⟹ token 无效也
     不需要,该去。
  ② 自建反代 `BOTMUX_PUBLIC_URL`(如 nginx) —— 只是转发到本机 dashboard,
     **没有人注入身份**。
  ③ Devbox 短链 `devboxDashboardBaseUrl()`(merlin 隧道) —— 同 ②。

而 `buildDashboardUrls` 对 ①②③ 都会给出 `localUrl`,所以原判据把 ②③ 也判成
「可以去 token」。实测 ②③ 下(带 `X-Forwarded-*` 反代头)无凭证请求 401、带 `?t=`
才 302;而 401 上的登录出口 `buildPlatformDashboardLoginUrl()` 第一行就是
`if (!isRemoteAccessEnabled()) return undefined` 且要求读到 `platform.json`,
②③ 都拿不到 ⟹ **对 ②③ 去 token 会摘成打不开的死链**。

修法:`formatDashboardSuccessLines` 新增 `platformHosted` 参数,只有它为真且确实
有 `localUrl` 时才去 token。默认 `false` = fail-safe 方向 **拿不准就保留**:少去
一次只是维持现状,多去一次可能让 owner 完全进不去。判据由调用方注入(保持纯函数
可测),`cli.ts` 新增 `dashboardLinkIsPlatformHosted()`,与既有
`ensureDevboxDashboardExportForCurrentPort` 的 `remoteBaseConfigured` 同源,
区别是**不**把反代算进来;`platform/bind.ts` 处刚 bind 成功、platform.json 已落盘,
直接传 true。

验证:
- 新增 3 个回归用例:反代形态(有 localUrl 但 platformHosted=false)必须保留 token
  且工作台仍用 `/workbench`;省略参数时默认保留;声称 platformHosted 但无 localUrl
  时也保留(不自洽的输入不摘凭证)
- 变异测试 3 个全部被测到红:M11 退回本缺陷(只看 localUrl) / M12 只看
  platformHosted 不要求自洽 / M13 默认值改 true(fail-open 方向)
- 针对性 9 套件 341/341 绿;`bun run build` 绿
- 真机实跑:本机已绑中心平台 ⇒ 仍正确去 token;注入反代形态 ⇒ 正确保留 token
- 文档同步更正(两处原先写的是过宽的判据)

Co-Authored-By: Claude Code <noreply@anthropic.com>
补上一处被我漏掉的影响面。`daemon.ts:dashboardUrlForReport()` 给两张**飞书卡片**
提供 dashboard 链接:

  · 重启报告 DM(`core/restart-report.ts` → `restart.dashboard` /
    `restart.dashboard_local`)
  · CLI 运行时更新提醒(`core/cli-runtime-update.ts` → `cli_update.dashboard`)

它原样带 `?t=<长期 token>`。而卡片是**永久聊天记录**——可转发、可截图、可搜索,
正是本 PR 要堵的那条泄漏面;前两个 commit 只收敛了终端输出与 `botmux bind` 回显,
影响面分析漏了这条。实测(本机已绑平台)该函数确实产出
`https://m-<id>.<平台域名>/?t=SECRET-TOKEN` 并交给卡片构建器。

修法:新增 `core/dashboard-url.ts:reportDashboardUrls(urls, platformHosted)`,
平台托管时 ①摘掉主链接 `?t=` ②**整条不返回 `localUrl`**。②比终端更严格是有意的:
`localUrl` 恒定带 token,它是给终端里的 owner 当平台异常兜底用的(`botmux dashboard`
有显式参数可取),不该主动推进聊天记录。判据与
`cli/dashboard-command.ts:formatDashboardSuccessLines` 同源:**只有中心平台**这条腿
注入身份 + 走 SSO,自建反代 / Devbox 短链没人注入身份、token 仍是唯一凭证,摘了是死链。

收敛逻辑放 `core/dashboard-url.ts` 而不是内联在 daemon:`daemon.ts` 不导出任何测试
够得到的东西,与当初把 `dashboard-command.ts` 从 `cli.ts` 抽出来同一个理由。

验证:
- 新增 4 个回归用例(平台托管摘 token 且扣下 localUrl / 非平台托管原样透传 /
  不可解析时 fail-safe 保留 URL 但仍扣 localUrl / 已无 token 时幂等)
- 变异 3 个全部被测到红:M14 退回原缺陷(原样返回) / M15 只摘 token 但仍带
  localUrl(半修) / M16 忽略 platformHosted 一律摘(反代变死链)
- 针对性 10 套件 388/388 绿(含 restart-report 与 cli-runtime-update);build 绿
- 真机端到端:本机已绑平台 ⇒ 卡片拿到的 url 无 token、localUrl 为 undefined

⭐ 发现路径:穷尽「谁会把带 token 的链接交到人/模型手里」这个维度——按
`buildDashboardUrls` 调用点 + 读 `.dashboard-token` 的位置两条线各扫一遍,
而不是只看自己改过的文件。

Co-Authored-By: Claude Code <noreply@anthropic.com>
复审抓出一个**确定性**阻断项,连带证否了我自己的一条「结构性不可达」论证。两个洞
同源:判据由**调用方各自推断**,而只有生成 URL 的 dashboard 进程知道它用了哪条基址。

**阻断项**:`platform/bind.ts` 硬编码 `platformHosted: true`,注释理由是「刚 bind
成功必然平台托管」。错的——同文件上方 `cmdBind` 只在 `remoteAccess === undefined`
时才写 `true`,用户**显式设过 `false` 就保持 `false`**(注释自己也写了「尊重用户
选择」)。此时若配了 `BOTMUX_PUBLIC_URL` / Devbox 短链,dashboard 返回反代基址、
`localUrl` 有值,硬编码 true ⟹ **确定性把 token 摘成死链**(反代无登录出口)。
已用隔离 HOME 复现:`remoteAccess=false` + `platform.json` 存在 + 反代 ⟹ 正确判据
应为 false,而 bind.ts 传 true。

**连带**:我此前论证「跨进程判据不一致的有害方向被 `localUrl !== undefined` 结构性
挡死」,前提是「dashboard 没用远程基址就不给 localUrl」——**配了反代时 localUrl 有
定义,这条挡不住**。窄(≤2s 配置缓存窗口 + 直接改文件),但结构上可达。

**修法(按我自己设的 bar:能构造出漏掉的组合就改协议层)**:
- `DashboardUrls` 新增 `platformHosted`,由 `buildDashboardUrls` 如实标注:拆出
  `platformCentralBaseUrl()`(远程访问开 + 已绑定)与 `nonPlatformRemoteBase()`
  (反代 / Devbox),`platformHosted = remoteBase !== null && remoteBase === platformBase`
  ——是「主链接**就是**平台子域」,不是「这台机器绑过平台」。
- `/__cli/*` 响应自然带上该字段(`dashboardUrlsFor` 直接返回 DashboardUrls)。
- `DashboardResult` 解析该字段,且**只认严格 `true`**:字段缺失(旧版 dashboard)、
  字符串 `'true'`、数字 `1`、`null` 一律 false = fail-safe 保留 token。
- 三个调用点全部改为读该字段,不再自己算:`formatDashboardSuccessLines` 去掉
  `platformHosted` 形参、`reportDashboardUrls` 去掉第二个形参、删掉 `cli.ts` 的
  `dashboardLinkIsPlatformHosted()` 与 daemon 侧的重复推断(连带清掉两个死 import)。

⟹ 两个洞一起消失:bind.ts 无从硬编码,跨进程也不可能不一致(判据与 URL 同源同一次
计算、同一响应传回)。

验证:
- **真机 live 部署实测**(switch:here + daemon:restart):`/__cli/current` 响应
  keys=`url,localUrl,platformHosted`、`platformHosted=true`,协议层如实回原始带
  token 的 url,由 CLI 决定摘 ⟹ 打印无 token;restart 后的面板提示同样无 token
- **阻断项场景隔离复现**:`remoteAccess=false` + 反代 ⟹ 协议层回
  `platformHosted=false` ⟹ token 正确保留(修复前是死链)
- 新增 6 格线上解析用例(严格 true/false/缺失/字符串/数字/null)+ 反代那格
  `platformHosted: false` 的强断言(错判据当初被骗的正是这一格)
- 变异 3 个全部被测到红:M17 退回宽判据(remoteBase 非 null 就算平台) /
  M18 只看绑定不看远程访问开关 / M19 线上解析放宽成 truthy
- 针对性 10 套件 394/394 绿;build 绿
- 文档同步:判据由 dashboard 标注、字段缺失即保留

Co-Authored-By: Claude Code <noreply@anthropic.com>
新增 `platformHosted` 是**加字段**,所以升级不同步的两个方向都必须安全,而我此前只
验了一个:

  · 新 CLI + 旧 dashboard —— 字段缺失 ⟹ 严格 `true` 判定为 false ⟹ 保留 token
    (已由 dashboard-endpoint.test.ts 的 6 格解析用例覆盖)
  · **旧 CLI + 新 dashboard** —— 旧 CLI 不认识该字段、也没有摘 token 的逻辑,只会
    原样打印 `url` ⟹ **`url` 必须始终带 token**,否则用户升级 daemon(而 PATH 上
    还是旧 botmux,或 install.sh 装的旧二进制)就会拿到一条进不去的链接。

这正是「协议层如实回原始 URL、由消费端决定摘不摘」这个设计的兑现点:当初若让
dashboard 直接回摘好的 URL,旧 CLI 会确定性拿到死链。真机实测新 dashboard 的
`/__cli/current` 仍回 `url` 带 `?t=`、`localUrl` 在位 ⟹ 旧 CLI 行为与本 PR 之前一致。

之前这条不变量只是**碰巧成立**(没有任何断言钉住它)。补一个 describe 钉死:
`platformHosted === true` 时 `url` 与 `localUrl` 仍含 `?t=`。变异 M20(让协议层直接
回摘好的 URL)⟹ 5 个用例红,证明钉住了。

验证:针对性 4 套件全绿;build 绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
@deepcoldy
deepcoldy force-pushed the wt/dashboard-hint-token branch from d06fa84 to 7174a91 Compare September 3, 2026 16:56
@deepcoldy
deepcoldy merged commit f7e9aad into master Sep 3, 2026
7 of 8 checks passed
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown

🚀 Released in v3.18.14

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