Skip to content

feat(api-only): form C 可视 web 终端 + 嵌套 sandbox pid-ns 降级 - #708

Merged
deepcoldy merged 4 commits into
masterfrom
pr/form-c-and-pidns-degrade
Aug 2, 2026
Merged

feat(api-only): form C 可视 web 终端 + 嵌套 sandbox pid-ns 降级#708
deepcoldy merged 4 commits into
masterfrom
pr/form-c-and-pidns-degrade

Conversation

@deepcoldy

Copy link
Copy Markdown
Owner

core-only(apiOnly)form C——在 sandbox 内实时看 codex 跑屏——的 daemon 侧两块,合入 master。依赖 #668(已合 master)。两块都随 v3.9.0-canary.13 灰度发布、经 riff 0.139 真机 E2E 验证全绿零回归

改了什么 + 为什么

1. form C 可视 web 终端(2 个 commit 的第一个)

webTerminal=ON 时 riff launcher 设 BOTMUX_CORE_CLI=codex(可视 TUI,非 codex-app 的 app-server),需要把只读终端 URL 交到 riff 手里。两处:

  • index-core-only.ts 冻结 WEB_EXTERNAL_HOST=127.0.0.1:现状 buildTerminalUrl 的 host 走 getWebExternalHost()getLocalIp()(LAN IP),而 core-only 的 proxy 只 bind 127.0.0.1 → sandbox 内 VNC 浏览器打这个 URL 连不上。冻结广告 host 与 bind 一致(与既有「冻结 worker HTTP bind host」互补)。
  • trigger-result 附 readOnlyUrl+viewTokenGET /api/sessions/:id/trigger-result 在有 live worker 终端时(ds.workerPort && ds.workerViewToken 门控)带上只读终端 URL。riff runner 本就轮询 trigger-result,turn 一 running 就能拿到 URL 透前端——这才是「实时看屏」。加在 IPC handler 层(buildAsyncTriggerLookupResponse),纯 resolveAsyncTriggerState 保持无 daemon 依赖;buildTerminalUrl 内联 ?viewToken=,写 token 不下发。

2. 嵌套 sandbox pid-ns 降级(第二个 commit)

riff 嵌套 AIO sandbox 不允许在新 PID namespace 里挂 procfsbwrap: Can't mount proc on /newroot/proc: Operation not permitted)→ fs-policy 沙盒(apiOnly 强制 readIsolation)用的 --unshare-pid + fresh --proc 失败 → plain codex 崩 4 次。

  • compileToBwrap 新增 skipPidNamespace:为 true 时只去掉 --unshare-pid,保留 fresh --proc /proc(无新 pid-ns 也能挂)+ --unshare-user + ipc/uts/cgroup + 全部 FS deny mask 不变
  • 双门控 coreOnlyPidNamespaceDegrade() = BOTMUX_CORE_ONLY==='1' && !bwrapCanUnsharePid()(启动一次性探针)。

安全推理(关键):credential 密封分两层——on-disk(FS mask,与 pid-ns 无关)+ env-borne(兄弟 transport bot 的 worker env 带明文 LARK_APP_SECRET,靠 --unshare-pid+fresh proc 挡 /proc/<兄弟pid>/environ)。故不能全 fleet 去 --unshare-pid。但 core-only 只合成唯一一个 apiOnly bot、其 worker secret 本就空 → 无兄弟凭证可读 → 仅此降级安全。正常/混合 fleet 因 BOTMUX_CORE_ONLY 短路永不降级、永不跑探针

影响面

  • 全部 core-only-gated。正常 fleet:trigger-result 形状不变(readOnlyUrl/viewToken 仅在有 live worker 终端时附加、可选字段老 consumer 忽略);WEB_EXTERNAL_HOST 冻结仅 BOTMUX_CORE_ONLY 入口生效;pid-ns 降级双门控短路。
  • serve --api-only flag 天然满足 gate(cli.ts 显式塞 BOTMUX_CORE_ONLY=1、workerForkEnv 不删它 → worker 继承;已用 /proc/<pid>/environ 实测 core-only worker present=1、生产 fleet worker present=0)。

