feat(api-only): form C 可视 web 终端 + 嵌套 sandbox pid-ns 降级 - #708
Conversation
…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
left a comment
There was a problem hiding this comment.
当前 SHA 241637f097a36c210d5aee0334f1356868acf291 有 2 个 blocking finding,先不要合并/发正式版:
-
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 未下发这一点本身是对的。
-
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
left a comment
There was a problem hiding this comment.
复审 c251b7b:P1-1 已收敛;P1-2 还剩一个 blocking 分类缺口。
- ✅
readOnlyUrl/viewToken发射点现在明确 gateBOTMUX_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 全压成同一个 false;pidNsDualProbeCanUnshare()(276-281)只看这个 boolean。因此只要 full timeout/出错而随后 weak exit 0,当前代码仍返回 canUnshare=false,coreOnlyPidNamespaceDegrade() 仍会删除 --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
left a comment
There was a problem hiding this comment.
终审 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 build、git diff --check 绿。GitHub 新一轮 CI/CodeQL 当前仍在跑,代码审查结论为 review-pass;待 checks 全绿及申晗 approve 后可合。
|
🚀 Released in v3.9.0 |
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 手里。两处: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」互补)。readOnlyUrl+viewToken:GET /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 里挂 procfs(
bwrap: 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短路永不降级、永不跑探针。影响面
WEB_EXTERNAL_HOST冻结仅BOTMUX_CORE_ONLY入口生效;pid-ns 降级双门控短路。serve --api-onlyflag 天然满足 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 无冲突)。Can't mount proc消失)→trigger-result completed→readOnlyUrlhttp://127.0.0.1:8800/s/<sid>?viewToken=带 token 200/去 token 403、VNC 可开。本地也真跑 bwrap 证明降级 argv exit0。🤖 Generated with Claude Code