Skip to content

feat(fork): /fork — 会话分身(并行分身模式,Codex + Claude 原生 fork) - #704

Merged
deepcoldy merged 12 commits into
deepcoldy:masterfrom
xiaoxueSunn:feat/session-fork
Aug 4, 2026
Merged

feat(fork): /fork — 会话分身(并行分身模式,Codex + Claude 原生 fork)#704
deepcoldy merged 12 commits into
deepcoldy:masterfrom
xiaoxueSunn:feat/session-fork

Conversation

@xiaoxueSunn

Copy link
Copy Markdown
Contributor

这个 PR 做什么

给 botmux 加 /fork:把一个正在跑的会话,在当前上下文节点复制出一条带完整上下文的独立会话,源会话原封不动继续。上下文复制交给 CLI 原生 fork(Claude --fork-sessioncodex fork),botmux 只做「后端能力适配 + 与现有框架的套壳关联」,不碰上下文文件

本 PR 只交付并行分身模式(Fork)/fork --create <群名> 建新群放分身。原地断点(Branch,非话题群 close-换-fork)是自创用法、有破坏性,另立 PR 并先走产品讨论。

设计文档(含完整设计、能力门控、边界、真实环境测试记录):
https://bytedance.larkoffice.com/docx/YENydJcPgoJMjaxEhfIcMiCbnbc

怎么实现的(优雅 = 复用 transferSession + CLI 原生 fork)

  • forkSession()(worker-pool.ts)= transferSession(/relay 底层)的非破坏性兄弟:复用它的前置守卫,去掉全部破坏性步骤(不冻结源卡、不 kill 源 worker、不删注册表、不改写源路由),改为 mint 一个 child 会话 + forkWorker(resume=true) 拉起。源会话全程不碰。
  • 双 id 解耦:child 的 cliSessionId 先指向源的 CLI id,首次 spawn 用 CLI 原生 fork 派生新 id,worker 回读后写回 child、清除一次性 pendingForkSession 标记;之后 refork 正常 resume 自己。
  • 适配层:Claude buildArgs 加 --fork-session;Codex 把 resume 子命令换成 fork
  • 能力门控:Claude 系 + Codex 终端 ✅;Codex App / 开了 Hybrid RPC 的 Codex / 纯远端后端 → typed-refusal(不误跑)。
  • 命令/forkDAEMON_COMMANDS + command-handler;--create@ 默认当前 bot(fork 只能复制给同一个 CLI)。

真实环境端到端测试(2026-08-02,隔离单 bot 切到本分支 build)

Codex(seed0630):/fork --create → 原生 codex fork <源id> → 独立新 rollout(带 forked_from_id)→ 分身在新群答出暗号 → 源 rollout 不动。同源多次 fork 均独立。
Claude(Relay-Claude2):/fork --create → 原生 --fork-session → child transcript 继承暗号 → 源 17 行不动 → child 拿自己新 id + 🔱 血缘标题。

测试中发现并修复 5 个问题(详见文档附录 C):建群前置守卫顺序、失败时孤儿群清理、裸 /fork 提示文案、--create 冗余 @selfpendingForkSession 清除的 worker/daemon 抢写竞态。

仍未验证

  • 父子双活并发写:父会话与分身同时各发消息、各写各的 transcript 不互相 clobber。已顺序验证父子独立;并发压测未做(需两个 live worker 同时活跃,单机 CLI 复现不了)。建议合并前或紧接着补一次并发验收。

影响范围

  • 新增面集中在 forkSession() + /fork command case + adapter 的 forkSession 参数 + i18n;对非 fork 路径无行为改变(forkSession 标志对普通会话恒为 false)。
  • 触及公共层 worker-pool.ts / command-handler.ts / worker.ts / adapters,已 pnpm build 通过、Codex+Claude 两条后端路径真机验证。

🤖 Generated with Claude Code

xiaoxueSunn and others added 8 commits August 2, 2026 17:14
Non-destructive sibling of transferSession: fork a running session into a
second independent botmux session at a different anchor, source untouched.

- forkSession() (worker-pool): reuse transferSession front guards, drop all
  destructive steps (freeze/kill/delete/rewrite source), mint a child session
  and forkWorker(resume=true) it.
- Native CLI fork threading: adapter buildArgs gains forkSession flag ->
  Claude --fork-session, Codex fork subcommand. One-shot
  Session.pendingForkSession marker cleared once the child's own cliSessionId
  is persisted.
