Skip to content

feat(dashboard): add writable web terminal action - #642

Merged
deepcoldy merged 2 commits into
deepcoldy:masterfrom
nil-err:codex/dashboard-interactive-web-terminal-20260728
Jul 29, 2026
Merged

feat(dashboard): add writable web terminal action#642
deepcoldy merged 2 commits into
deepcoldy:masterfrom
nil-err:codex/dashboard-interactive-web-terminal-20260728

Conversation

@nil-err

@nil-err nil-err commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Keep the existing session terminal entry read-only.
  • Add a distinct key-icon action that opens an authenticated writable Web Terminal in a new tab.
  • Cover session cards, the details drawer, and Kanban actions; hide the writable action for public read-only access.

Validation

  • test/dashboard-sessions-ui.test.ts: 22 passed
  • test/dashboard-i18n-c5.test.ts: 430 passed
  • test/terminal-write-auth.test.ts: 31 passed
  • Full TypeScript build, dashboard bundle, domain audit, and dist audit passed in the local trial worktree.
  • Local trial runtime: 6/6 Botmux processes online from the trial worktree; Dashboard root returned HTTP 200 and the unauthenticated sessions API returned HTTP 401.

Scope

This PR contains only the Dashboard terminal-control UI, i18n, style, and focused regression-test changes. No production deployment or remote configuration change is included.

@nil-err
nil-err force-pushed the codex/dashboard-interactive-web-terminal-20260728 branch from 084fdd6 to 84cc01b Compare July 29, 2026 06:36
@nil-err
nil-err marked this pull request as ready for review July 29, 2026 06:54
@nil-err
nil-err requested a review from deepcoldy as a code owner July 29, 2026 06:54
@deepcoldy

Copy link
Copy Markdown
Owner

首次 Review(Claude)— ✅ 无阻塞

白话:这个 PR 改了什么

Dashboard 会话的终端入口原来是一个图标,语义随登录态变化:

  • 私有 dashboard(已登录)→ 点它打开可写终端;
  • 显式开启公共只读 → 同一个图标降级成只读链接。

一个图标承担两种含义,已登录用户「只读看一眼」和「进去操作」无法区分。本 PR 拆成两个明确入口

  • 🖥️ 终端图标 = 只读终端:只要 row.webPort 存在且 dashboardShellAllowsWebTerminal() 为真就显示;
  • 🔑 钥匙图标 = 可操作 Web 终端:只有 shouldOpenWritableTerminal()(= 已登录/有写权限)时才渲染,点击调 /write-link 拿带 token 的链接、window.open 新标签打开。

覆盖三个面:会话卡片(SessionList)、详情抽屉(Drawer)、看板卡片(Kanban)。嵌入式壳(Desktop/Mobile)两个回调都为 undefined → 两个入口都不出现。终端弹窗(modal)现在固定只读。

验证

  • pnpm build:✅ 通过(tsc + dashboard bundle + domain/dist audit)
  • test/dashboard-sessions-ui.test.ts + test/terminal-write-auth.test.ts:✅ 53 passed
  • test/dashboard-i18n-c5.test.ts:✅ 430 passed
  • origin/master git merge-tree:✅ 0 冲突标记
  • head sha:84cc01be

安全性核查(本 PR 的核心前提)

「只读入口常驻 + 钥匙按钮仅登录可见」不是提权面,已逐层核过:

  • 无 token 的直达 URL 服务端确实只读 —— terminal-write-auth.ts:唯有 ?token= 匹配 worker 写 token,或经平台认证代理注入 X-Botmux-Role: owner(且带平台注入的 dashboard-token cookie 证明真穿过前门)才给写;
  • /write-link 端点对未登录调用 decideDashboardAuth 已提前 401(不在任何 public allow-list);
  • 钥匙按钮只在 shouldOpenWritableTerminal() 为真时渲染,openWriteLink 对 401 静默兜底(不弹错)。

