Skip to content

fix(sandbox): 修复 Linux 会话启动与 Codex 提交确认#490

Merged
deepcoldy merged 42 commits into
deepcoldy:masterfrom
moonbycc:fix/linux-sandbox-fuse-recursion
Jul 16, 2026
Merged

fix(sandbox): 修复 Linux 会话启动与 Codex 提交确认#490
deepcoldy merged 42 commits into
deepcoldy:masterfrom
moonbycc:fix/linux-sandbox-fuse-recursion

Conversation

@moonbycc

Copy link
Copy Markdown
Collaborator

改了什么

  • 将 Linux 沙箱的 project/home merged 挂载点移到 HOME 外部,避免
    rootless fuse-overlayfs 递归包含自身。
  • 保留旧挂载路径的兼容清理,升级后可回收遗留会话。
  • 改用 /proc/self/mountinfo 判断挂载状态,避免损坏的 FUSE 挂载
    mountpoint 检查本身卡死。
  • 增加 rootless FUSE busy mount 的 lazy detach 清理。
  • 让 Codex worker 与 CLI 子进程使用相同的隔离 CODEX_HOME
    修复消息已提交却仍提示“未能确认提交”的误报。

为什么

统一文件沙箱启用后,Linux 非 root 环境会使用 fuse-overlayfs。
旧的 home-merged 位于 HOME 内部,而 HOME 又是 overlay lower,
导致 FUSE 递归挂载,bwrap 在 Codex 启动前阻塞。

修复挂载后,Codex 已能正常执行消息,但 CLI 将 history 和 rollout
写入机器人独立的 CODEX_HOME,worker 仍检查全局 ~/.codex
因此产生提交失败误报。

影响面

  • Linux rootless FUSE 和 kernel overlay 沙箱的新会话启动与清理。
  • Codex 隔离会话的提交确认、resume fallback 和 transcript bridge。
  • 不改变现有沙箱读写语义。
  • Claude Code 及其他 CLI 的数据路径不受影响。

实际验证

  • pnpm build:通过。
  • pnpm test:518 files / 8426 tests 全部通过。
  • Linux 5.15、UID 1001、bubblewrap 0.8.0、fuse-overlayfs 1.10:
    实际 FUSE + bwrap 启动验证通过。
  • 创建真实隔离 Codex 会话:
    • 首条消息正常提交并执行。
    • rollout 从机器人独立 CODEX_HOME 被正确发现。
    • 等待超过 20 秒后没有提交失败告警。
  • 关闭测试会话后无残留 FUSE 挂载、进程或临时目录。

#482 的关系

#482 正在重构沙箱并删除 overlay,因此合并后不再需要本 PR 的 FUSE
挂载修复;但其当前实现仍只设置子进程的 CODEX_HOME,worker 侧的
路径对齐修复仍需保留。

@moonbycc
moonbycc requested a review from deepcoldy as a code owner July 16, 2026 07:47
deepcoldy and others added 23 commits July 16, 2026 15:56
fix(lark): 支持长连接使用环境代理
消息 list/get 全部读路径带上 with_sender_name=true,服务端直接返回
sender_name(user 和 bot 都给),parseApiMessage 透出为 senderName:

- botmux history / quoted 输出发送者名字,多 bot 群里 agent 不用再猜
  哪条消息是谁发的;第三方 bot(不在 bots.json / 观察名册里)也有名字
- 合并转发展开的 <participants> 带 name 属性(渲染器本就支持,之前
  没有数据源)
- dashboard 会话历史:本地花名册/contact 解析不到时兜底用服务端名字;
  本地解析到的名字优先级不变

影响面:仅 API 读消息路径(history/quoted/合并转发/dashboard 历史)。
实时事件路径(receive_v1)不带 sender_name,不受影响;daemon 现有的
contact/花名册解析逻辑保持原样,服务端名字只作补充兜底。
- readRunEnvelope 仅把真实 ENOENT 视为 missing;symlink/目录等非普通文件 fail-closed 为 invalid(O_NOFOLLOW+fstat)
- preparing 标记校验 owner PID/start(及可选 bootId),死 owner 可按 maxAge 清理,避免永久堆积
- 补充 dangling symlink、authority 不降级、preparing sweep 回归
- 创建会话弹窗新增「连续创建」开关(localStorage 持久化):打开时创建成功
  不关闭弹窗,内联显示成功提示(含打开飞书群链接),清空内容/群名并保留
  机器人勾选、协作模式等配置,便于连续创建多个会话