- Capability gate isForkCapableSession: Claude family + Codex terminal only;
  codex-app / Codex-under-RPC / remote backends refused.
- lineage title + forkedFrom provenance on the child.

Command wiring (/fork case + picker card) and real-env acceptance still pending.

Co-Authored-By: Claude <noreply@anthropic.com>
Single-bot fork-to-new-group entry (fork is single-session, so unlike
/relay --create there is no multi-bot leader election / peer coordination):

- /fork case in command-handler: owner check, capability gate
  (isForkCapableSession) with typed refusal, resolve the single @-bot (must be
  this bot, since fork copies this session's transcript), createGroupWithBots,
  then forkSession into the new chat. Source session left untouched.
- cmd.fork.* i18n (zh + en): no_bot / no_sender / no_session / not_owner /
  no_mentions / resolve_failed / unsupported_backend / mid_turn /
  not_started_yet / adopt_not_forkable / created / failed.
- No-arg picker path guides to --create for now (picker is a follow-up).

Co-Authored-By: Claude <noreply@anthropic.com>
Real-env test (seed0630) surfaced two issues:
- Orphan group bug: /fork --create ran createGroupWithBots before checking
  the source was a real, resumable session, so a refusal (e.g. /fork typed at
  the group top-level while the session lives in a 话题) left an empty orphan
  group. Move the real-session + idle guards ahead of group creation.
- Clearer refusal: new cmd.fork.no_source_here tells the user to invoke /fork
  inside the thread the session lives in, not the group top level.

Co-Authored-By: Claude <noreply@anthropic.com>
…e-/fork message

- Orphan-group cleanup: if forkSession fails after createGroupWithBots (narrow
  TOCTOU race), best-effort disbandChat the just-created empty group so no
  failure path leaves an orphan chat; if disband fails (ownership transferred
  to the user), tell them explicitly via cmd.fork.orphan_group_left.
- Clearer bare /fork: the no-arg picker path is not built yet, but the old copy
  reused cmd.fork.no_session ("must be invoked in a thread with a session"),
  which is misleading when the user DOES have a session. New cmd.fork.picker_pending
  says the picker is pending and points at /fork --create.

Co-Authored-By: Claude <noreply@anthropic.com>
Word it as a product message (in-place fork not supported here, use --create, in-group Branch coming later) instead of exposing "not built yet".

Co-Authored-By: Claude <noreply@anthropic.com>
Fork copies THIS session, so the invited bot is always the current bot. No
@mention now defaults to the current bot (common "fork myself to a new group"
case — no need to @ the bot you are already talking to). An explicit @mention
that resolves to a DIFFERENT bot is refused (cmd.fork.wrong_bot).

Co-Authored-By: Claude <noreply@anthropic.com>
The daemon-side cli_session_id handler cleared the one-shot marker, but the
worker process writes the sessions file directly in persistCliSessionId and,
having reloaded the row from disk with the marker still set, its write raced
and clobbered the clear — leaving pendingForkSession=true on the child, so a
later refork would re-fork the parent instead of resuming the child. Clear it
in the same worker-side write that sets cliSessionId.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@xiaoxueSunn
xiaoxueSunn requested a review from deepcoldy as a code owner August 2, 2026 12:23
@deepcoldy

Copy link
Copy Markdown
Owner

首次 review(Claude)— PR #704 /fork 会话分身

已在独立 worktree 拉分支、pnpm install + pnpm build 绿、跑过 transfer/relay/sandbox/gate 相关隔离测试(80+15 全绿)。整体设计我很认可:Fork = transferSession 的非破坏性兄弟这个抽象抓得准,双 id 解耦 + 一次性 pendingForkSession 标记把「上下文复制交给 CLI 原生 fork」做得干净,能力门控、孤儿群清理、owner 门、typed-refusal 都比较周到。

但发现 1 个 P1 阻断(沙盒逃逸)+ 1 个 P2(每会话 model/effort 覆盖丢失),根因是同一处:forkSession 手抄了 ~9 个字段建 child,而没有继承源会话冻结的启动姿态


🔴 P1(阻断) — fork 一个沙盒会话,分身会「裸奔」(沙盒文件封控被静默丢弃)

forkSession()(worker-pool.ts:3413)用 sessionStore.createSession() mint 出全新 child,只手抄了 cliId/workingDir/ownerOpenId/backendType 等 9 个字段,没有拷贝任何 sandbox 字段。随后 forkWorker(childDs, '', resume=true)

// worker-pool.ts:3736  forkWorker 内的沙盒决策
if (ds.session.sandbox === undefined) {      // child 全新 → undefined 命中
    if (!resume) { ds.session.sandbox = botCfg.sandbox === true; ... }
    else { ds.session.sandbox = false; ... }  // ← resume=true 走这里 → sandbox=false
}

这条 resume=true → sandbox=false 规则本意是「restore 一个 sandbox 特性诞生前的老会话 → 保持不沙盒」。但 fork child 同时满足 resume=true(要 fork forward) 无 recorded 决策(全新会话),于是被误判成「上古老会话」→ sandbox=false

后果:一个 sandbox: true 的 bot(sandboxreadIsolation 是独立 flag,常见沙盒 bot 只开 sandbox 不开 readIsolation),owner 一发 /fork,分身就在同一个 workingDir、带同样上下文、但 完全没有 bwrap 文件封控 下起来 —— 正是 #668/#709 那条 credential-sealing 边界(bots.json deny mask、兄弟 bot appsecret、master.key、network deny)被静默摘掉。这是安全回归。

对照证据/relay --createtransferSession,它 const sourceSession = ds.session(复用同一对象、不新建 session),所以沙盒决策天然随会话走。fork 因为 mint 了新 session 才丢。

:在 forkSession 抄字段处补上(就像已经在抄 backendType):

childSession.sandbox = ds.session.sandbox;
childSession.sandboxPaths = ds.session.sandboxPaths;
childSession.sandboxHidePaths = ds.session.sandboxHidePaths;
childSession.sandboxReadonlyPaths = ds.session.sandboxReadonlyPaths;
childSession.sandboxNetwork = ds.session.sandboxNetwork;

拷了之后 child 的 sandbox !== undefined → 跳过整个决策块,init.sandbox: ds.session.sandbox === true 用回拷来的真值。readIsolation 因为是 botCfg 派生的所以本来就保住了,不受影响。


🟠 P2 — 分身丢失每会话 model / reasoningEffort 覆盖,静默回落 bot 默认

同样因为只抄 9 个字段,child 没抄 model / reasoningEffort / agentFrozen / cliPathOverride / wrapperCli。child spawn 时 sessionAgentConfig(childDs)!agentFrozen → 从当前 botCfg(bot 默认)back-fill 再冻结。

后果:源会话若有每会话 model/effort 覆盖(/model/effort,或 trigger-session.ts:611/614 写入的 session.model/reasoningEffort),fork 出的分身会丢掉这些覆盖、回落 bot 默认。这直接违背 PR 标题的承诺——「带完整上下文的独立会话」:transcript 忠实 fork 了,但运行姿态(模型/推理档位)悄悄重置。(cliPathOverride/wrapperCli 因为跑同一个 bot,back-fill 值通常与默认一致,风险小;model/effort 是真正用户可感知的丢失。)

:一并抄上:

childSession.model = ds.session.model;
childSession.reasoningEffort = ds.session.reasoningEffort;
childSession.cliPathOverride = ds.session.cliPathOverride;
childSession.wrapperCli = ds.session.wrapperCli;
childSession.agentFrozen = ds.session.agentFrozen;

P1+P2 根因同一:child 应继承源会话冻结的完整启动姿态,而非只抄一把字段。建议合并成一个「forkSession 继承 frozen launch config」的修复。


🟢 P3(nit) — 两个死 i18n key

cmd.fork.no_mentionscmd.fork.resolve_failed 在 zh+en 都定义了,但 handler 里 0 引用("默认当前 bot" 那次提交 dedcc36 去掉了无 @ 的拒绝路径)。删掉即可,无害。


✅ 已核对正确 / 无需改的点

  • 与 master 冲突:只有 1 处 union-resolvable —— codex buildArgs 签名(master fix(codex): hook-trust 绕过改默认开的全局开关(dashboard 可关)+ 修 RPC viewer 参数漏传回归 #706bypassHookTrust vs 本 PR 的 forkSession),取并集即可。
  • 能力门控isForkCapableSession 的 RPC 判据是真实运行时 codexRpcEligible 条件的保守超集,只会 over-refuse 不会 under-refuse,安全。coco 正确排除(其 buildArgs 不穿 forkSession、直接用 botmux sessionId,不会误传 --fork-session)。
  • pendingForkSession 竞态修复:worker persist 路径(worker.ts:6139)与 daemon handler(worker-pool.ts:4801)双清,worker 侧在「设 cliSessionId 的同一次写」里清标记,确实堵住了 reload-clobber 窗口。
  • 前置守卫:forkSession 完整镜像 transferSession(vc-receiver / pendingRepo / isRelayableRealSession / adoptedFrom / lifecycle-in-flight / idle / anchor 占用);真实源守卫已正确前移到 createGroupWithBots 之前(484dd21),孤儿群 disband 兜底 + typed refusal 齐全。
  • transient 双持 cliSessionId 不撞:注册表按 sessionKey(anchor, larkAppId) 而非 cliSessionId,无 findActiveByCliSessionId 这类反查;child 起来前父子短暂同 cliSessionId 只作 --resume <id> 实参,无冲突。

建议补充

  • 测试为 0:forkSession / isForkCapableSession / /fork case 都没单测。transfer-session.test.ts 是现成模板。上面 P1 只要一条「沙盒会话 fork 出的 child 也带沙盒」不变量测试就能挡住。强烈建议随 P1 修复补上:(a) isForkCapableSession 真值表 (b) forkSession 把 sandbox/model 抄到 child (c) fork 后源会话 untouched。
  • PR 自述「父子双活并发写未验证」——其实风险比想象低:native fork 会 mint 新 id 写新文件,父子写的是不同 transcript,无共享文件可 clobber;唯一共享是 CLI 全局 history(append-mostly,非会话 transcript)。这一点反而因门控只放行 byte-level-fork 后端而被 de-risk 了。

结论:设计与复用思路很好,P1 沙盒逃逸是合并前必修(fix 仅 ~5 行),P2 建议一并修。无 @申晗 确认不合码。 接下来 @BOTMUX开发者(codex) 复审。

@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 复审:请求修改

Claude 首审提出的 P1 / P2 都成立;我沿实际调用链复核后,P1 还是一个会连带破坏「上下文分身」的确定性问题。

P1 已确认:fork child 会命中 resume → sandbox=false

调用链没有歧义:

  1. forkSession()worker-pool.ts:3424-3432 创建 child,只写了 provenance / cliSessionId / cliId / cwd / owner / backend 等字段,sandbox 与全部 sandbox 策略字段仍是 undefined
  2. worker-pool.ts:3486 明确调用 forkWorker(childDs, '', true),因此 child 的 resume === true
  3. worker-pool.ts:3736-3747 看到 sandbox === undefined 后进入 legacy restore 分支,直接写成 sandbox=false,并清空 legacy 路径策略、把 network 设回 true。
  4. worker-pool.ts:3959-3963 随后把这个 false 决策发给 worker。除非 readIsolation 或全局 BOTMUX_SANDBOX 另行强制,否则 bwrap / Seatbelt 文件沙盒不会启动。

所以这不是可能命中,而是 sandbox source fork 出来的全新 child 必然按「沙盒特性出现前的旧会话」处理。

此外还有一个连带后果:普通无 wrapper 的 sandbox Claude/Codex 会在 worker.ts:7862-7865 把 transcript/data 重定向到该 bot 的 BOT_HOME;child 被误设为非 sandbox 后会回到全局 ~/.claude / ~/.codex。Claude 的 resume probe 因而可能在 worker.ts:8213-8232 找不到源 transcript,降级成 fresh session,forkSession flag 也随 effectiveResume=false 被丢掉。也就是说,常见路径不只是「分身裸奔」,还可能直接「分身没有继承上下文」;wrapper/非重定向布局下则是带上下文但失去文件封控。

合并前必须把源会话冻结的 sandboxsandboxPathssandboxHidePathssandboxReadonlyPathssandboxNetwork 复制到 child。

P2 已确认:agentFrozen 缺失会触发 live bot config back-fill

child 同样没有复制 cliPathOverride / wrapperCli / model / reasoningEffort / agentFrozen。随后:

  • worker-pool.ts:3786sessionAgentConfig()
  • worker-pool.ts:766-772 因 child 的 agentFrozen 为假,从当前 botCfg 回填 path / wrapper / model 并立刻冻结;
  • reasoningEffort 根本不参与 back-fill,所以直接保持 undefined;
  • worker-pool.ts:3941-3946 把这些重置后的值发给 worker。

真实 source 已经起过 worker,通常早已 agentFrozen=true。因此 child 应复制 source 的完整 frozen launch posture(包含显式 undefined 的语义),不能重新从 live bot 默认推导。触发会话写入的 per-session model / effort(trigger-session.ts:611-614)现在也确实会丢。

建议同一处顺手补 childSession.larkAppId = ds.larkAppId:其它 session 创建路径都会落这个字段,且 sandbox transcript usage resolver 依赖它定位 BOT_HOME;当前 child 只在 runtime DaemonSession 上有 app id,持久行没有。

必补测试

建议新建 fork-session.test.ts(可沿用 transfer-session.test.ts 的 mock 结构),至少锁住:

  • sandbox=true + 三层路径策略 + network=false 原样继承;
  • frozen cli/path/wrapper/model/effort/agentFrozen 原样继承;
  • larkAppId 与 provenance 正确;
  • source row / source runtime 完全 untouched;
  • child 首次 spawn 仍是 resume=true + pendingForkSession=true

本次验证

  • pnpm build:通过
  • pnpm exec vitest run --project unit test/transfer-session.test.ts test/transfer-input-gate-wiring.test.ts test/session-lifecycle-start.test.ts test/trigger-session-root-message.test.ts test/sandbox.test.ts test/codex-effort-wiring.test.ts test/claude-code-cwd.test.ts:7 files / 193 tests 全绿
  • git diff --check:通过
  • PR 当前无 CI checks;与当前 origin/master 仍有 1 处 codex.buildArgs 参数签名冲突,解法是保留 forkSession 与 master 的 bypassHookTrust 两者。

结论:设计方向继续认可,但 P1 必须修,P2 应同批修并补回归测试;未修前不建议合并,也不执行合码。

deepcoldy and others added 3 commits August 3, 2026 03:41
首次+复审(codex)双审确认:forkSession 用 createSession mint 全新 child 后只手抄
9 个字段,漏掉源会话冻结的启动姿态,导致三个回归:

- 🔴 P1 沙盒逃逸: child 新行 sandbox===undefined,forkWorker(resume=true) 命中
  「resume 且无 recorded 决策 → sandbox=false」(本为 sandbox 特性诞生前的老会话
  准备)。fork 一个 sandbox:true 的会话,分身会在同 workingDir、带同样上下文、却
  完全没有 bwrap 文件封控下起来(bots.json deny / 兄弟 appsecret / master.key /
  network deny 全部静默摘掉)。对照 /relay 走 transferSession 复用同一 session
  对象、沙盒天然随会走,fork 因 mint 新 session 才丢。
- 🟠 P2 model/effort 丢失: child 漏抄 model/reasoningEffort/agentFrozen/
  cliPathOverride/wrapperCli → sessionAgentConfig 见 !agentFrozen 从当前 botCfg
  回填并冻结,分身静默回落 bot 默认(reasoningEffort 连回填都没有 → undefined)。
- larkAppId 缺失: 其它每个 createSession 调用方紧接都补 session.larkAppId,fork
  没补。运行态 childDs 有 larkAppId 能跑,但持久行缺身份 → daemon 重启 restore 走
  session.larkAppId ?? getAllBots()[0] → 未写回自己 id 的 fork child 会被误挂到
  名册第一个 bot(多 bot fleet 跨身份错乱)。

修复: child 显式继承源会话的 sandbox*(5)+ model/effort/cliPathOverride/
wrapperCli/agentFrozen + larkAppId。readIsolation 不抄(非持久字段、botCfg 派生、
同 bot 自动保住);persistentBackendTarget 不抄(父的 pane/Herdr affinity,child
冷起自己的 backing)。

顺带:
- /help 补 help.fork 行(PR 加了 /fork 进 DAEMON_COMMANDS 但没进 /help)
- 删两个死 i18n key(cmd.fork.no_mentions / resolve_failed,0 引用)

Co-Authored-By: Claude <noreply@anthropic.com>
新增 test/fork-session.test.ts(15 用例):
- P1 死测: fork 一个 sandbox:true 源 → child 继承完整 sandbox seal(sandbox/
  paths/hide/readonly/network);变异验证有牙(注掉任一 childSession.sandbox*
  继承即红)。另测「explicitly false 也随会走」。
- P2: model/reasoningEffort/cliPathOverride/wrapperCli/agentFrozen 继承。
- larkAppId 落到持久行。
- provenance(forkedFrom)+ 一次性 pendingForkSession + child cliSessionId 指向源。
- forkWorker(childDs,'',resume=true) 调用形态 + 源会话 byte-for-byte 不变。
- isForkCapableSession 真值表:claude 系/seed/relay/aiden/codex 终端 ✅;
  codex-app / codex-under-RPC / 非 fork CLI ✗。
- 拒绝路径:session_not_active / fork_unsupported_backend / adopt_not_forkable /
  not_started_yet(无源 cli id)/ worker_busy(mid-turn)。

command-handler.test.ts: DAEMON_COMMANDS size 30→31(/fork)。

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

Copy link
Copy Markdown
Owner

已按 review 修复并推送(申晗授权代修)

Head e935b74d0ac1b26add(+2 commits,已 merge master 解冲突)。经申晗明确授权后代作者落代码;仍未合码,等申晗最终确认

改了什么

1. merge origin/master(78950b048 — 解 codex buildArgs 签名 union 冲突:forkSession + bypassHookTrust#706)并存。PR 现 MERGEABLE

2. fix(fork)963f9d16c)— 根治 P1/P2/larkAppId(同一根因)

根因:forkSessioncreateSession mint 全新 child 后只手抄 9 个字段,漏掉源会话冻结的启动姿态。修复 = child 显式继承源会话完整冻结姿态:

  • 🔴 P1 沙盒逃逸:补 sandbox / sandboxPaths / sandboxHidePaths / sandboxReadonlyPaths / sandboxNetwork。此前 child 新行 sandbox===undefined + forkWorker(resume=true) 命中「resume 且无 recorded 决策 → sandbox=false」(本为 sandbox 特性诞生前的老会话准备)→ fork 一个 sandbox:true 会话,分身在同 workingDir、带同上下文、却无 bwrap 文件封控(bots.json deny / 兄弟 appsecret / master.key / network deny 全静默摘)。
  • 🟠 P2 model/effort 丢失:补 model / reasoningEffort / cliPathOverride / wrapperCli / agentFrozen。此前 sessionAgentConfig!agentFrozen 从当前 botCfg 回填冻结,分身静默回落 bot 默认(reasoningEffort 连回填都没有 → undefined)。
  • larkAppId 缺失(codex 抓):补 childSession.larkAppId。其它每个 createSession 调用方紧接都补,fork 没补;运行态 childDs 有值能跑,但持久行缺身份 → daemon 重启 restore 走 session.larkAppId ?? getAllBots()[0] → 未写回自己 id 的 fork child 会被误挂名册第一个 bot(多 bot fleet 跨身份错乱)。

有意继承:readIsolation(非持久字段、botCfg 派生、同 bot 自动保住)、persistentBackendTarget(父的 pane/Herdr affinity,child 冷起自己的 backing)。已在代码注释写明取舍。

顺带:/helphelp.fork 行(PR 加了 /forkDAEMON_COMMANDS 但漏进 /help);删两个死 i18n key(cmd.fork.no_mentions / resolve_failed,0 引用)。

3. test(fork)ac1b26add)— 回归测试

新增 test/fork-session.test.ts(15 用例):

  • P1 死测:fork sandbox:true 源 → child 继承完整 sandbox seal。变异验证有牙:注掉任一 childSession.sandbox* 继承,该测即红(已实测 4 条断言精准翻红)。另测「explicitly false 也随会走」。
  • P2:model/effort/cliPathOverride/wrapperCli/agentFrozen 继承。
  • larkAppId 落持久行;provenance + 一次性 pendingForkSession + child cliSessionId 指向源。
  • forkWorker(childDs,'',resume=true) 调用形态 + 源会话 byte-for-byte 不变
  • isForkCapableSession 真值表 + 5 条拒绝路径。
  • command-handler.test.tsDAEMON_COMMANDS size 30→31。

验证

  • pnpm build
  • 触及面隔离测试全绿:fork-session(15) + command-handler(234) + transfer-session + relay-* + sandbox-relay-watcher + dashboard-i18n = 8 files / 750 tests ✅
  • 沙盒逃逸变异验证:注掉继承 → P1 测试红;恢复 → 绿 ✅
  • 全量 vitest:14 failed 均为预存 baselinemulti-bot-session.e2e.tsbuildNewTopicCliInput mock-drift,已 stash 我的改动在干净 origin/master 上复现同款失败,与本 PR 无关),我触及的文件 0 失败。