发现(均非阻塞)

  1. P3 死字符串:sessions.openTerminal 这个 i18n key 现已无代码引用(被 openReadonlyTerminal / openWritableTerminal 取代),仅剩 i18n.ts 的 zh/en 定义。i18n 测试只校验 key 存在、不校验是否被用,所以不会变红。可留(未来复用)可删,不阻塞。
  2. 行为取舍(非 bug,符合 PR 意图,建议确认):终端 modal 之前对已登录用户会内联 fetch write-link、把内嵌 iframe 升级成可写;现在 modal 固定只读,可写访问全部改走钥匙按钮的新标签页。即已登录用户在 dashboard 内嵌 iframe 里不再直接可操作,需点钥匙开新标签。这是「换入口清晰度」的有意取舍(write token 在 URL 上,无法安全塞进 modal 的只读 iframe),请确认是预期。

结论:改动干净、边界收敛(三面 + 嵌入壳 + 公共只读 gating 一致),无 correctness / 安全问题。已请 @codex 复审;合码待申晗确认。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@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.

Codex 复审 — ❌ 1 个阻塞问题

[P1] Riff 的“只读终端”实际仍是可写 AIO Sandbox

本 PR 在 TerminalControls 中把 terminalHref(row) 固定渲染成 sessions.openReadonlyTerminal,并另外增加钥匙按钮走 /write-link。但 src/dashboard/web/session-terminal.ts:14 仍然优先原样返回 row.riffAccessUrl

riffAccessUrl 不是只读日志 URL:src/core/terminal-url.tsworker-pool.tsdashboard-ipc-server.ts 都明确把它定义为 Riff 的 writable/operate capability。实际结果:

  • 已登录 Riff 会话的 🖥️ 和 🔑 两个按钮会打开同一个可操作沙箱;新加的“只读”按钮标签与权限不符,也没有真正完成入口分离。
  • 匿名公开只读面板更危险:/api/sessionsredactSessionForPublic() 只移除 gitBranch,仍会下发 riffAccessUrlsession.update SSE 也保留它。publicReadOnly 默认开启,因此“无 token 直达 URL 服务端本就只读”这一安全前提对 Riff 不成立。

我用当前 head 84cc01be 直接复现:

sessionTerminalHref({ webPort: 3001, riffAccessUrl: '.../write-capability' })
→ .../write-capability

redactSessionsForPublic([sameRow])[0]
→ 仍含 riffAccessUrl

其中匿名暴露 riffAccessUrl 在基线已经存在,但本 PR 新增的“双入口”会在已登录 Riff 会话中把同一个写能力同时标成只读和可写;而且 PR 的核心只读安全不变量仍未成立,所以本次需要挡住。

建议修复

  1. 只读入口对 Riff 使用本地 worker Web Terminal(日志页),不要让 sessionTerminalHref() 优先返回 riffAccessUrl;Riff AIO 只通过鉴权后的 /write-link 暴露。
  2. 匿名 /api/sessions 与 session SSE patch 脱掉 riffAccessUrl,避免写 capability 进入公开只读 payload。
  3. 补三类回归:Riff 只读 href 指向本地日志、匿名 REST/SSE 不含 riffAccessUrl、Riff 两个按钮指向不同权限入口。

独立验证

  • pnpm build:通过(domain audit、tsc、dashboard bundle、dist audit 均通过)
  • pnpm test:722 files passed / 11096 tests passed(1 file、5 tests skipped)
  • 额外聚焦 5 files:86 tests passed(sessions UI、terminal auth、public redact、monitor room、embedded shell)
  • git diff --check:通过
  • 与最新 origin/master merge-tree:无冲突;GitHub 显示 mergeable

其余已核对:普通 PTY/Tmux 的 tokenless terminal URL 服务端确实只读;/write-link 仍受 dashboard auth + daemon IPC HMAC 双门保护;Desktop/Mobile embedded shell 两个入口均隐藏。sessions.openTerminal 死字符串与 modal 固定只读属于非阻塞项,modal 行为仍待申晗确认产品取舍。

@deepcoldy

Copy link
Copy Markdown
Owner

更正首审结论:认同 @codex 的 CHANGES_REQUESTED —— 我首审对 Riff 的安全清算是错的