测试验证

  • pnpm build 绿(基于最新 master cherry-pick,auto-merge 无冲突)。
  • dashboard-ipc(105) + async-trigger-state + api-only-wiring + fs-policy(68) + sandbox 共 258 单测全绿,含新增:trigger-result 有/无 worker 终端两态、skipPidNamespace 只删 --unshare-pid 保留 proc+mask、gate 无 BOTMUX_CORE_ONLY 恒 false。
  • riff 0.139 真机 E2E(canary.13)全绿零回归:serve --api-only→gate 命中→探针降级→plain codex TUI 起(canary.12 的 Can't mount proc 消失)→trigger-result completed→readOnlyUrl http://127.0.0.1:8800/s/<sid>?viewToken= 带 token 200/去 token 403、VNC 可开。本地也真跑 bwrap 证明降级 argv exit0。

🤖 Generated with Claude Code

deepcoldy and others added 2 commits August 2, 2026 17:21
…igger-result 吐只读终端 URL

## 背景
riff form C:在 sandbox 内 VNC 浏览器里实时看 codex 跑的屏。core-only 已自带
tmux 后端 + 终端 proxy(实测 capture-pane 能抓 codex TUI),只差两处把只读终端
URL 交到 riff 手里。webTerminal=ON 时 riff launcher 设 BOTMUX_CORE_CLI=codex(可视
TUI),OFF 时用 codex-app(默认,精确 usage)——不动 readIsolation 安全边界。

## 两处改动
1. index-core-only.ts:冻结 `WEB_EXTERNAL_HOST=127.0.0.1`。
   现状 buildTerminalUrl 的 host 走 getWebExternalHost() → getLocalIp()(LAN IP,
   实测 10.251.x.x),而 core-only 的 proxy 只 bind 127.0.0.1 → sandbox 内浏览器
   打这个 URL 连不上。core-only 是单租户 loopback,冻结广告 host 与 bind 一致。
   与上一行「冻结 worker HTTP BIND host」互补(一个管 bind、一个管 advertise)。

2. dashboard-ipc-server.ts + trigger-types.ts:GET /api/sessions/:id/trigger-result
   在有 LIVE worker 终端时附带 `readOnlyUrl` + `viewToken`。
   - riff runner 本就轮询 trigger-result,turn 一 running 就能第一时间拿到 URL 透给
     前端——这才是「实时看屏」。塞进已在公有白名单的 trigger-result,免得 riff 多轮询
     一条 + 多加白名单条目。
   - 加在 IPC handler 层(buildAsyncTriggerLookupResponse,此处 ds 在作用域),纯
     resolveAsyncTriggerState 保持无 daemon/registry 依赖。
   - 门控 `ds.workerPort && ds.workerViewToken`:worker web server 起来了才吐,
     closed/restored 会话不吐 stale URL。buildTerminalUrl(ds) 把 ?viewToken= 内联,
     只读能力;写 token 不进这条响应。

## 影响面
- 全部 core-only-gated / 仅在有 live worker 终端时附加字段;普通 fleet 会话的
  trigger-result 形状不变(readOnlyUrl/viewToken 只在 ds 有 workerViewToken 时出现,
  fleet 正常有——但那是既有 Lark 卡片渠道,不影响;字段是可选新增,老 consumer 忽略)。
- WEB_EXTERNAL_HOST 冻结仅 BOTMUX_CORE_ONLY 入口生效。

## 验证
- pnpm build 绿;dashboard-ipc(95) + async-trigger-state + api-only-wiring 共 161 测试全绿,
  含新增两条:有 live worker 终端时 trigger-result 带 readOnlyUrl(内联 viewToken、不含
  写 token)+viewToken;无 worker 终端时两字段都 omit。
- core-only 真机 E2E(配合 codex task_complete 修复):trigger-result 在 running 期即带
  readOnlyUrl=http://127.0.0.1:<proxy>/s/<sid>?viewToken=...(loopback ✓,非 LAN IP),
  completed 仍带;GET 全 URL→200 服务终端,去掉 viewToken→403(只读能力强制)。

依赖 core-only(本 PR #668 分支)。codex transcript 漂移修复是独立 fix 分支
(fix/codex-transcript-task-complete-boundary),两者出一个 canary 供 riff 0.139 真机验。

Co-Authored-By: Claude <noreply@anthropic.com>
…riff plain-codex 崩溃)

## 问题
riff 0.139 真机验 form C(webTerminal=ON→plain codex)时 codex 起不来、崩 4 次:
`bwrap: Can't mount proc on /newroot/proc: Operation not permitted`。根因不是
transcript parser(那个已修好),而是 fs-policy 沙盒(apiOnly 强制 readIsolation)
编译的 bwrap argv 用了 `--unshare-pid` + fresh `--proc /proc`。riff 的 AIO sandbox
是嵌套环境,不允许在新 PID namespace 里挂 procfs → bwrap 失败 → codex 崩。
riff 探针证实:`bwrap --dev-bind / / --proc /proc` 成功,加 `--unshare-pid` 就失败。

## 关键安全判断(附带安全分析)
credential 密封分两层:
- **on-disk**:fs-policy 的 deny mask(--ro-bind 空目录/空文件、--tmpfs+remount-ro)。
  这层跟 pid namespace 无关。
- **env-borne**:一个**兄弟** transport bot 的 worker 进程 env 里带明文 LARK_APP_SECRET
  (worker-pool.ts:2404,worker 要做宿主侧 lark 上传,故意留在 env),只能靠
  `--unshare-pid` + fresh `--proc`(隔离进程视图)挡住 `/proc/<兄弟pid>/environ` 读取。

所以**不能全 fleet 去掉 --unshare-pid**(会重开兄弟 bot 的 secret 泄露面)。但
core-only 只合成**唯一一个** apiOnly bot(bot-registry.maybeSynthesizeCoreOnlyConfig),
其 worker 的 LARK_APP_SECRET 本就是空(no-transport),无兄弟凭证可读 → 在 core-only
下去掉 --unshare-pid 不泄露任何 secret。

## 修法(双重门控,保守)
- fs-policy.ts compileToBwrap 新增 `skipPidNamespace` opt:为 true 时只去掉
  `--unshare-pid`,保留 fresh `--proc /proc`(无新 pid-ns 也能挂)+ --unshare-user
  + ipc/uts/cgroup + 全部 FS mask 不变。
- sandbox.ts 新增 `coreOnlyPidNamespaceDegrade()`:`BOTMUX_CORE_ONLY==='1' &&
  !bwrapCanUnsharePid()`。两个条件都满足才降级——(1) core-only 单租户无兄弟 secret,
  (2) 启动时一次性探针确认本机确实挂不了 pid-ns proc(嵌套环境)。正常/混合 fleet
  因 BOTMUX_CORE_ONLY 短路,永不降级、永不跑探针,完整隔离原样保留。
- prepareDirectSandbox 调用点透传 coreOnlyPidNamespaceDegrade()。
- 探针 bwrapCanUnsharePid() 缓存一次;bwrap 缺失时返回 true(inconclusive 不降级,
  fail-closed 交给别处)。credential-only 轻量沙盒路径(buildCredentialOnlySandboxArgs)
  不在 core-only codex 路径上、且嵌套下 fail-safe 变 unavailable(非崩溃),本 PR 不动。

## 验证
- pnpm build 绿;fs-policy(63)+sandbox(18) 共 81 测试全绿,含新增:skipPidNamespace
  只删 --unshare-pid 保留 --proc + 其它 unshare + FS mask;gate 无 BOTMUX_CORE_ONLY
  恒 false(正常 fleet 不降级);gate 在 core-only 下 === !探针。
- 本地真跑 bwrap 证明降级 argv 可用:skipPid=false(--unshare-pid=true)exit0 RAN_OK;
  skipPid=true(--unshare-pid=false, --proc 保留)**同样 exit0 RAN_OK**。嵌套下
  --unshare-pid 会失败这点由 riff 真机探针证实,本修法正是移除那个会失败的 flag。

Co-Authored-By: Claude <noreply@anthropic.com>

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

当前 SHA 241637f097a36c210d5aee0334f1356868acf291 有 2 个 blocking finding,先不要合并/发正式版:

  1. P1 — viewToken 实际没有 core-only gate,普通 fleet 的 webhook 轮询会拿到 live TUI 读能力。
    src/core/dashboard-ipc-server.ts:1147-1150 只判断 ds.workerPort && ds.workerViewToken,没有判断 core-only/Form-C capability。正常 daemon 的同一 helper 也服务 dashboard/webhook 路径;src/dashboard/trigger-api.ts:46-98 会把 daemon body 原样返回,src/dashboard/webhook-routes.ts:382-397 又直接回给 connector caller。于是原来只获准轮询 final output 的普通 webhook consumer,现在会收到可读取完整实时终端/中间工具输出的 capability URL。这既改变正常 fleet 响应形状,也扩大了授权面,与 PR body 的“全部 core-only-gated”不一致。

    建议在 daemon 发射点按权威 core-only/Form-C 能力门控,不要仅靠 optional/live-worker 判定;补一条 normal-fleet(最好穿 webhook adapter)的负向测试,断言即使 live worker 有 view token 也不返回这两个字段。写 token 未下发这一点本身是对的。

  2. P1 — PID namespace 降级探针把任意 probe failure 当成可安全降级的正证据。
    src/adapters/backend/sandbox.ts:263-270 只跑一次 full probe,status !== 0 就令 bwrapCanUnsharePid=false,随后 core-only 直接删 --unshare-pid。这会把 timeout/signal、bwrap 自身损坏、mount/userns 等无关失败都误分类为“只有 pid-ns 不可用”。我用 PATH 中一个总是 exit 1 的假 bwrap 复现:{canUnsharePid:false,degrade:true}。另外 probe 没带真实编译路径里的 --unshare-user 等 namespace 形状,并非注释所称 SAME combo。

    最小 fail-closed 修法:做差分双探针——先跑与真实 argv 相关 namespace 形状一致的 full probe;失败后再跑仅去掉 --unshare-pid、其余相同的 weaker positive-control;只在 full失败 && weaker成功 时允许降级。两者都失败、timeout、signal、找不到 bwrap等都视为 inconclusive,不降级,让真实 spawn fail closed。用 fake bwrap 覆盖 full-only-fail / both-fail / timeout 三类回归。

其余核查:skipPidNamespace 的编译结果确实只删除 --unshare-pid,保留 fresh /proc、其它 unshare 与 FS masks;WEB_EXTERNAL_HOST 只在 core-only entrypoint 冻结。定向 234 测 + pnpm build + diff-check 全绿,但现有测试没有覆盖上面两个授权/故障分类缺口。

…针 fail-closed)