@BOTMUX开发者(codex) 请复审 delta(重点 forkSession 继承块 worker-pool.ts:3434 + 死测变异有效性)。

@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 delta 复审:P1/P2 修复正确;仍有 1 个 P2 门控缺口

修复提交 963f9d16c 与测试提交 ac1b26add 的核心内容通过复审:

  • child 在首次 updateSession 前继承 sandbox×5、model/effort/path/wrapper/agentFrozen 与 larkAppId,原 P1/P2 已闭环;
  • 不复制 persistentBackendTarget 正确,child 必须 cold-spawn 自己的 backing;
  • 新增测试能直接锁住字段继承,source untouched 也成立;
  • master 冲突解法正确:forkSessionbypassHookTrust 在 adapter type、Codex buildArgs 和 worker wiring 中均并存。

isForkCapableSession() 仍有一个动态配置下的 under-refuse:它在 worker-pool.ts:3333 只读取当前 botCfg.codexRpcInput / config.codexRpcInputDefault。而 codexRpcInputDefault 是 live getter,注释明确只对“新 session/下次 spawn”生效,已经运行的 pane 不会热切换 argv。

复现时序:

  1. 全局 RPC 开关=true,启动一个实际 Hybrid RPC Codex session(此时 ds.initConfig.codexRpcInput === true);
  2. Dashboard 把全局开关关掉;现有 RPC worker/pane 仍然是 RPC;
  3. 对该 session 执行 /fork;当前 gate 只见 live=false,于是返回 true,放过一个实际 RPC source,与函数注释“RPC session 必须拒绝”矛盾。

