Skip to content

fix(sandbox): 修复 Claude 沙盒启动与消息提交#515

Merged
deepcoldy merged 3 commits into
masterfrom
codex/fix-claude-sandbox-startup
Jul 18, 2026
Merged

fix(sandbox): 修复 Claude 沙盒启动与消息提交#515
deepcoldy merged 3 commits into
masterfrom
codex/fix-claude-sandbox-startup

Conversation

@ejjcc

@ejjcc ejjcc commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

一句话说明

这次修复覆盖的是一条完整链路:

飞书首轮消息 → Worker 准备隔离环境 → Claude 启动并发出 ready → Worker 输入 prompt → Claude 写入 JSONL → botmux 确认消息已提交。

旧实现中,这条链路在 Linux 文件沙盒 / 读隔离组合下有多个相互放大的缺陷,因此不是单独补一个 hook 就能解决。

用户可见问题

开启沙盒后,新话题的第一条消息可能出现以下现象:

  1. Claude 已显示输入界面,但 botmux 仍等待约 45 秒。
  2. 超时后 botmux 才把首轮 prompt 写入终端。
  3. 较长的 XML、多行引用或单个长行触发 Claude Code 的粘贴检测。
  4. 反斜杠或换行留在输入框中,Enter 没有真正提交。
  5. Claude 会话 JSONL 中没有新增 user record,botmux 最终提示“未能确认提交”。

根因与对应修复

环节 旧实现的问题 本 PR 的处理
Claude 有效配置 隔离会话使用独立 CLAUDE_CONFIG_DIR,看不到共享 ~/.claude/settings.json 中的 provider env;SessionStart hook 也可能安装在 Claude 实际不读取的位置 每次冷启动把共享配置中的 env 合并到 per-bot settings.json,再把 botmux hooks 安装到同一个有效配置文件;文件权限收紧为 0600
ready 回调 沙盒会屏蔽 daemon registry,hook 即使执行,也无法通过原来的文件发现机制找到 daemon Worker 向隔离子进程注入 loopback IPC 端口;session-ready 使用该端口回调,并继续携带现有轮换 capability 做鉴权
ready gate Worker 只根据 adapter 声明就假设 hook 一定可用,hook 缺失或回调链路不可达时仍盲等 45 秒 启动前检查有效配置中的 hook、IPC 端口和 capability;任何一项缺失就不武装 ready gate,直接使用已有的 readyPattern + quiescence 路径
prompt 输入 原来只在“行与行之间”限速;单个超长行仍会一次性写入并触发 Claude Code paste detector 每个非空行按最多 96 UTF-8 字节拆块并限速发送;按 Unicode code point 切分,不会拆坏中文、Emoji 或代理对
HOME / project overlay 当工作目录就是 HOME 时,HOME overlay 与 project overlay 被连续 bind 到同一目标,后一个挂载会遮蔽前一个;upper/work 还可能位于自身 lower 内 cwd=HOME 时只创建一个最终生效的 project overlay;实际 upper/work 放在 /var/tmp/botmux-sbx/<session>/,稳定 land 路径保留为 symlink
JSONL bridge 代码固定认为 Claude 数据写入 home-upper,但 cwd=HOME 时真正生效的是 project overlay 根据实际 winning overlay 计算 Claude data dir:普通项目监听 home-upper,HOME 项目监听 proj-upper
敏感路径 mask symlink HOME 下目的路径不一致;缺失父目录会被误判成文件,后续再挂载子 mask 时出现 ENOTDIR 通过最深存在祖先进行规范化;若一个缺失路径包含其他待 mask 子路径,则按目录创建空挂载

修复后的启动时序

sequenceDiagram
    autonumber
    participant L as 飞书话题
    participant D as botmux Daemon
    participant W as Worker
    participant B as Linux bwrap / overlay
    participant C as Claude Code

    L->>D: 首轮消息
    D->>W: 初始化会话并暂存首轮 prompt
    W->>W: 合并共享 Claude env 到 per-bot settings
    W->>W: 安装 SessionStart / AskUserQuestion hooks
    W->>W: 创建轮换 capability 并注入 daemon IPC 端口
    W->>B: 准备 overlay、敏感路径 masks 和 outbox
    B->>C: 使用隔离后的配置启动 Claude
    C->>D: SessionStart hook 调用 /api/session-ready<br/>携带 sessionId 与 capability
    D-->>W: session_ready IPC
    W->>W: 释放首轮 ready gate
    W->>C: 按 96 UTF-8 字节分块、限速输入 prompt
    C->>B: 提交 user turn 并写入 JSONL
    B-->>W: Bridge 在实际 winning upper 中观察到记录
    W-->>D: 确认首轮消息已提交