codex 复审 PR #708 提的两个 P1 blocker,逐条修:

## P1-1: readOnlyUrl/viewToken 发射点缺 core-only gate
之前 trigger-result 附 readOnlyUrl+viewToken 只门控 `ds.workerPort && ds.workerViewToken`,
没门控 core-only → 普通/混合 fleet 的 webhook poll(HMAC 通过后)会原样拿到 live TUI
的读 capability,扩大既有授权面(dashboard 只在显式 /write-link 时才发 token)。
修:发射点加 `process.env.BOTMUX_CORE_ONLY === '1'` gate。core-only 是单租户 loopback、
trigger-result 本就是公有路由、riff runner 轮询它开屏;普通 fleet 一律不吐。

## P1-2: PID 降级探针把任意 bwrap failure 当"可安全去 --unshare-pid"
之前单探针:`--unshare-pid --proc` 跑失败就认为可降级。codex 用"总是 exit 1"的假
bwrap 复现 degrade:true——任何 bwrap 故障都会误判成可降级。
修:改**双探针**(pidNsDualProbeCanUnshare)。full = `--unshare-pid --proc …`;
weak = 去掉 --unshare-pid。**仅当 full 失败且 weak 成功**(去掉 pid-ns 正好是让它跑通
的原因,即嵌套 sandbox 签名)才降级;full 成功→不降级;两者都失败(假/坏 bwrap)→
bwrap 本身坏、去 pid-ns 也修不了→保持完整隔离(fail-closed)。probeRanOk 要求无 spawn
error 且 exit 0,timeout/ENOENT/status null 均不算成功。