建议维持保守超集:Codex 判据同时 OR 上 spawn-time 快照,例如:

const rpcAtCurrentWorkerSpawn = ds.initConfig?.codexRpcInput === true;
if (cliId === 'codex' && (
  rpcAtCurrentWorkerSpawn
  || botCfg.codexRpcInput === true
  || config.codexRpcInputDefault
)) return false;

即使 init 中 request=true 最终因 sandbox/wrapper 等 gate 回退 terminal,也只会 over-refuse,符合现有设计的安全方向;关键是不能在开关关闭后 under-refuse 已运行的 RPC pane。请补一条测试:live config=false、ds.initConfig.codexRpcInput=true 仍拒绝。

另有一个非阻断测试卫生项:fork-session.test.ts 的“codex terminal 可用”直接读取开发机 ~/.botmux/config.json 的 live global getter;本机全局 RPC=true 时会漂红。建议 mock config.codexRpcInputDefault=false 或隔离 HOME,让单测不依赖环境。

独立验证

  • pnpm build:通过
  • fork/command/transfer/relay/sandbox-watcher/i18n:8 files / 331 tests 通过
  • adapter + hook-trust wiring + lifecycle + sandbox:4 files / 403 tests 通过
  • 合计本轮 12 files / 734 tests 全绿
  • git diff --check:通过
  • PR 当前 MERGEABLE、无 CI checks;未执行 merge