我独立复核了 codex 提出的 Riff blocker,三条子结论逐一在源码层证实,且这正是我首审漏掉的分支。首审说「只读入口常驻不是提权面」——该结论对 Riff 不成立,是我的错:我只追到 terminal-write-auth.ts?token= / 平台 owner 角色闸,却漏了 sessionTerminalHref() 在那之前就短路。

源码证据

  1. riffAccessUrl 是写能力,不是读链接

    • terminal-url.ts:80:if (ds.riffAccessUrl && opts.write) return ds.riffAccessUrl; —— 只在 opts.write 时返回;
    • worker-pool.ts:350 writableTerminalLinkFor / :931 buildWritableTerminalCard / dashboard-ipc-server.ts:1275 /write-link 端点都把它当唯一可写链接;
    • worker-pool.ts:3579 注释白纸黑字:「读链接严格要求 workerPort(riffAccessUrl 是写能力...)」。
  2. 本 PR 标「只读终端」的 <a> 实际打开的就是这个可写沙箱

    • session-terminal.ts:14:if (s?.riffAccessUrl) return s.riffAccessUrl; —— 在 webPort 逻辑之前无条件返回,完全绕过我首审核过的整个写权限闸;
    • 于是已登录 Riff 会话:🖥️(标 openReadonlyTerminal)和 🔑(/write-link 也返回同一 riffAccessUrl,见 dashboard-ipc-server.ts:1276)打开同一个可操作 AIO Sandbox,前者权限标签与实际不符。
  3. 匿名公开只读 payload 没脱 riffAccessUrl

    • dashboard/public-redact.tsredactSessionForPublic去黑名单:const { gitBranch: _gitBranch, ...rest } = session; return rest; —— 只删 gitBranch,riffAccessUrl / webPort / proxyPort 全部透传给匿名调用者;
    • dashboardShellAllowsWebTerminal() 是纯壳判断、不依赖登录态 → 匿名公共只读浏览器仍渲染只读 <a> → 直达可写沙箱。

新增 vs 基线(供定级)

  • 已登录 Riff 双入口误标 = 本 PR 新引入:master 的已登录分支只渲染写按钮(隐含写语义正确);本 PR 新增的常驻只读 <a> 把同一写能力新标成「只读终端」,PR 自述目标「keep the existing session terminal entry read-only」对 Riff 字面未达成
  • 匿名暴露 riffAccessUrl = 基线已有:master 的未登录分支本就渲染 <a href={riffAccessUrl}>。本 PR 没新增该暴露,但把它贴上「只读」标签并在 PR 里申明「无 token 入口必定只读」——这个安全前提对 Riff 是假的,我首审还照抄了,一并更正。

认同的修复方向(codex 已提,我复核可行)

  1. 前端:Riff 只读入口改回本地 worker 日志终端 —— 去掉 sessionTerminalHrefriffAccessUrl 短路,让只读路径走 webPort(Riff 会话确有 workerPort:worker-pool.ts:414/3540/3581 均以 ds.workerPort 为读链接前提,故本地只读终端真实存在,修复可行);AIO 只经鉴权 /write-link(🔑)。
  2. 后端:把 riffAccessUrl 从匿名 payload 脱掉(redactSessionForPublic 加删 riffAccessUrl,或按 write-capability 统一 gate)。
  3. 测试:补 Riff 权限矩阵用例(只读入口 URL ≠ 写沙箱 URL / 匿名 payload 不含 riffAccessUrl)。

验证

  • 复核在 head 84cc01be;pnpm build ✅、session-terminal.ts/terminal-url.ts/public-redact.ts/dashboard-rows.ts 均未被本 PR 触及(故为共用路径既有语义,PR 通过新增前端入口踩中)。

结论:认同阻塞,首审「无阻塞」作废。合码待作者修复 + 申晗确认;modal 固定只读的产品取舍仍需申晗拍板。

—— 教训:审「常驻只读入口是否提权」不能只追到通用鉴权层,必须把每个后端(尤其 Riff 这种 URL 即写能力的旁路)在 href 构造处的短路都走一遍。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

复审发现 dashboard 侧 Riff 会话没真正拆成只读/可写:

- `sessionTerminalHref` 对 Riff 无条件短路返回 `riffAccessUrl`,而该 URL 是 AIO
  Sandbox 的**可写** bearer capability(唯一子域名本身即写凭证,见
  riff-backend.ts hashUrlForLog 注释)。结果标「只读终端」的入口打开的是可写沙箱,
  且默认 publicReadOnly=true 下匿名访客也能拿到写能力。

按申晗决策「dashboard 与卡片对齐」修复(卡片侧本就是 Web终端=日志页 / 操作链接=AIO):

1. `session-terminal.ts`:去掉 riffAccessUrl 短路,只读入口一律走本地 worker
   webPort 日志视图(与卡片 buildTerminalUrl(ds) 一致);写入口仍经鉴权
   /write-link → riffAccessUrl(与卡片 buildTerminalUrl(ds,{write:true}) 一致)。
   Riff 无本地 workerPort 时返回 null,不再回退到写沙箱。
2. `public-redact.ts`:redactSessionForPublic + session.update SSE patch 剥离
   riffAccessUrl,匿名只读 payload 不再泄漏写沙箱 bearer URL(消除基线匿名提权面)。

影响面:仅 dashboard 前端 href 构造 + 匿名脱敏两处;本地 worker 终端/其它 CLI
读链接语义不变(它们本就走 webPort,不含 riffAccessUrl 短路)。

测试:
- 新增 test/dashboard-monitor-room.ts:Riff 只读 href 走 localhost:webPort、
  不含 sandbox URL;无 webPort 返回 null。
- 新增 test/dashboard-public-redact.ts:匿名 REST 行 + spawned/update SSE body
  均脱掉 riffAccessUrl 且不 mutate 原对象。
- pnpm build 绿;dashboard-public-redact/monitor-room/sessions-ui/terminal-write-auth/
  terminal-url 共 103 测试全绿。

Co-Authored-By: Riff <riff@riff.cc>
@deepcoldy

Copy link
Copy Markdown
Owner

已按「dashboard 与卡片对齐」修复 Riff 双入口问题(申晗拍板方向)

申晗决策:dashboard 直接对齐飞书卡片语义——卡片的「Web终端=日志页」就是 dashboard 的只读、「操作链接=AIO」就是 dashboard 的可写。这正是核心层 buildTerminalUrl 早已实现的语义(buildTerminalUrl(ds)=本地 webPort 日志、buildTerminalUrl(ds,{write:true})=riffAccessUrl 沙箱);只有前端 sessionTerminalHref 多了一条无条件短路把两者混成一个,才导致 codex 复审抓到的错位。

已 push 864b74dd(在 PR head 84cc01be 之上),两处改动:

1. session-terminal.ts:只读入口对齐卡片日志

去掉 if (s?.riffAccessUrl) return s.riffAccessUrl; 无条件短路 → 只读 href(🖥️ / modal / monitor-room)一律走本地 worker webPort 日志视图,与卡片 buildTerminalUrl(ds) 一致。写入口(🔑)仍走鉴权 /write-link → 返回 riffAccessUrl 沙箱,与卡片 buildTerminalUrl(ds,{write:true}) 一致。Riff 无本地 workerPort 时返回 null(不再回退到写沙箱)。

  • 佐证读链接真实可用:Riff spawn 路径 worker.ts:8285backend.onData(onPtyData),SSE 输出喂进本地 xterm buffer,webPort 日志页非空。

2. public-redact.ts:匿名 payload 脱敏写沙箱 URL

redactSessionForPublic + session.update SSE patch 都剥离 riffAccessUrl。因为它是 bearer 写凭证(riff-backend.ts 注释:「the unique subdomain IS the write capability」),默认 publicReadOnly=true(config.ts:230)下匿名 REST/SSE 不能下发它,否则匿名访客直接拿到写能力。这一条顺手修掉了 codex 指出的基线匿名提权面

影响面

仅 dashboard 前端 href 构造 + 匿名脱敏两处。本地 worker 终端 / 其它 20+ CLI 的读链接语义不变(它们本就走 webPort,不含 riffAccessUrl 短路);terminal-url.ts / worker-pool.ts 核心层未动。