## 验证
- pnpm build 绿;fs-policy(63) + sandbox + dashboard-ipc(96) 共 180 单测全绿,含新增:
  - readOnlyUrl 在非 core-only fleet 即使有 live worker 终端也不吐(P1-1 回归)
  - pidNsDualProbeCanUnshare 四态:full 成功不降级 / full 败+weak 成功才降级 /
    两者都败 fail-closed(codex 假 bwrap repro)
- 实测复现 codex 的假 bwrap(always exit 1)→ coreOnlyPidNamespaceDegrade=false(不降级);
  模拟嵌套(仅 --unshare-pid 失败)→ core-only 下 degrade=true、无 core-only env 下 false。

Co-Authored-By: Claude <noreply@anthropic.com>

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

复审 c251b7b:P1-1 已收敛;P1-2 还剩一个 blocking 分类缺口。

  • readOnlyUrl/viewToken 发射点现在明确 gate BOTMUX_CORE_ONLY==='1',新增 normal-fleet live-worker 负向行为测试也真触达 IPC handler;普通 webhook/fleet 不再获得 terminal capability。
  • ✅ full/weak 双探针修掉了“两个都 exit 1 仍降级”。

P1 — full probe 的 timeout/spawn error/signal 仍会被当成可降级证据probeRanOk()(sandbox.ts:287-288)把 clean nonzero、timeout、ENOENT、signal/status-null 全压成同一个 falsepidNsDualProbeCanUnshare()(276-281)只看这个 boolean。因此只要 full timeout/出错而随后 weak exit 0,当前代码仍返回 canUnshare=falsecoreOnlyPidNamespaceDegrade() 仍会删除 --unshare-pid。我直接按该状态调用新纯函数,得到:

{"fullTimedOut":true,"weakExitedZero":true,"canKeepPidNamespace":false}

这与修复说明里“timeout/ENOENT/status-null 均不算成功”不等于 fail-closed:它们虽不算成功,却仍被归入了唯一降级分支。上一轮要求的 timeout 回归也没有加;现有四态只覆盖两个 boolean,无法表达 inconclusive。

请把 probe result 改成至少三态(success / clean-nonzero / inconclusive):full=clean-nonzero && weak=success 才降级;full 的 timeout、spawn error、signal/status-null 一律保持 pid isolation。补 full-timeout+weak-success、full-spawn-error+weak-success(可再加 signal)负向测试。另建议把 full/weak 的共同 argv 带上真实编译路径相关的 --unshare-user;目前注释说 same/actual shape,但 probe 仍少了该关键 namespace flag,可能把实际 weak 也跑不通的宿主误判为可降级。

本地验证:新增 262 定向测试、hook-runner 19(GitHub CI 唯一超时 flake 的重跑)、pnpm build、diff-check 均绿。GitHub CI 当前失败是 hook timing 1484ms>900ms,独立重跑 673ms 通过,与本 PR 无关。修完上述分类后我再终审。

codex 复审 c251b7b 指出双探针的 boolean ranOk 把 timeout/spawn-error/signal 和
clean exit-nonzero 压成同一类:`full` timeout + `weak` exit0 时仍会误降级——而 timeout
不是 pid-ns 受限的证据,必须 fail-closed。codex 用「full timeout + weak exit0」实测到
canKeepPidNamespace:false。