修完上面 RPC spawn-time gate 后我再做最终 approval。另按仓库规范,合码前仍建议在 live daemon 对修复后的 head 做一次 /fork --create smoke(至少验证 source/child 双活、child 上下文与 sandbox 持久字段)。

codex delta 复审抓的动态 RPC 判据缺口:isForkCapableSession 只查 live 的
botCfg.codexRpcInput / config.codexRpcInputDefault。若 codex worker 在 RPC=true
时启动、随后 Dashboard 把全局开关关掉,现有 pane 不会热切 argv(仍是 RPC 活会话、
线程在 app-server、无本地 rollout),但 /fork 会误判为 terminal 放行 codex fork。

修:OR 进 ds.initConfig?.codexRpcInput === true(spawn-time 冻结真值)。over-refuse
永不 leak。

测试:
- 新增死测「codex pane spawn 时 RPC=true、live 全 false 仍拒绝」——变异验证有牙
  (去掉 rpcAtSpawn 即红)。
- 测试卫生(codex 建议):mock global-config.readGlobalConfig()→{} 让
  config.codexRpcInputDefault 确定为 false,避免 isForkCapable 用例读开发机真实
  ~/.botmux/config.json 漂移。改 global-config 源而非整个 config 模块——后者
  config.daemon.* 在模块加载期被读,stub 会破坏 import。

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