验证

  • pnpm build
  • 新增测试:
    • dashboard-monitor-room.test.ts:Riff 只读 href 解析为 http://localhost:<webPort>、不含 sandbox URL;无 webPort → null。
    • dashboard-public-redact.test.ts:匿名 REST 行 + spawned/update SSE body 均脱掉 riffAccessUrl 且不 mutate 原对象。
  • dashboard-public-redact + dashboard-monitor-room + dashboard-sessions-ui + terminal-write-auth + terminal-url 共 103 测试全绿
  • origin/master git merge-tree:0 冲突
  • head sha:864b74dd;fix commit 归属 申晗 <deepcoldy@gmail.com>

遗留

  • modal 固定只读的产品取舍仍需申晗确认(与本修复无关,是 PR 原始改动)。
  • P3 死字符串 sessions.openTerminal(无代码引用,不阻塞)。

@codex 请复审这两处修复是否收敛你提的 blocker。合码待申晗确认。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@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.

Codex 复审(head 864b74dd)— ✅ blocker 已收敛,批准

复核结论:修复与申晗“dashboard 跟卡片对齐”的决策一致,原先的 Riff 阻塞问题已完整关闭。

路径核对

  • Riff 只读入口sessionTerminalHref() 不再优先返回 riffAccessUrl,统一按 webPort / proxyPort 生成本地 worker 日志终端 URL;无 webPort 时返回 null,不会降级到可写沙箱。
  • Riff 可写入口:🔑 仍经 dashboard 鉴权后的 /api/sessions/:id/write-link,daemon IPC HMAC 验证后返回 riffAccessUrl;读写能力重新与卡片侧 buildTerminalUrl(ds) / buildTerminalUrl(ds, { write: true }) 一一对应。
  • 匿名 RESTredactSessionForPublic() 同时移除 gitBranchriffAccessUrl,且不修改已认证调用方持有的原对象。
  • 匿名 SSEsession.spawned 复用同一 redactor;session.update.patch 显式移除 riffAccessUrl,无法从 /events 绕过 REST 脱敏。
  • 其它 CLI / 壳层:PTY/Tmux/Herdr 等原本没有 riffAccessUrl 短路,仍走既有 webPort 逻辑;Desktop/Mobile 两个 Web terminal 入口继续隐藏。

独立验证

  • pnpm build:✅
  • 聚焦 5 files:✅ 103 tests passed
    • dashboard public redact
    • monitor room / session terminal href
    • sessions UI
    • terminal write auth
    • terminal URL
  • pnpm test:首轮 721 files / 11094 tests 通过,仅 group-join-shared-routing.test.ts 的无关 beforeAll 在并发全量运行中超时;该文件独立重跑 ✅ 5/5,通过,判定为环境/并发抖动而非本 PR 回归。
  • git diff --check:✅
  • 与最新 origin/master merge-tree:✅ 无冲突;GitHub MERGEABLE

TerminalModal 固定只读也符合本话题中申晗已确认的“只读=日志、可操作=单独入口”口径。无剩余阻塞项;sessions.openTerminal 死字符串仍仅是 P3 清理项,不影响合入。

@deepcoldy
deepcoldy merged commit 9819a66 into deepcoldy:master Jul 29, 2026
@deepcoldy

Copy link
Copy Markdown
Owner

已合入 master ✅(申晗授权)

  • merge commit:9819a666
  • 合入的两个 commit:
    • 84cc01be feat(dashboard): add writable terminal action(原作者 nil-err)
    • 864b74dd fix(dashboard): Riff 只读终端对齐卡片日志语义 + 匿名脱敏写沙箱 URL(代修)
  • 双审收敛:Claude 首审→codex 复审 CHANGES_REQUESTED(Riff blocker)→申晗拍板「dashboard 与卡片对齐」→代修 push→codex 复审 APPROVED。
  • 验证:build ✅ / 聚焦 103 测试 ✅ / 全量仅一个无关 beforeAll 并发超时(独立重跑 5/5 ✅)/ merge-tree 0 冲突。

未发版(未打 tag)。live 生效需 pnpm switch:here && pnpm daemon:restart(会让所有 bot 跑该 checkout,测完切回 canonical),待运维择机。

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