Loading

这里的 daemon 端口本身不是凭证。/api/session-ready 仍校验当前会话的轮换 capability;只是把“如何找到 daemon”从被 mask 的 registry 文件,补充为 Worker 明确注入的 loopback 地址。

Overlay 特殊场景

普通项目目录与 cwd=HOME 的落盘位置不同:

flowchart LR
    subgraph Normal["普通项目:HOME 与 project 不重叠"]
        H1["真实 HOME lower"] --> HO["home overlay"]
        HU["/var/tmp/.../home-upper"] --> HO
        P1["真实 project lower"] --> PO["project overlay"]
        PU1["data/sandboxes/.../proj-upper"] --> PO
        HO --> CH1["Claude HOME"]
        PO --> CW1["项目工作目录"]
    end

    subgraph HomeProject["cwd = HOME:只保留一个 winning overlay"]
        H2["真实 HOME / project lower"] --> WO["project overlay"]
        PU2["/var/tmp/.../proj-upper"] --> WO
        WO --> CH2["Claude HOME 与项目工作目录"]
        LP["稳定 land 路径"] -. symlink .-> PU2
    end
Loading

兼容旧沙盒目录时:

  • proj-upper 为空:自动转换为外部 scratch + symlink。
  • proj-upper 非空:fail closed,保留原 changeset 并要求先 land / 备份,避免静默丢失 overlay whiteout 或 xattr。