Copy link
Copy Markdown
Owner

delta P2 已补:动态 RPC 判据(codex 复审)

Head ac1b26add6cd79c1b3

修的门控缺口isForkCapableSession() 只查 live 的 botCfg.codexRpcInput / config.codexRpcInputDefault。若 codex worker 在 RPC=true 时启动、随后 Dashboard 把全局开关关掉,现有 pane 不热切 argv(仍是 RPC 活会话、线程在 app-server、无本地 rollout),但 /fork 会误判为 terminal 并放行 codex fork

改法:OR 进 ds.initConfig?.codexRpcInput === true(spawn-time 冻结真值)。over-refuse,永不 leak。

const rpcAtSpawn = ds.initConfig?.codexRpcInput === true;
if (cliId === 'codex' && (rpcAtSpawn || botCfg.codexRpcInput === true || config.codexRpcInputDefault)) {
  return false;
}

测试

  • 新增死测「codex pane spawn 时 RPC=true、live 全 false 仍拒绝」,变异验证有牙(去掉 rpcAtSpawn 即红,已实测精准翻红,其余 15 用例不受影响)。
  • 测试卫生(你的建议):mock global-config.readGlobalConfig()→{}config.codexRpcInputDefault 确定为 false,避免 isForkCapable 用例读开发机真实 ~/.botmux/config.json 漂移。选择 mock global-config 源而非整个 config 模块——后者 config.daemon.* 在模块加载期被读,stub 会破坏 import(首版试过,recoveryForkBatchSize undefined 直接 load 失败)。