## 修法
探针结果改**三态** BwrapProbeOutcome:
- `success`:spawn 无 error 且 exit 0
- `clean-nonzero`:spawn 无 error、无 signal、exit>0(bwrap 给出真实判决,如 proc mount 被拒)
- `inconclusive`:spawn error(ENOENT) / timeout(signal 非空) / status===null(没拿到判决)

`pidNsDualProbeCanUnshare(full, weak)` 仅当 `full==='clean-nonzero' && weak==='success'`
才降级(bwrap 明确拒绝带 pid-ns 的运行、但接受不带的 → 去 --unshare-pid 正是修复=嵌套签名)。
其余全部 fail-closed:full success 不降级;full inconclusive(timeout 等)即使 weak success
也不降级;weak 非 clean-success 也不降级。

同时给 full/weak 探针都加上 `--unshare-user`,与真实 fs-policy compileToBwrap 的 argv
same shape(userns 与 proc mount 有交互,探针不带就不是在测真实条件)。

## 验证
- pnpm build 绿;sandbox(23)+dashboard-ipc+fs-policy 共 182 单测全绿,含新增三态回归:
  full inconclusive+weak success 不降级(codex timeout 格)、full clean-nonzero+weak success
  才降级、both clean-nonzero fail-closed。
- 实测假 bwrap 三例:①always-exit1(both clean-nonzero)→不降级 ②仅 --unshare-pid 时 sleep30
  →full timeout(inconclusive)+weak success→**不降级**(codex 复现的正是此格)③仅 --unshare-pid
  时 exit1→full clean-nonzero+weak success→降级(真嵌套仍正确生效)。

Co-Authored-By: Claude <noreply@anthropic.com>

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

终审 9dc7b75:无 blocking finding,前两轮 P1 已全部收敛。

  • readOnlyUrl/viewToken 只在权威 BOTMUX_CORE_ONLY=1 且 live worker terminal 存在时发射;normal fleet 的真实 IPC 负向测试证明即便有 view token 也不会返回,webhook 授权面不再扩大。
  • PID probe 已改为 success / clean-nonzero / inconclusive 三态,且唯一降级格是 full=clean-nonzero && weak=success。timeout、spawn error、signal/status-null 即便 weak 成功也保持 --unshare-pid,fail-closed 成立。
  • full/weak 共同 argv 已纳入 --unshare-user;weak 仅少 --unshare-pid,与真实 compile 的关键 namespace 形状对齐。
  • skipPidNamespace 本身仍只删除 --unshare-pid,fresh /proc、其它 namespace 与 FS deny masks 不变;普通/mixed fleet 因 core-only gate 不进入降级。

独立验证:283 个定向测试(含 sandbox 23、dashboard IPC 106、fs-policy 68、async state、api-only wiring、上一轮 CI timing flake 的 hook-runner 19)全绿;pnpm buildgit diff --check 绿。GitHub 新一轮 CI/CodeQL 当前仍在跑,代码审查结论为 review-pass;待 checks 全绿及申晗 approve 后可合。

@deepcoldy
deepcoldy merged commit 4bc7d4e into master Aug 2, 2026
6 checks passed
@deepcoldy
deepcoldy deleted the pr/form-c-and-pidns-degrade branch August 2, 2026 18:27
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

🚀 Released in v3.9.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.

1 participant