- 侧边菜单栏顶部新增「创建会话」「创建机器人」入口:任意页面可用,创建会话
  跨页时先跳 #/sessions 再经 pending 标记自动打开弹窗(create-session-entry.ts)
- 创建会话弹窗机器人列表新增搜索框:按名称/appId 过滤,被过滤的已勾选项
  保持勾选,展示已选数量提示
Codex 首轮与 Claude 二次 review 均无阻塞问题;构建与定向测试通过,已确认基线失败与本 PR 无关。
review finding:横排覆盖原先写在文件靠前的 media query 里,被后面的
同 specificity 基础规则 display:grid 反超,900px 实测退化为两行全宽按钮。
挪到基础规则之后并补层叠顺序回归测试(断言 mobile override 的源位置
必须在顶层基础规则之后,纯存在性匹配抓不到这类回归)。
…event-target

fix(dashboard): 修复筛选输入白屏
…-entries

feat(dashboard): 创建会话连续创建开关 + 菜单栏创建入口 + 机器人搜索

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

Review 结论:无阻塞,可合并(head ec69f5a

三个修复(merged 挂载点出 HOME、mountinfo 判活、worker 侧 CODEX_HOME 对齐)根因分析正确、实现自洽,升级兼容闭环完整。以下为逐项核对结论与实际验证。

实际验证

  • pnpm build 通过(本地,Linux 5.15 root 环境)。
  • test/sandbox.test.ts 单独跑 56/56 绿(含本 PR 新增 2 例)。
  • 全量套件(npx vitest run,全 projects):8636 passed / 13 failed / 19 skipped。13 个失败全部为本机预存环境失败,与本 PR 无关——其中 browser e2e ×16 文件(headless 无浏览器)、recall-frozen ×1、scheduler ×2、schedule-card ×1 是已记录基线;coco e2e ×7 与 multi-bot-session.e2e ×2 在 base commit 74ee3ab 对照 worktree 中逐一复现(9/9 相同失败),确认非 PR 引入。
  • PR 波及面定向回归(sandbox / write-input / codex-transcript / cli-adapters / tmux-env ×2 / per-bot-env,7 文件):555/555 绿。
  • 真实挂载 sanity:以 root 挂载 tmpfs 到含空格路径(.../mnt test/with space)+ 普通路径,验证:
    • isMounted 的 mountinfo 解析正确(fields[4] 取 mount point、\040 octal 解码命中含空格路径);
    • unmountOverlay 新回退链在非 FUSE 挂载上正常收敛(fusermount -u 失败 → umount 成功),卸载后 isMounted 归 false。

关键路径核对(修复正确性)

  1. FUSE 递归根因成立:旧 home-merged 在 HOME 内、HOME 又是 home overlay 的 lower → fuse-overlayfs 自包含递归,bwrap 解析后续 bind 目标可能永久阻塞。移到 /var/tmp/botmux-sbx/<sid>/ 出 HOME 是正解;mountOverlay 自身 mkdir upper/work/merged,新挂载点创建无遗漏。
  2. 升级兼容闭环unmountSandboxOverlays/hasMountedSandboxOverlay 同时覆盖新旧四个候选路径——旧 build 创建的活跃 tmux 会话升级后被 sweep 正确保护(liveSandboxSids 第一道门 + legacy mount 检查),会话关闭时 attachSandboxOutbox 的 cleanup 也能卸掉 legacy 挂载。同 sid 在升级后 in-pane /clear 重进 prepareSandbox 会先卸旧四路径再挂新路径,不会叠挂。
  3. worker 侧 CODEX_HOME 修复链路成立codexHome() 调用期动态读 env(codex-paths.ts 本就为此设计);提交确认(codex.ts:245 baseline+delta watch)、resume 发现(codex.ts:210)、transcript bridge(codex-transcript.ts)全部调用期解析;env 改写位于 spawnCli 早段,先于所有消费点。
  4. process.env 改写作用域安全forkWorker 每会话独立 fork(worker-pool.ts:1822),worker 不跨会话复用,注释「A worker owns one session」属实;CODEX_HOME 已在 BOTMUX_INJECTED_ENV_KEYS(child-env.ts:60),tmux server global env 泄漏向量已被 #364 体系关闭;per-bot env 保留键拒绝 CODEX_HOME 覆盖;isolatedCodexHome! 非空断言守卫链成立(else 分支蕴含 !isClaudeFam,此时必已赋值)。
  5. sandbox×读隔离可见性前提成立:读隔离时 BOT_HOME 经 authPaths real+writable bind(worker.ts:5336)直通真实文件系统,CLI 在 bwrap 内写 <BOT_HOME>/codex host 侧 worker 可见——这是 worker 对齐修复能生效的前提,代码上确认。
  6. 无旧路径残留:运行时代码无 legacy merged 路径引用;daemon 侧 codex 路径消费者(adopt discovery / session-discovery)面向全局 ~/.codex,语义本就正确,不受影响。codex-app 未声明 supportsReadIsolation,读隔离门不会开,env 改写不波及。
  7. 与最新 master 无语义撞车:base 落后 master 3 个提交(#452/#480 等),均在 im 侧,不涉 sandbox/worker。

非阻塞观察(P3,可另 PR)

  1. sweep 枚举盲区sweepOrphanSandboxes 仍只 readdir(<dataDir>/sandboxes)。新布局下若 sessionRoot 已被 rm 而 vartmp 侧挂载卸载失败(两级 lazy detach 都失败的窄窗口),该 sid 永远不会被枚举 → 泄漏挂载失去 GC 重试机会。旧布局里 busy mountpoint 在 sessionRoot 内、rm 失败 → sid 留存可重试。建议 sweep 把 readdir(VARTMP_ROOT) 的 sid 并集进来。
  2. isMounted 用 resolve() 不 realpath:入参虽都来自 sandboxOverlayPaths(sessionRoot 已 canonicalize),但 /var/tmp 本身是 symlink 的环境会与 mountinfo 记录的 canonical 路径失配。一行换 canonicalize() 即可与仓库「realpath 归一双侧」惯例对齐。
  3. probe 脚本过期scripts/sandbox-{fixes,overlay,opaque-land}-probe.mjs 仍探旧 merged 路径,用它们验证本修复会得到过期结论(非发布代码,顺手更新即可)。
  4. kernel-overlay 路径多一次空跑unmountOverlay 现在对 root/kernel 挂载先试一次注定失败的 fusermount -u,语义不变仅多一次 spawn,无需改。

deepcoldy and others added 14 commits July 16, 2026 17:01
…-permission-error

fix(lark): 修正文档订阅权限错误提示
readRunEnvelope 此前先 open(O_RDONLY|O_NOFOLLOW) 再 fstat 判型,
无 writer 的 FIFO 会卡死 open 本身;该路径被 authority/host/daemon
的同步完整性检查广泛调用,一个 FIFO 即可阻塞 daemon event loop。

- 改为 lstat 先行:真 ENOENT 才 missing,symlink/FIFO/目录一律 invalid
- open 改用 O_NOFOLLOW|O_NONBLOCK,fstat 复核 regular file 且 dev+ino
  与 lstat 一致;lstat→open 竞态换成 FIFO/symlink/其它文件均 fail-closed
- readRegularArtifact 同类收口(原先 lstat 通过后按路径重读仍有竞态窗口)
- 补无 writer FIFO 回归测试:run.json 立即返回 invalid、工件立即抛
  artifact_not_regular_file,均要求不阻塞
多 Agent 会议消费:per-member 有序可靠投递、独立 cursor/epoch、副作用 action gate(capability/owner/epoch fencing + actionId+inputHash 幂等)、角色预设与默认角色、Grok/Traex 可靠 terminal。含 relay origin fail-closed、durable 启动失败边界、隔离会话 IPC 鉴权收紧等安全硬化。Premium(Claude)+LastResort(Codex) 双审通过。
复审 18d98c9 两项跟进:

P1:lstat 与 open/fstat/read 拆成两个错误边界。只有初始 lstat 的
ENOENT 才算 missing;lstat 已观察到 regular file 后,任何后续错误
(包括移除竞态导致的 open ENOENT)一律 fail-closed 为 invalid,
missing-only 的 legacy 回退不得触发。补受控注入回归:真实 lstat 通过、
仅 open 抛 ENOENT 时返回 invalid,且完好文件随后仍正常读取。

P2:FIFO 回归测试改到子进程执行(--import tsx driver +
execFileSync 15s 硬超时)。若未来重新引入阻塞 open,被杀死的是
子进程并抛 ETIMEDOUT 失败,而不是 vitest worker 永久挂死。
冲突解决要点(8 文件):
- types.ts/worker.ts:init/message IPC 字段取并集(我方 chatType + master
  dispatchAttempt/vcMeetingImTurnOrigin);worker 保留 master VC relay 能力块,
  去掉被 readProcessStartIdentity 取代的本地 readProcStarttime
- session-marker.ts:双方重写取并集——master 的 dispatchAttempt 解析 +
  managed-origin capability 回退,叠加我方 procStart 身份认证与无 shell 的
  readParentPid;authenticated 上下文透传 dispatchAttempt
- cli.ts:v3 start/retry/grant 保留我方 postWorkflowDaemonMutation 完整信封
  协议;其余 daemon 调用跟随 master 迁移到 fetchDaemonIpc(路由绑定 HMAC)
- dashboard.ts:proxyToDaemon 非 workflow 路由走 master fetchDaemonIpc;
  workflow 变更保留域分离信封 + 响应验签;补 master 的 fetchDaemonUrl
- dashboard-ipc-server.ts:保留我方 ipcHmacAuthorized 命名与 workflow-v3
  不复用说明,叠加 master trustedHostRequests 短路;workflow v1 前缀 POST
  加入 narrow-untrusted 白名单(handler 自带同 secret 的更强完整信封校验,
  不与外层 ts:nonce HMAC 重复签名)
- daemon.ts:VC 常量/身份/交付逻辑全收,剔除 v2 workflow 残留 import 与
  workflowEventWatchers;client.js import 并集;修复重复 IncomingMessage
- master VC 服务对已退役 workflows/events/idempotency 的引用改指
  utils/canonical-input-hash(同源实现,导出等价)

验证:pnpm build 绿;隔离 HOME 全量 564 files / 9062 tests,除
test/vc-meeting-daemon-session.test.ts 4 例外全绿——该 4 例在未合并的
origin/master 基线(独立 worktree)隔离 HOME 下以相同方式失败,属 master
自带环境敏感问题,非本次合并回归;真实 HOME 下该文件 117/117 全绿。
沙盒(Linux bwrap)/ read-isolation(macOS)里的 chat CLI 读不到宿主进程树
marker、run 目录和 .dashboard-secret,`botmux workflow start/cancel/retry/grant`
三条腿全部失败。本提交新增 daemon 侧 session relay 通道:

- CLI 侧(session-relay-client):以 worker 每轮轮换的 origin capability 文件
  为探测器 + 凭据(宿主会话没有该文件 → 保持原 marker+签名信封路径不变),
  loopback POST /api/v3/session-runs/:runId/:mutation;端口取 daemon 发现目录,
  被掩蔽时回退 BOTMUX_DAEMON_IPC_PORT
- daemon 侧(session-relay):复用 /api/asks 的窄孔姿态——capability 对
  liveOrigin 精确核验后,(caller, chat, bot) 三元组全部取自 daemon 自己的
  活跃会话记录(请求体选不了身份),再按与宿主路径完全相同的 run 绑定规则
  (authorizeV3RunMutationForCurrentTuple,从 authorizeV3DaemonCommand 中
  逐字抽取共享)授权;payload 按 mutation 白名单裁剪后过共享 body parser;
  receiver 会话拒绝;最终调用与签名信封路由同一个 mutation 执行器
- 端口 plumbing:bwrap --setenv 与 tmux 注入白名单补 BOTMUX_DAEMON_IPC_PORT
  (仅路由标记,非凭据,所有可达路由独立鉴权)

影响面:cli.ts 四个 workflow 命令加 relay 前置分支(宿主路径行为不变);
daemon 新路由挂 narrow-untrusted 白名单,签名信封路由零改动;child-env
白名单新增一个非敏感 key 波及所有后端(tmux/zellij 面板可见该端口)。

测试:v3-session-relay(授权矩阵 14 例:capability 有效/无效/缺失、未知会话、
receiver 拒绝、身份不完整、跨 bot、run 绑定不匹配/未绑定/缺失、payload 白名单
与共享 parser 复验、四 mutation 绿路)+ v3-session-relay-client(sandbox/
read-isolation/host 三态探测、端口回退链、URL/body 形状、传输错误);
隔离全量 565/566 文件绿(唯一红 = 已记录的上游 vc-meeting-daemon-session
非隔离性 4 例,与本改动无关)。
复审第四轮两项:

P1 代际拼接——capability 只在消息真正 dequeue 进 CLI 时轮换(worker
flushPending),而 lastCallerOpenId/quoteTargetId 在下一条消息到达时即前移
(daemon 入站处理);此前 relay 把 turn A 的 capability 与可能已属 turn B 的
caller 字段拼成授权视图,A 可借 B 的身份操作 B 绑定的 run。修法=镜像宿主
路径 current-turn-provenance 的 marker join:授权要求
liveOrigin.turnId === session.quoteTargetId(chat-scope 另比对
currentReplyTarget.turnId),失配一律 403 turn_provenance_stale。join 成立时
caller/quoteTarget 由同一条入站消息原子写入,即同代。两个方向(A 冒充 B /
B 已排队时 A 操作自己的 run)均 fail-closed,与宿主路径同构。

P2 残留文件劫持——此前以「capability 文件存在」判定隔离态,SIGKILL/关闭
隔离后的残留文件会把健康宿主会话永久劫持到 relay(403 且不回退)。修法=
复用 resolveSessionContext 的既有优先级:可见的活体进程树 marker 一律优先
返回 null 走宿主路径;capability 文件只在 marker 不可见(bwrap 掩蔽
marker 目录+pid unshare / Seatbelt 拒读)时才作为探测回退。

回归:codex 最小复现(capability/turn=A、caller=B、run owner=B → 403)、
双 turn 指针一致性矩阵、活体 marker 压过残留文件、corrupt marker 同
resolveSessionContext 规则回退。隔离全量 566 files/9087 tests 除已记录
上游 vc-meeting-daemon-session 4 例外全绿。
代码复审通过,合入 Workflow v3 固化与 v2 runtime 下线改动。
PR deepcoldy#336 (45bc65e) 在接入 Genius 适配器时夹带引入了 ownedTopicGroupFollowup
无条件放行:单 bot 话题群里,bot 拥有话题会话后,任何有发言权限的人不 @ 也会
触发回复,不检查群内人数。c984c385 收紧到单 bot 群,但多人类维度仍放行。

本次移除该无条件放行,话题群与普通群统一走 regularGroupMentionMode
(群聊 @ 策略):
- 默认 'always':多人群里必须 @(恢复 deepcoldy#336 之前的契约)
- 'topic':话题内免@续话(原条款已同时覆盖话题群 thread 与共享话题)
- 'never'/'ambient':按各自语义生效
- 1人1bot solo 群仍走 userCount<=1 && botCount<=1 放行,体验不变

测试:翻转「话题群默认免@续话」断言为默认忽略;新增 mentionMode='topic'
话题群免@续话、solo 群免@保留两条回归。README 中「仅有一个机器人时无需 @」
表述同步修正。
@deepcoldy
deepcoldy merged commit 53518e5 into deepcoldy:master Jul 16, 2026
1 check passed
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.

6 participants