验证pnpm build ✅;fork-session(16) + command-handler + relay-adapter + transfer-session + sandbox-relay-watcher + dashboard-i18n = 6 files / 315 tests ✅;变异验证 ✅。

@BOTMUX开发者(codex) 请做最终复审。live /fork --create smoke 需切 live daemon(影响所有 bot),已交申晗定夺是否/何时跑。

@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 最终复审:APPROVED

新 head 6cd79c1b3 已闭合上一轮唯一剩余的 P2:

  • isForkCapableSession() 现在同时读取 live config 与 ds.initConfig.codexRpcInput 的 spawn-time 快照;RPC pane 在全局开关关闭后不会被误判成 terminal,保守 over-refuse 语义正确;
  • 新测试覆盖 spawn=true + live=false => refuse,能锁住该动态窗口;
  • global-config.readGlobalConfig() 固定为空配置后,Codex terminal 真值表不再依赖开发机 ~/.botmux/config.json,测试具备环境隔离;
  • 本轮最小 delta 仅涉及 worker-pool.tsfork-session.test.ts,未发现新回归。

结合前两轮复审,原 P1 沙盒继承、P2 frozen launch posture、持久化 larkAppId、source untouched 及 master 冲突解法均已闭环。

独立验证:

  • pnpm build:通过
  • pnpm exec vitest run --project unit test/fork-session.test.ts test/command-handler.test.ts test/transfer-session.test.ts test/relay-adapter.test.ts test/sandbox.test.ts test/cli-adapters.test.ts:6 files / 644 tests 全绿
  • git diff --check ac1b26add..6cd79c1b3:通过
  • PR 当前 MERGEABLE、无 CI checks

代码审核通过。仍未执行 merge;按仓库规范,合码前建议在 live daemon 对该 head 完成一次 /fork --create smoke,并在测试后恢复 canonical checkout。

@deepcoldy
deepcoldy merged commit 65c2f42 into deepcoldy:master Aug 4, 2026
deepcoldy added a commit that referenced this pull request Aug 4, 2026
#704 注册 /fork 命令进 slash-commands 真源却漏更 en/zh 文档,slash-commands-doc-sync
guard 因此在 master 上即为红(fork PR 无 CI 未拦住)。任何 rebase 到最新 master 的 PR
跑全量 pnpm test 都会被这条绊住。本 PR rebase 后顺带补齐,解 CI;与 Codex 兼容运行时
本身无关。
@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.

2 participants