代码落点

  • src/worker.tssrc/adapters/hook-installer.ts
    • 准备 Claude 隔离配置、同步 env、安装 hook、执行 ready preflight。
  • src/cli.tssrc/utils/daemon-discovery.tssrc/utils/ready-gate.ts
    • 完成隔离环境中的 daemon 回调与 gate 判定。
  • src/adapters/cli/claude-code.ts
    • 实现 UTF-8 安全的长行分块输入。
  • src/adapters/backend/sandbox.ts
    • 修复 HOME/project overlay 重叠、bridge 路径、外部 upper/work 和 mask 分类。
  • test/*
    • 覆盖 hook/env 继承、isolated ready CLI、HOME overlay、嵌套 mask、symlink HOME 和长 Unicode 行。

影响面

维度 影响
Linux + 文件沙盒 + Claude fresh spawn 主要修复场景
Claude read isolation per-bot settings 会在每次冷启动同步共享 provider env 与 hooks
其他 CLI 的 Linux 文件沙盒 共享 HOME 重叠和 mask 分类逻辑会受益;没有修改它们的输入协议
sandbox off 不进入 overlay / 隔离回调路径,行为不变
macOS / Windows 不走 Linux bwrap overlay,行为不变
adopt / persistent backend reattach 仍不武装 fresh SessionStart ready gate,恢复语义不变
网络策略 没有新增外网权限,也没有改变当前 sandbox 网络策略

部署与会话兼容

  • 修复对新建或冷启动的沙盒会话生效。
  • 已经运行的 persistent pane / bwrap 仍保留创建时的挂载与配置;需要 suspend 后由下一条消息冷启动,或新建话题验证。
  • ready preflight 的策略是 fail open to existing readiness detection,而不是 fail open to unsandboxed execution:沙盒创建失败仍保持原有 fail-safe,不会静默退化为无沙盒运行。

验证

  • pnpm build:通过,且已 rebase 到最新 origin/master 后重跑。
  • 定向回归:6 个文件、224 个测试全部通过。
    • test/sandbox.test.ts
    • test/hook-installer.test.ts
    • test/ready-gate.test.ts
    • test/daemon-discovery.test.ts
    • test/write-input.test.ts
    • test/session-ready-cli.test.ts
  • dist / CLI 回归:4 个文件、68 个测试全部通过。
    • test/workflow-cli.test.ts
    • test/preset-export-cli.test.ts
    • test/whiteboard-cli.test.ts
    • test/workflow-c0-isolation.test.ts
  • GitHub Actions build:通过。
  • 飞书 live 实测:
    • sandbox on 的 Claude 会话中,一条 633 字符首轮消息成功写入会话 JSONL,并收到 assistant 回复。
    • 隔离配置中同时存在 SessionStart hook、provider auth 和 base URL。
    • cwd=HOME 使用单一 project overlay,不再出现 HOME overlay 被遮蔽。

本机完整测试基线说明

完整 pnpm test 试跑未宣称全绿:本机 Node 24 / Linux 环境中,test/v3-worker-fence.test.ts 有 3 个进程发现用例返回 fail-closed ambiguous;已在临时 detached、未修改的 origin/master 上复现完全相同结果,与本 PR 无关。

此外还有本机 npm EALLOWSCRIPTS/proc/self/comm=MainThread 等宿主环境项。最初并行 build/test 导致的 dist/cli.js missing 已改为串行补跑,相关 68 个用例全部通过。

@ejjcc
ejjcc requested a review from deepcoldy as a code owner July 17, 2026 10:56

@ejjcc ejjcc left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

整体方向和覆盖面都很好,HOME overlay、IPC capability、Unicode 分块的处理也比较扎实。但这里有两处冷启动状态同步/预检问题,建议合入前修掉:

  1. src/adapters/hook-installer.ts:188-195:全局 Claude env 只做 additive merge,无法传播删除。假设第一次冷启动继承了 ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL,之后管理员从共享 ~/.claude/settings.json 删除这些键(切回 OAuth、撤销代理或 provider),下一次冷启动仍会从 per-bot settings.jsonlocalEnv 保留旧值。结果是已撤销的凭证和 provider 配置会无限期继续生效,与“每次冷启动从 authoritative global config refresh”相反。需要区分 per-bot-only 键和上次继承的键,或直接以共享 env 替换受管理的 provider/auth/proxy 键;请补一个“共享键被删除后本地同步删除”的测试。

  2. src/adapters/hook-installer.ts:161-168:ready preflight 没有验证当前 sessionStartCommand,只要配置里存在任意 *cli.js session-ready 就返回 true。installHook() 本身会吞掉写入错误;因此升级后若旧 checkout 的 hook 仍在、但本次因权限/只读文件等原因未能替换,预检会错误通过并武装 gate,旧命令执行失败后首条消息仍会等完整 timeout——正是本 PR 想消除的失败模式。这里应至少比对规范化后的当前 command(或验证其中 node/cli.js 路径仍可执行),并补 stale-hook + install failure 的回归测试。

CI build 当前是绿的;以上是静态路径 review 发现的两个阻塞项。由于该 PR 来自当前 GitHub 账号,平台不允许 self-review 使用 Request changes,因此以 comment review 提交。

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

发现 1 个仍需合入前处理的 ready-preflight 问题:

[P1] src/worker.ts:6484-6488:capability 预检只判断路径存在,可能对不可读/陈旧 token 错误放行。

publishSandboxRelayCapability()replaceManagedOriginCapabilityFile() 写失败后只记日志,仍会把新的 token 赋给 sandboxRelayCapability;这里随后只检查 existsSync(readyCapabilityPath)。因此旧文件、坏 JSON,尤其是同名目录都会让预检返回 true。Linux sandbox 的 outbox 对子进程可写,上一代 CLI 可以留下这类状态;例如 capability 路径为目录时,下一次原子 rename 返回 EISDIR,但 existsSync 仍为 true。worker 会武装 ready gate,而 botmux session-ready 读不到当前 capability,daemon 返回 403,首条消息又会等待完整 READY_SIGNAL_TIMEOUT_MS,正好绕过本 PR 新增的 fail-fast 目标。

本地最小复现得到:publishError=EISDIR, exists=true, readableClaim=null。建议让 publish 返回明确成功状态,或在预检中用 readManagedOriginCapability(...) 读取并比对 sandboxRelayCapability.token;同时补 stale/invalid/directory capability path 的回归测试。

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

复审通过。原 blocker 已由 bf1e49f7 闭合:

  • capability 写盘失败时撤销旧 generation,不再发布新内存/daemon token;
  • ready preflight 实读 claim 并比对当前 token,拒绝 stale、malformed、directory/missing transport;
  • 新增异常路径回归测试。

验证:pnpm build 通过;相关 9 个测试文件 274/274 通过;GitHub Actions build 通过。完整 unit 9443/9446,剩余 3 个仍为本 PR 未改动的 scheduler 本地时区基线失败。

@deepcoldy
deepcoldy merged commit 2efec2b into master Jul 18, 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.

2 participants